diff --git a/package.json b/package.json index 801c7888..e1e4def1 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "bn.js": "^5.2.1", "buffer": "^6.0.3", "daisyui": "^4.11.1", - "ethers": "^6.13.4", "framer-motion": "^11.2.14", "postcss": "^8.4.38", "preact": "^10.12.1", @@ -57,7 +56,6 @@ "react-hook-form": "^7.51.5", "react-router-dom": "^6.8.1", "react-toastify": "^10.0.6", - "siwe": "^2.3.2", "stellar-base": "^11.0.1", "stellar-sdk": "^11.3.0", "tailwind": "^4.0.0", @@ -80,7 +78,7 @@ "@preact/preset-vite": "^2.9.1", "@types/big.js": "^6", "@types/bn.js": "^5", - "@types/node": "^18.14.1", + "@types/node": "^22.7.5", "@types/react": "^18.3.10", "@typescript-eslint/eslint-plugin": "^5.53.0", "@typescript-eslint/parser": "^5.53.0", diff --git a/signer-service/src/api/controllers/siwe.controller.js b/signer-service/src/api/controllers/siwe.controller.js index b07ddd62..ae43ff9f 100644 --- a/signer-service/src/api/controllers/siwe.controller.js +++ b/signer-service/src/api/controllers/siwe.controller.js @@ -17,14 +17,14 @@ exports.sendSiweMessage = async (req, res) => { exports.validateSiweSignature = async (req, res) => { const { nonce, signature, siweMessage } = req.body; try { - await verifyAndStoreSiweMessage(nonce, signature, siweMessage); + const address = await verifyAndStoreSiweMessage(nonce, signature, siweMessage); const token = { nonce, signature, }; - res.cookie('authToken', token, { + res.cookie(`authToken_${address}`, token, { httpOnly: true, secure: true, sameSite: 'Strict', diff --git a/signer-service/src/api/controllers/stellar.controller.js b/signer-service/src/api/controllers/stellar.controller.js index b3c299d2..cbf29b4e 100644 --- a/signer-service/src/api/controllers/stellar.controller.js +++ b/signer-service/src/api/controllers/stellar.controller.js @@ -56,36 +56,14 @@ exports.changeOpTransaction = async (req, res, next) => { exports.signSep10Challenge = async (req, res, next) => { try { - let maybeChallengeSignature; - let maybeNonce; - if (req.cookies?.authToken) { - maybeChallengeSignature = req.cookies.authToken.signature; - maybeNonce = req.cookies.authToken.nonce; - } - - if (Boolean(req.body.memo) && (!maybeChallengeSignature || !maybeNonce)) { - return res.status(401).json({ - error: 'Missing signature or nonce', - }); - } - let { masterClientSignature, masterClientPublic, clientSignature, clientPublic } = await signSep10Challenge( req.body.challengeXDR, req.body.outToken, req.body.clientPublicKey, - maybeChallengeSignature, - maybeNonce, + req.derivedMemo, // Derived in middleware ); return res.json({ masterClientSignature, masterClientPublic, clientSignature, clientPublic }); } catch (error) { - if (error.message.includes('Could not verify signature')) { - // Distinguish between failed signature check and other errors. - return res.status(401).json({ - error: 'Signature validation failed.', - details: error.message, - }); - } - console.error('Error in signSep10Challenge:', error); return res.status(500).json({ error: 'Failed to sign challenge', details: error.message }); } diff --git a/signer-service/src/api/helpers/memoDerivation.js b/signer-service/src/api/helpers/memoDerivation.js new file mode 100644 index 00000000..59f9292f --- /dev/null +++ b/signer-service/src/api/helpers/memoDerivation.js @@ -0,0 +1,21 @@ +const { keccak256 } = require('viem/utils'); +const { Keyring } = require('@polkadot/api'); + +// Returns the hash value for the address. If it's a polkadot address, it will return raw data of the address. +function getHashValueForAddress(address) { + if (address.startsWith('0x')) { + return address; + } else { + const keyring = new Keyring({ type: 'sr25519' }); + return keyring.decodeAddress(address); + } +} + +//A memo derivation. +async function deriveMemoFromAddress(address) { + const hashValue = getHashValueForAddress(address); + const hash = keccak256(hashValue); + return BigInt(hash).toString().slice(0, 15); +} + +module.exports = { deriveMemoFromAddress }; diff --git a/signer-service/src/api/helpers/siweMessageFormatter.js b/signer-service/src/api/helpers/siweMessageFormatter.js new file mode 100644 index 00000000..86ca8804 --- /dev/null +++ b/signer-service/src/api/helpers/siweMessageFormatter.js @@ -0,0 +1,53 @@ +class SignInMessage { + // fixed statement string + static LOGIN_MESSAGE = ' wants you to sign in with your account: '; + + constructor(fields) { + this.scheme = fields.scheme; + this.domain = fields.domain; + this.address = fields.address; + this.nonce = fields.nonce; + this.expirationTime = new Date(fields.expirationTime).toISOString(); + this.issuedAt = fields.issuedAt ? new Date(fields.issuedAt).toISOString() : new Date().toISOString(); + } + + toMessage() { + const header = `${this.domain}${SignInMessage.LOGIN_MESSAGE}${this.address}`; + + const body = `\nNonce: ${this.nonce}\nIssued At: ${this.issuedAt}\nExpiration Time: ${this.expirationTime}`; + + return `${header}\n\n${body}`; + } + + static fromMessage(message) { + const lines = message + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + const headerLine = lines.find((line) => line.includes(SignInMessage.LOGIN_MESSAGE)) || ''; + const [domain, address] = headerLine.split(SignInMessage.LOGIN_MESSAGE).map((part) => part.trim()); + + const nonceLine = lines.find((line) => line.startsWith('Nonce:')) || ''; + const nonce = nonceLine.split('Nonce:')[1]?.trim() || ''; + + const issuedAtLine = lines.find((line) => line.startsWith('Issued At:')) || ''; + const issuedAt = issuedAtLine.split('Issued At:')[1]?.trim(); // Can't really be empty. Constructor will default to current date if not defined. + const issuedAtMilis = new Date(issuedAt).getTime(); + + const expirationTimeLine = lines.find((line) => line.startsWith('Expiration Time:')) || ''; + const expirationTime = expirationTimeLine.split('Expiration Time:')[1]?.trim(); + const expirationTimeMilis = new Date(expirationTime).getTime(); + + return new SignInMessage({ + scheme: 'https', + domain, + address, + nonce, + expirationTime: expirationTimeMilis, + issuedAt: issuedAtMilis, + }); + } +} + +module.exports = { SignInMessage }; diff --git a/signer-service/src/api/middlewares/auth.js b/signer-service/src/api/middlewares/auth.js new file mode 100644 index 00000000..614eab48 --- /dev/null +++ b/signer-service/src/api/middlewares/auth.js @@ -0,0 +1,77 @@ +const { validateSignatureAndGetMemo } = require('../services/siwe.service'); + +const getMemoFromCookiesMiddleware = async (req, res, next) => { + // If the client didn't specify, we don't want to pass a derived memo even if a cookie was sent. + + req.derivedMemo = null; // Explicit overwrite to avoid tampering, defensive. + if (!Boolean(req.body.usesMemo)) { + return next(); + } + try { + const cookies = req.cookies; + const address = req.body.address; + // Default memo (represents no memo usage at all) + let resultMemo = null; + + for (const authToken in cookies) { + if (!authToken.startsWith('authToken_')) { + continue; + } + + //check if matches the address requested by client, otherwise ignore cookie. + if (!authToken.includes(address)) { + continue; + } + + try { + const token = cookies[authToken]; + const signature = token.signature; + const nonce = token.nonce; + + if (!signature || !nonce) { + continue; + } + + const memo = await validateSignatureAndGetMemo(nonce, signature); + console.log(memo); + + // First found first used + if (memo) { + resultMemo = memo; + break; + } + } catch (e) { + continue; + } + } + + // Client declared usage of memo, but it could not be derived from provided signatures. + if (Boolean(req.body.usesMemo) && !resultMemo) { + return res.status(401).json({ + error: 'Missing or invalid authentication token', + }); + } + + req.derivedMemo = resultMemo; + + next(); + } catch (err) { + if (err.message.includes('Could not verify signature')) { + // Distinguish between failed signature check and other errors. + return res.status(401).json({ + error: 'Signature validation failed.', + details: err.message, + }); + } + console.error(`Error in getMemoFromCookiesMiddleware: ${err.message}`); + + return res.status(500).json({ + error: 'Error while verifying signature', + details: err.message, + }); + } +}; + +module.exports = { + getMemoFromCookiesMiddleware, +}; diff --git a/signer-service/src/api/routes/v1/stellar.route.js b/signer-service/src/api/routes/v1/stellar.route.js index bc8f1d74..444e38ad 100644 --- a/signer-service/src/api/routes/v1/stellar.route.js +++ b/signer-service/src/api/routes/v1/stellar.route.js @@ -1,6 +1,7 @@ const express = require('express'); const controller = require('../../controllers/stellar.controller'); const { validateCreationInput, validateChangeOpInput, validateSep10Input } = require('../../middlewares/validators'); +const { getMemoFromCookiesMiddleware } = require('../../middlewares/auth'); const router = express.Router({ mergeParams: true }); @@ -8,7 +9,8 @@ router.route('/create').post(validateCreationInput, controller.createStellarTran router.route('/payment').post(validateChangeOpInput, controller.changeOpTransaction); -router.route('/sep10').post(validateSep10Input, controller.signSep10Challenge); +// Only authorized route. Does not reject the request, but rather passes the memo (if any) derived from a valid cookie in the request. +router.route('/sep10').post([validateSep10Input, getMemoFromCookiesMiddleware], controller.signSep10Challenge); router.route('/sep10').get(controller.getSep10MasterPK); diff --git a/signer-service/src/api/services/sep10.service.js b/signer-service/src/api/services/sep10.service.js index 667b9408..e351172d 100644 --- a/signer-service/src/api/services/sep10.service.js +++ b/signer-service/src/api/services/sep10.service.js @@ -1,49 +1,19 @@ const { Keypair } = require('stellar-sdk'); const { TransactionBuilder, Networks } = require('stellar-sdk'); const { fetchTomlValues } = require('../helpers/anchors'); -const { verifySiweMessage } = require('./siwe.service'); -const { keccak256 } = require('viem/utils'); const { TOKEN_CONFIG } = require('../../constants/tokenConfig'); const { SEP10_MASTER_SECRET, CLIENT_DOMAIN_SECRET } = require('../../constants/constants'); const NETWORK_PASSPHRASE = Networks.PUBLIC; -async function deriveMemoFromAddress(address) { - const hash = keccak256(address); - return BigInt(hash).toString().slice(0, 15); -} - -// we validate a challenge for a given nonce. From it we obtain the address and derive the memo -// we can then ensure that the memo is the same as the one we expect from the anchor challenge -const validateSignatureAndGetMemo = async (nonce, userChallengeSignature, memoEnabled) => { - if (!userChallengeSignature || !nonce || !memoEnabled) { - return null; // Default memo value when single stellar account is used - } - - let message; - try { - // initialSiweMessage must be undefined after an initial check, - // message must exist on the map. - message = await verifySiweMessage(nonce, userChallengeSignature, undefined); - } catch (e) { - throw new Error(`Could not verify signature: ${e.message}`); - } - - const memo = await deriveMemoFromAddress(message.address); - return memo; -}; - -exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, userChallengeSignature, nonce) => { +exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, memo) => { const masterStellarKeypair = Keypair.fromSecret(SEP10_MASTER_SECRET); const clientDomainStellarKeypair = Keypair.fromSecret(CLIENT_DOMAIN_SECRET); const { signingKey: anchorSigningKey } = await fetchTomlValues(TOKEN_CONFIG[outToken].tomlFileUrl); const { homeDomain, clientDomainEnabled, memoEnabled } = TOKEN_CONFIG[outToken]; - // Expected memo based on user's signature and nonce. - const memo = await validateSignatureAndGetMemo(nonce, userChallengeSignature, memoEnabled); - const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, NETWORK_PASSPHRASE); if (transactionSigned.source !== anchorSigningKey) { throw new Error(`Invalid source account: ${transactionSigned.source}`); diff --git a/signer-service/src/api/services/siwe.service.js b/signer-service/src/api/services/siwe.service.js index a3dda7a6..0b28f0c5 100644 --- a/signer-service/src/api/services/siwe.service.js +++ b/signer-service/src/api/services/siwe.service.js @@ -1,7 +1,11 @@ const siwe = require('siwe'); const { createPublicClient, http } = require('viem'); const { polygon } = require('viem/chains'); -const { DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, VALID_SIWE_CHAINS } = require('../../constants/constants'); +const { Keyring } = require('@polkadot/api'); +const { SignInMessage } = require('../helpers/siweMessageFormatter.js'); +const { signatureVerify } = require('@polkadot/util-crypto'); +const { deriveMemoFromAddress } = require('../helpers/memoDerivation'); +const { DEFAULT_LOGIN_EXPIRATION_TIME_HOURS } = require('../../constants/constants'); class ValidationError extends Error { constructor(message) { @@ -30,29 +34,34 @@ const verifySiweMessage = async (nonce, signature, initialSiweMessage) => { const siweMessage = existingSiweDataForNonce.siweMessage ? existingSiweDataForNonce.siweMessage - : new siwe.SiweMessage(initialSiweMessage); + : SignInMessage.fromMessage(initialSiweMessage); const address = existingSiweDataForNonce.address; if (!siweMessage) { throw new Error('Message must be provided as a parameter if it has not been initially validated.'); } - // Verify the integrity of the message - const publicClient = createPublicClient({ - chain: polygon, - transport: http(), - }); - - const valid = await publicClient.verifyMessage({ - address: address, - message: siweMessage.toMessage(), // Validation must be done on the message as string - signature, - }); + // verify with substrate (generic) or evm generic (using polygon public client) + let valid = false; + if (address.startsWith('0x')) { + const publicClient = createPublicClient({ + chain: polygon, + transport: http(), + }); + valid = await publicClient.verifyMessage({ + address: address, + message: siweMessage.toMessage(), // Validation must be done on the message as string + signature, + }); + } else if (address.startsWith('5')) { + valid = signatureVerify(siweMessage.toMessage(), signature, address); + } else { + throw new ValidationError(`verifySiweMessage: Invalid address format: ${address}`); + } if (!valid) { throw new ValidationError('Invalid signature'); } - // Perform additional checks to ensure message fields if (siweMessage.nonce !== nonce) { throw new ValidationError('Nonce mismatch'); @@ -68,16 +77,9 @@ const verifySiweMessage = async (nonce, signature, initialSiweMessage) => { // Since the message is created in the UI, we need to verify the fields of the message const verifyInitialMessageFields = (siweMessage) => { // Fields we validate on initial - const domain = siweMessage.domain; - const uri = siweMessage.uri; const scheme = siweMessage.scheme; // must be https - const chainId = siweMessage.chainId; const expirationTime = siweMessage.expirationTime; - if (!VALID_SIWE_CHAINS.includes(chainId)) { - throw new ValidationError('Incorrect chain ID'); - } - if (scheme !== 'https') { throw new ValidationError('Scheme must be https'); } @@ -122,6 +124,25 @@ const verifyAndStoreSiweMessage = async (nonce, signature, siweMessage) => { siweMessagesMap.delete(nonce); } }); + return siweData.address; +}; + +const validateSignatureAndGetMemo = async (nonce, userChallengeSignature) => { + if (!userChallengeSignature || !nonce) { + return null; // Default memo value when single stellar account is used + } + + let message; + try { + // initialSiweMessage must be undefined after an initial check, + // message must exist on the map. + message = await verifySiweMessage(nonce, userChallengeSignature, undefined); + } catch (e) { + throw new Error(`Could not verify signature: ${e.message}`); + } + + const memo = await deriveMemoFromAddress(message.address); + return memo; }; -module.exports = { verifySiweMessage, verifyAndStoreSiweMessage, createAndSendNonce }; +module.exports = { verifySiweMessage, verifyAndStoreSiweMessage, createAndSendNonce, validateSignatureAndGetMemo }; diff --git a/signer-service/src/constants/constants.js b/signer-service/src/constants/constants.js index ebac4e53..2dcb7bcc 100644 --- a/signer-service/src/constants/constants.js +++ b/signer-service/src/constants/constants.js @@ -9,7 +9,6 @@ const MOONBEAM_RECEIVER_CONTRACT_ADDRESS = '0x2AB52086e8edaB28193172209407FF9df1 const STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS = '2.5'; // Amount to send to the new stellar ephemeral account created const PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS = '0.1'; // Amount to send to the new pendulum ephemeral account created const DEFAULT_LOGIN_EXPIRATION_TIME_HOURS = 7 * 24; -const VALID_SIWE_CHAINS = [137]; // 137: Polygon require('dotenv').config(); @@ -36,5 +35,4 @@ module.exports = { SEP10_MASTER_SECRET, CLIENT_DOMAIN_SECRET, DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, - VALID_SIWE_CHAINS, }; diff --git a/src/contexts/siwe.tsx b/src/contexts/siwe.tsx index 406a4c16..59336306 100644 --- a/src/contexts/siwe.tsx +++ b/src/contexts/siwe.tsx @@ -1,6 +1,5 @@ import { createContext } from 'preact'; import { PropsWithChildren, useContext } from 'preact/compat'; -import { useVortexAccount } from '../hooks/useVortexAccount'; import { useSiweSignature } from '../hooks/useSignChallenge'; type UseSiweContext = ReturnType; @@ -16,8 +15,7 @@ export const useSiweContext = () => { }; export const SiweProvider = ({ children }: PropsWithChildren) => { - const { address } = useVortexAccount(); - const siweSignature = useSiweSignature(address); + const siweSignature = useSiweSignature(); return {children}; }; diff --git a/src/helpers/siweMessageFormatter.ts b/src/helpers/siweMessageFormatter.ts new file mode 100644 index 00000000..c4f94f0b --- /dev/null +++ b/src/helpers/siweMessageFormatter.ts @@ -0,0 +1,66 @@ +interface SignInMessageFields { + scheme: string; + domain: string; + address: string; + nonce: string; + expirationTime: number; + issuedAt?: number; +} + +export class SignInMessage { + public scheme: string; + public domain: string; + public address: string; + public nonce: string; + public expirationTime: string; + public issuedAt?: string; + + static LOGIN_MESSAGE = ' wants you to sign in with your account: '; + + constructor(fields: SignInMessageFields) { + this.scheme = fields.scheme; + this.domain = fields.domain; + this.address = fields.address; + this.nonce = fields.nonce; + this.expirationTime = new Date(fields.expirationTime).toISOString(); + this.issuedAt = fields.issuedAt ? new Date(fields.issuedAt).toISOString() : new Date().toISOString(); + } + + public toMessage(): string { + const header = `${this.domain}${SignInMessage.LOGIN_MESSAGE}${this.address}`; + + const body = `\nNonce: ${this.nonce}\nIssued At: ${this.issuedAt}\nExpiration Time: ${this.expirationTime}`; + + return `${header}\n\n${body}`; + } + + public static fromMessage(message: string): SignInMessage { + const lines = message + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + const headerLine = lines.find((line) => line.includes(SignInMessage.LOGIN_MESSAGE)) || ''; + const [domain, address] = headerLine.split(SignInMessage.LOGIN_MESSAGE).map((part) => part.trim()); + + const nonceLine = lines.find((line) => line.startsWith('Nonce:')) || ''; + const nonce = nonceLine.split('Nonce:')[1]?.trim() || ''; + + const issuedAtLine = lines.find((line) => line.startsWith('Issued At:')) || ''; + const issuedAt = issuedAtLine.split('Issued At:')[1]?.trim(); // Can't really be empty. Constructor will default to current date if not defined. + const issuedAtMilis = new Date(issuedAt).getTime(); + + const expirationTimeLine = lines.find((line) => line.startsWith('Expiration Time:')) || ''; + const expirationTime = expirationTimeLine.split('Expiration Time:')[1]?.trim(); + const expirationTimeMilis = new Date(expirationTime).getTime(); + + return new SignInMessage({ + scheme: 'https', + domain, + address, + nonce, + expirationTime: expirationTimeMilis, + issuedAt: issuedAtMilis, + }); + } +} diff --git a/src/hooks/offramp/useSubmitOfframp.ts b/src/hooks/offramp/useSubmitOfframp.ts index e8e466d0..70c1c5ad 100644 --- a/src/hooks/offramp/useSubmitOfframp.ts +++ b/src/hooks/offramp/useSubmitOfframp.ts @@ -80,6 +80,10 @@ export const useSubmitOfframp = ({ const outputToken = OUTPUT_TOKEN_CONFIG[outputTokenType]; const tomlValues = await fetchTomlValues(outputToken.tomlFileUrl!); + if (!address) { + throw new Error('useSubmitOfframp: Address must be defined at this stage'); + } + const { token: sep10Token, sep10Account } = await sep10( tomlValues, stellarEphemeralSecret, diff --git a/src/hooks/useSignChallenge.ts b/src/hooks/useSignChallenge.ts index c92390e3..6b56a68e 100644 --- a/src/hooks/useSignChallenge.ts +++ b/src/hooks/useSignChallenge.ts @@ -1,11 +1,10 @@ import { useState, useCallback } from 'preact/compat'; -import { useSignMessage } from 'wagmi'; -import { polygon } from 'wagmi/chains'; -import { SiweMessage } from 'siwe'; +import { SignInMessage } from '../helpers/siweMessageFormatter'; import { DEFAULT_LOGIN_EXPIRATION_TIME_HOURS } from '../constants/constants'; import { SIGNING_SERVICE_URL } from '../constants/constants'; import { storageKeys } from '../constants/localStorage'; +import { useVortexAccount } from './useVortexAccount'; export interface SiweSignatureData { signatureSet: boolean; @@ -13,24 +12,20 @@ export interface SiweSignatureData { } function createSiweMessage(address: string, nonce: string) { - // Make constants on config - const siweMessage = new SiweMessage({ + const siweMessage = new SignInMessage({ scheme: 'https', domain: window.location.host, - address, - statement: 'Please sign the message to login!', - uri: window.location.origin, - version: '1', - chainId: polygon.id, + address: address, nonce, - expirationTime: new Date(Date.now() + DEFAULT_LOGIN_EXPIRATION_TIME_HOURS * 60 * 60 * 1000).toISOString(), // Constructor in ms. + expirationTime: new Date(Date.now() + DEFAULT_LOGIN_EXPIRATION_TIME_HOURS * 60 * 60 * 1000).getTime(), // Constructor in ms. }); + return siweMessage.toMessage(); } -export function useSiweSignature(address?: string) { - const { signMessageAsync } = useSignMessage(); +export function useSiweSignature() { const [signingPending, setSigningPending] = useState(false); + const { address, getMessageSignature } = useVortexAccount(); // Used to wait for the modal interaction and/or return of the // signing promise. @@ -78,10 +73,11 @@ export function useSiweSignature(address?: string) { if (!messageResponse.ok) throw new Error('Failed to create message'); const { nonce } = await messageResponse.json(); + // Message in both string and object form const siweMessage = createSiweMessage(address, nonce); + const message = SignInMessage.fromMessage(siweMessage); - const message = new SiweMessage(siweMessage); - const signature = await signMessageAsync({ message: siweMessage }); + const signature = await getMessageSignature(siweMessage); const validationResponse = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/validate`, { method: 'POST', @@ -106,7 +102,7 @@ export function useSiweSignature(address?: string) { setSigningPending(false); setSignPromise(null); } - }, [address, storageKey, signMessageAsync, signPromise, setSigningPending, setSignPromise]); + }, [address, storageKey, signPromise, setSigningPending, setSignPromise, getMessageSignature]); // Handler for modal cancellation const handleCancel = useCallback(() => { diff --git a/src/hooks/useVortexAccount.ts b/src/hooks/useVortexAccount.ts index 362bcb21..7c9b3488 100644 --- a/src/hooks/useVortexAccount.ts +++ b/src/hooks/useVortexAccount.ts @@ -1,7 +1,9 @@ import { Networks, useNetwork } from '../contexts/network'; -import { useMemo } from 'preact/compat'; +import { useMemo, useCallback } from 'preact/compat'; import { usePolkadotWalletState } from '../contexts/polkadotWallet'; import { useAccount } from 'wagmi'; +import { Signer } from '@polkadot/types/types'; +import { useSignMessage } from 'wagmi'; // For the AssetHub network, we use a chain ID of -1. This is not a valid chain ID // but we just use it to differentiate between the EVM and Polkadot accounts. @@ -14,6 +16,7 @@ export const useVortexAccount = () => { const { walletAccount: polkadotWalletAccount } = usePolkadotWalletState(); const { isDisconnected: isEvmAccountDisconnected, chainId: evmChainId, address: evmAccountAddress } = useAccount(); + const { signMessageAsync } = useSignMessage(); const address = useMemo(() => { if (selectedNetwork === Networks.AssetHub) { @@ -47,10 +50,36 @@ export const useVortexAccount = () => { } }, [selectedNetwork]); + const getMessageSignature = useCallback( + async (siweMessage: string) => { + let signature; + + if (selectedNetwork === Networks.Polygon) { + signature = await signMessageAsync({ message: siweMessage }); + } else if (selectedNetwork === Networks.AssetHub) { + if (!polkadotWalletAccount) { + throw new Error('getMessageSignature: Polkadot wallet account not found. Wallet must be connected to sign.'); + } + const { signature: substrateSignature } = await (polkadotWalletAccount.signer as Signer).signRaw!({ + type: 'payload', + data: siweMessage, + address: polkadotWalletAccount.address, + }); + signature = substrateSignature; + } else { + throw new Error('getMessageSignature: Unsupported network.'); + } + + return signature; + }, + [polkadotWalletAccount, selectedNetwork, signMessageAsync], + ); + return { isDisconnected, chainId, address, type, + getMessageSignature, }; }; diff --git a/src/services/anchor/index.ts b/src/services/anchor/index.ts index 9044da05..721d9426 100644 --- a/src/services/anchor/index.ts +++ b/src/services/anchor/index.ts @@ -75,7 +75,7 @@ function getHashValueForAddress(address: string) { } } -//A memo derivation. TODO: Secure? how to check for collisions? +//A memo derivation. async function deriveMemoFromAddress(address: string) { const hashValue = getHashValueForAddress(address); const hash = keccak256(hashValue); @@ -142,7 +142,7 @@ export const sep10 = async ( tomlValues: TomlValues, stellarEphemeralSecret: string, outputToken: OutputTokenType, - address: string | undefined, + address: string, checkAndWaitForSignature: () => Promise, forceRefreshAndWaitForSignature: () => Promise, renderEvent: (event: string, status: EventStatus) => void, @@ -150,7 +150,7 @@ export const sep10 = async ( const { signingKey, webAuthEndpoint } = tomlValues; if (!exists(signingKey) || !exists(webAuthEndpoint)) { - throw new Error('Missing values in TOML file'); + throw new Error('sep10: Missing values in TOML file'); } const NETWORK_PASSPHRASE = Networks.PUBLIC; const ephemeralKeys = Keypair.fromSecret(stellarEphemeralSecret); @@ -159,24 +159,24 @@ export const sep10 = async ( const { usesMemo, supportsClientDomain } = OUTPUT_TOKEN_CONFIG[outputToken]; // will select either clientMaster or the ephemeral account - const { urlParams, sep10Account } = await getUrlParams(accountId, usesMemo, supportsClientDomain, address!); + const { urlParams, sep10Account } = await getUrlParams(accountId, usesMemo, supportsClientDomain, address); const challenge = await fetch(`${webAuthEndpoint}?${urlParams.toString()}`); if (challenge.status !== 200) { - throw new Error(`Failed to fetch SEP-10 challenge: ${challenge.statusText}`); + throw new Error(`sep10: Failed to fetch SEP-10 challenge: ${challenge.statusText}`); } const { transaction, network_passphrase } = await challenge.json(); if (network_passphrase !== NETWORK_PASSPHRASE) { - throw new Error(`Invalid network passphrase: ${network_passphrase}`); + throw new Error(`sep10: Invalid network passphrase: ${network_passphrase}`); } const transactionSigned = new Transaction(transaction, NETWORK_PASSPHRASE); if (transactionSigned.source !== signingKey) { - throw new Error(`Invalid source account: ${transactionSigned.source}`); + throw new Error(`sep10: Invalid source account: ${transactionSigned.source}`); } if (transactionSigned.sequence !== '0') { - throw new Error(`Invalid sequence number: ${transactionSigned.sequence}`); + throw new Error(`sep10: Invalid sequence number: ${transactionSigned.sequence}`); } if (usesMemo) { @@ -189,7 +189,8 @@ export const sep10 = async ( challengeXDR: transactionSigned.toXDR(), outToken: outputToken, clientPublicKey: sep10Account, - memo: usesMemo, + usesMemo, + address: address, }, ); diff --git a/src/services/signingService.tsx b/src/services/signingService.tsx index d70237a5..8ba7f28e 100644 --- a/src/services/signingService.tsx +++ b/src/services/signingService.tsx @@ -22,7 +22,8 @@ export interface SignerServiceSep10Request { challengeXDR: string; outToken: OutputTokenType; clientPublicKey: string; - memo?: boolean; + address: string; + usesMemo?: boolean; } // @todo: implement @tanstack/react-query @@ -51,13 +52,14 @@ export const fetchSep10Signatures = async ({ challengeXDR, outToken, clientPublicKey, - memo, + usesMemo, + address, }: SignerServiceSep10Request): Promise => { const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ challengeXDR, outToken, clientPublicKey, memo }), + body: JSON.stringify({ challengeXDR, outToken, clientPublicKey, usesMemo, address }), }); if (response.status !== 200) { if (response.status === 401) { diff --git a/yarn.lock b/yarn.lock index e4afabb4..15df6d94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,13 +12,6 @@ __metadata: languageName: node linkType: hard -"@adraffy/ens-normalize@npm:1.10.1": - version: 1.10.1 - resolution: "@adraffy/ens-normalize@npm:1.10.1" - checksum: 10/4cb938c4abb88a346d50cb0ea44243ab3574330c81d4f5aaaf9dfee584b96189d0faa404de0fcbef5a1b73909ea4ebc3e63d84bd23f9949e5c8d4085207a5091 - languageName: node - linkType: hard - "@adraffy/ens-normalize@npm:^1.10.1, @adraffy/ens-normalize@npm:^1.8.8": version: 1.11.0 resolution: "@adraffy/ens-normalize@npm:1.11.0" @@ -2874,7 +2867,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.5.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.5.0, @noble/hashes@npm:~1.5.0": +"@noble/hashes@npm:1.5.0, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:^1.5.0, @noble/hashes@npm:~1.5.0": version: 1.5.0 resolution: "@noble/hashes@npm:1.5.0" checksum: 10/da7fc7af52af7afcf59810a7eea6155075464ff462ffda2572dc6d57d53e2669b1ea2ec774e814f6273f1697e567f28d36823776c9bf7068cba2a2855140f26e @@ -4429,18 +4422,6 @@ __metadata: languageName: node linkType: hard -"@spruceid/siwe-parser@npm:^2.1.2": - version: 2.1.2 - resolution: "@spruceid/siwe-parser@npm:2.1.2" - dependencies: - "@noble/hashes": "npm:^1.1.2" - apg-js: "npm:^4.3.0" - uri-js: "npm:^4.4.1" - valid-url: "npm:^1.0.9" - checksum: 10/48459fe3b4d4b3091375ee87af700864c9023d4a1271d34850c6d27475e5d93a45d1efe8a71da367ad838b6921ced60c387d54737edd0a7a0d8e4e0a3cc2b8b7 - languageName: node - linkType: hard - "@stablelib/aead@npm:^1.0.1": version: 1.0.1 resolution: "@stablelib/aead@npm:1.0.1" @@ -4859,19 +4840,19 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^18.14.1": +"@types/node@npm:*": version: 18.16.3 resolution: "@types/node@npm:18.16.3" checksum: 10/4f4425ba49a46e7efa88346dc2ea63ea9aab88c04d244d77710fe8019587f3c163ba2ea1e4854fd20cb5cdec9abd3bfb324ad60aabddf395cee6b0b195bd57bb languageName: node linkType: hard -"@types/node@npm:22.7.5": - version: 22.7.5 - resolution: "@types/node@npm:22.7.5" +"@types/node@npm:^22.7.5": + version: 22.10.2 + resolution: "@types/node@npm:22.10.2" dependencies: - undici-types: "npm:~6.19.2" - checksum: 10/e8ba102f8c1aa7623787d625389be68d64e54fcbb76d41f6c2c64e8cf4c9f4a2370e7ef5e5f1732f3c57529d3d26afdcb2edc0101c5e413a79081449825c57ac + undici-types: "npm:~6.20.0" + checksum: 10/451adfefed4add58b069407173e616220fd4aaa3307cdde1bb701aa053b65b54ced8483db2f870dcedec7a58cb3b06101fbc19d85852716672ec1fd3660947fa languageName: node linkType: hard @@ -5764,13 +5745,6 @@ __metadata: languageName: node linkType: hard -"aes-js@npm:4.0.0-beta.5": - version: 4.0.0-beta.5 - resolution: "aes-js@npm:4.0.0-beta.5" - checksum: 10/8f745da2e8fb38e91297a8ec13c2febe3219f8383303cd4ed4660ca67190242ccfd5fdc2f0d1642fd1ea934818fb871cd4cc28d3f28e812e3dc6c3d0f1f97c24 - languageName: node - linkType: hard - "agent-base@npm:6, agent-base@npm:^6.0.2": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -5901,13 +5875,6 @@ __metadata: languageName: node linkType: hard -"apg-js@npm:^4.3.0": - version: 4.4.0 - resolution: "apg-js@npm:4.4.0" - checksum: 10/425f19096026742f5f156f26542b68f55602aa60f0c4ae2d72a0a888cf15fe9622223191202262dd8979d76a6125de9d8fd164d56c95fb113f49099f405eb08c - languageName: node - linkType: hard - "app-root-path@npm:2.1.0": version: 2.1.0 resolution: "app-root-path@npm:2.1.0" @@ -8567,21 +8534,6 @@ __metadata: languageName: node linkType: hard -"ethers@npm:^6.13.4": - version: 6.13.4 - resolution: "ethers@npm:6.13.4" - dependencies: - "@adraffy/ens-normalize": "npm:1.10.1" - "@noble/curves": "npm:1.2.0" - "@noble/hashes": "npm:1.3.2" - "@types/node": "npm:22.7.5" - aes-js: "npm:4.0.0-beta.5" - tslib: "npm:2.7.0" - ws: "npm:8.17.1" - checksum: 10/221192fed93f6b0553f3e5e72bfd667d676220577d34ff854f677e955d6f608e60636a9c08b5d54039c532a9b9b7056384f0d7019eb6e111d53175806f896ac6 - languageName: node - linkType: hard - "event-emitter@npm:^0.3.5": version: 0.3.5 resolution: "event-emitter@npm:0.3.5" @@ -13422,20 +13374,6 @@ __metadata: languageName: node linkType: hard -"siwe@npm:^2.3.2": - version: 2.3.2 - resolution: "siwe@npm:2.3.2" - dependencies: - "@spruceid/siwe-parser": "npm:^2.1.2" - "@stablelib/random": "npm:^1.0.1" - uri-js: "npm:^4.4.1" - valid-url: "npm:^1.0.9" - peerDependencies: - ethers: ^5.6.8 || ^6.0.8 - checksum: 10/6ea5ad9a9046fa916f85bf9d3092bc898f7e339d9c552714ea53ecc17daa4f78300c3cf7cc9c70fe57baf77dcee5cb38c6e1d692400b874cd84d297b1261918c - languageName: node - linkType: hard - "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -14249,13 +14187,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.7.0, tslib@npm:^2.7.0": - version: 2.7.0 - resolution: "tslib@npm:2.7.0" - checksum: 10/9a5b47ddac65874fa011c20ff76db69f97cf90c78cff5934799ab8894a5342db2d17b4e7613a087046bc1d133d21547ddff87ac558abeec31ffa929c88b7fce6 - languageName: node - linkType: hard - "tslib@npm:^2.0.0, tslib@npm:^2.4.0": version: 2.6.3 resolution: "tslib@npm:2.6.3" @@ -14277,6 +14208,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.7.0": + version: 2.7.0 + resolution: "tslib@npm:2.7.0" + checksum: 10/9a5b47ddac65874fa011c20ff76db69f97cf90c78cff5934799ab8894a5342db2d17b4e7613a087046bc1d133d21547ddff87ac558abeec31ffa929c88b7fce6 + languageName: node + linkType: hard + "tsscmp@npm:^1.0.5": version: 1.0.6 resolution: "tsscmp@npm:1.0.6" @@ -14483,10 +14421,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.19.2": - version: 6.19.8 - resolution: "undici-types@npm:6.19.8" - checksum: 10/cf0b48ed4fc99baf56584afa91aaffa5010c268b8842f62e02f752df209e3dea138b372a60a963b3b2576ed932f32329ce7ddb9cb5f27a6c83040d8cd74b7a70 +"undici-types@npm:~6.20.0": + version: 6.20.0 + resolution: "undici-types@npm:6.20.0" + checksum: 10/583ac7bbf4ff69931d3985f4762cde2690bb607844c16a5e2fbb92ed312fe4fa1b365e953032d469fa28ba8b224e88a595f0b10a449332f83fa77c695e567dbe languageName: node linkType: hard @@ -14699,7 +14637,7 @@ __metadata: languageName: node linkType: hard -"uri-js@npm:^4.2.2, uri-js@npm:^4.4.1": +"uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" dependencies: @@ -14834,13 +14772,6 @@ __metadata: languageName: node linkType: hard -"valid-url@npm:^1.0.9": - version: 1.0.9 - resolution: "valid-url@npm:1.0.9" - checksum: 10/343dfaf85eb3691dc8eb93f7bc007be1ee6091e6c6d1a68bf633cb85e4bf2930e34ca9214fb2c3330de5b652510b257a8ee1ff0a0a37df0925e9dabf93ee512d - languageName: node - linkType: hard - "valtio@npm:1.11.2": version: 1.11.2 resolution: "valtio@npm:1.11.2" @@ -15142,7 +15073,7 @@ __metadata: "@tanstack/react-query": "npm:^5.61.0" "@types/big.js": "npm:^6" "@types/bn.js": "npm:^5" - "@types/node": "npm:^18.14.1" + "@types/node": "npm:^22.7.5" "@types/react": "npm:^18.3.10" "@typescript-eslint/eslint-plugin": "npm:^5.53.0" "@typescript-eslint/parser": "npm:^5.53.0" @@ -15157,7 +15088,6 @@ __metadata: eslint: "npm:^8.34.0" eslint-plugin-react: "npm:^7.32.2" eslint-plugin-react-hooks: "npm:^4.6.0" - ethers: "npm:^6.13.4" framer-motion: "npm:^11.2.14" happy-dom: "npm:^14.12.3" husky: "npm:>=6" @@ -15171,7 +15101,6 @@ __metadata: react-hook-form: "npm:^7.51.5" react-router-dom: "npm:^6.8.1" react-toastify: "npm:^10.0.6" - siwe: "npm:^2.3.2" stellar-base: "npm:^11.0.1" stellar-sdk: "npm:^11.3.0" tailwind: "npm:^4.0.0"