From 72839fb84fd05aef108a0278057d47f300225aad Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 1 Nov 2024 15:23:08 -0300 Subject: [PATCH 01/61] merge with add-ars-offramp-support --- .prettierignore | 3 +- .../src/api/services/pendulum.service.js | 4 ++ .../src/api/services/stellar.service.js | 6 +- signer-service/src/constants/tokenConfig.js | 16 +++++ src/assets/coins/ARS.png | Bin 0 -> 57760 bytes src/constants/tokenConfig.ts | 25 +++++++- src/hooks/useGetIcon.tsx | 2 + src/hooks/useMainProcess.ts | 4 +- src/services/anchor/index.ts | 58 ++++++++++++++---- src/services/stellar/index.tsx | 7 ++- 10 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 src/assets/coins/ARS.png diff --git a/.prettierignore b/.prettierignore index b3f98d67..7814de00 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,13 +7,14 @@ preact/ internals/ docs/ .lighthouseci/ - +**/coins/* *.yml package-lock.json package.json yarn.lock favicon.png +.prettierignore CHANGELOG.md **/*.svg diff --git a/signer-service/src/api/services/pendulum.service.js b/signer-service/src/api/services/pendulum.service.js index c333af1a..33260db5 100644 --- a/signer-service/src/api/services/pendulum.service.js +++ b/signer-service/src/api/services/pendulum.service.js @@ -93,10 +93,14 @@ exports.sendStatusWithPk = async () => { await Promise.all( Object.entries(TOKEN_CONFIG).map(async ([token, tokenConfig]) => { console.log(`Checking token ${token} balance...`); + if (!tokenConfig.pendulumCurrencyId) { + throw new Error(`Token ${token} does not have a currency id.`); + } const tokenBalanceResponse = await apiData.api.query.tokens.accounts( fundingAccountKeypair.address, tokenConfig.pendulumCurrencyId, ); + console.log(tokenBalanceResponse?.free?.toString()); const tokenBalance = Big(tokenBalanceResponse?.free?.toString() ?? '0'); const maximumSubsidyAmountRaw = Big(tokenConfig.maximumSubsidyAmountRaw); diff --git a/signer-service/src/api/services/stellar.service.js b/signer-service/src/api/services/stellar.service.js index c4ff461b..7368fad3 100644 --- a/signer-service/src/api/services/stellar.service.js +++ b/signer-service/src/api/services/stellar.service.js @@ -49,7 +49,7 @@ async function buildCreationStellarTx(fundingSecret, ephemeralAccountId, maxTime .addOperation( Operation.changeTrust({ source: ephemeralAccountId, - asset: new Asset(tokenConfig.assetCode, tokenConfig.assetIssuer), + asset: new Asset(tokenConfig.assetCode.replace('\0', ''), tokenConfig.assetIssuer), }), ) .setTimebounds(0, maxTime) @@ -99,7 +99,7 @@ async function buildPaymentAndMergeTx( .addOperation( Operation.payment({ amount, - asset: new Asset(tokenConfig.assetCode, tokenConfig.assetIssuer), + asset: new Asset(tokenConfig.assetCode.replace('\0', ''), tokenConfig.assetIssuer), destination: offrampingAccount, }), ) @@ -113,7 +113,7 @@ async function buildPaymentAndMergeTx( }) .addOperation( Operation.changeTrust({ - asset: new Asset(tokenConfig.assetCode, tokenConfig.assetIssuer), + asset: new Asset(tokenConfig.assetCode.replace('\0', ''), tokenConfig.assetIssuer), limit: '0', }), ) diff --git a/signer-service/src/constants/tokenConfig.js b/signer-service/src/constants/tokenConfig.js index 85e1c494..4af69866 100644 --- a/signer-service/src/constants/tokenConfig.js +++ b/signer-service/src/constants/tokenConfig.js @@ -20,6 +20,22 @@ const TOKEN_CONFIG = { decimals: 6, maximumSubsidyAmountRaw: '1000000', // 1 unit }, + ars: { + tomlFileUrl: 'https://api.anclap.com/.well-known/stellar.toml', + assetCode: 'ARS\0', + assetIssuer: 'GCYE7C77EB5AWAA25R5XMWNI2EDOKTTFTTPZKM2SR5DI4B4WFD52DARS', + vaultAccountId: '6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs', + minWithdrawalAmount: '10000000000000', + maximumSubsidyAmountRaw: '100000000000000', // 100 unit ~ 0.1 USD @ Oct/2024 + pendulumCurrencyId: { + Stellar: { + AlphaNum4: { + code: '0x41525300', + issuer: '0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1', + }, + }, + }, + }, }; function getTokenConfigByAssetCode(cofig, assetCode) { diff --git a/src/assets/coins/ARS.png b/src/assets/coins/ARS.png new file mode 100644 index 0000000000000000000000000000000000000000..bfa721022d7dae47f0663ef4a3f1c3fbcfe08847 GIT binary patch literal 57760 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 = { }, }; -export type OutputTokenType = 'eurc'; +export type OutputTokenType = 'eurc' | 'ars'; export const OUTPUT_TOKEN_CONFIG: Record = { eurc: { tomlFileUrl: 'https://circle.anchor.mykobo.co/.well-known/stellar.toml', @@ -91,6 +91,29 @@ export const OUTPUT_TOKEN_CONFIG: Record = maxWithdrawalAmountRaw: '10000000000000000', offrampFeesBasisPoints: 125, }, + ars: { + tomlFileUrl: 'https://api.anclap.com/.well-known/stellar.toml', + decimals: 12, + fiat: { + assetIcon: 'ars', + symbol: 'ARS', + }, + stellarAsset: { + code: { + hex: '0x41525300', + string: 'ARS\0', + }, + issuer: { + hex: '0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1', + stellarEncoding: 'GCYE7C77EB5AWAA25R5XMWNI2EDOKTTFTTPZKM2SR5DI4B4WFD52DARS', + }, + }, + vaultAccountId: '6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs', + erc20WrapperAddress: '6cNENXUqHUeEGSm4psQCeykZiLXJL9VzMQnvSoouyeEEoJpe', + minWithdrawalAmountRaw: '100000000000000', // 100 ARS? + maxWithdrawalAmountRaw: '500000000000000000', // 500000 ARS + offrampFeesBasisPoints: 200, // 2% + }, }; export function getPendulumCurrencyId(outputTokenType: OutputTokenType) { diff --git a/src/hooks/useGetIcon.tsx b/src/hooks/useGetIcon.tsx index 43dd0ecc..512a3ca7 100644 --- a/src/hooks/useGetIcon.tsx +++ b/src/hooks/useGetIcon.tsx @@ -2,12 +2,14 @@ import EURC from '../assets/coins/EURC.png'; import EUR from '../assets/coins/EUR.svg'; import USDC from '../assets/coins/USDC.png'; import USDC_POLYGON from '../assets/coins/USDC_POLYGON.svg'; +import ARS from '../assets/coins/ARS.png'; const ICONS = { eurc: EURC, eur: EUR, usdc: USDC, polygonUSDC: USDC_POLYGON, + ars: ARS, }; export type AssetIconType = keyof typeof ICONS; diff --git a/src/hooks/useMainProcess.ts b/src/hooks/useMainProcess.ts index d75e8416..59b20b42 100644 --- a/src/hooks/useMainProcess.ts +++ b/src/hooks/useMainProcess.ts @@ -21,6 +21,7 @@ import Big from 'big.js'; import { createTransactionEvent, useEventsContext } from '../contexts/events'; import { showToast, ToastMessage } from '../helpers/notifications'; import { IAnchorSessionParams, ISep24Intermediate } from '../services/anchor'; +import { Keypair } from 'stellar-sdk'; export type SigningPhase = 'started' | 'approved' | 'signed' | 'finished'; @@ -125,6 +126,7 @@ export const useMainProcess = () => { try { const stellarEphemeralSecret = createStellarEphemeralSecret(); + const stellarEphemeralPublic = Keypair.fromSecret(stellarEphemeralSecret).publicKey(); const outputToken = OUTPUT_TOKEN_CONFIG[outputTokenType]; const tomlValues = await fetchTomlValues(outputToken.tomlFileUrl!); @@ -151,7 +153,7 @@ export const useMainProcess = () => { setAnchorSessionParams(anchorSessionParams); const fetchAndUpdateSep24Url = async () => { - const firstSep24Response = await sep24First(anchorSessionParams); + const firstSep24Response = await sep24First(anchorSessionParams, stellarEphemeralPublic); const url = new URL(firstSep24Response.url); url.searchParams.append('callback', 'postMessage'); firstSep24Response.url = url.toString(); diff --git a/src/services/anchor/index.ts b/src/services/anchor/index.ts index e733233b..60243395 100644 --- a/src/services/anchor/index.ts +++ b/src/services/anchor/index.ts @@ -1,8 +1,9 @@ import { Transaction, Keypair, Networks } from 'stellar-sdk'; import { EventStatus } from '../../components/GenericEvent'; -import { OutputTokenDetails } from '../../constants/tokenConfig'; +import { OutputTokenDetails, OutputTokenType } from '../../constants/tokenConfig'; import { fetchSigningServiceAccountId } from '../signingService'; import { config } from '../../config'; +import { fetchClientDomainSep10 } from '../signingService'; interface TomlValues { signingKey?: string; @@ -67,6 +68,8 @@ export const fetchTomlValues = async (TOML_FILE_URL: string): Promise void, ): Promise => { const { signingKey, webAuthEndpoint } = tomlValues; @@ -75,11 +78,19 @@ export const sep10 = async ( throw new Error('Missing values in TOML file'); } const NETWORK_PASSPHRASE = Networks.PUBLIC; - const ephemeralKeys = Keypair.fromSecret(stellarEphemeralSecret); + const ephemeralKeys = Keypair.fromSecret('///'); const accountId = ephemeralKeys.publicKey(); - const urlParams = new URLSearchParams({ - account: accountId, - }); + let urlParams; + if (requiresClientDomain) { + urlParams = new URLSearchParams({ + account: accountId, + client_domain: config.applicationClientDomain, + }); + } else { + urlParams = new URLSearchParams({ + account: accountId, + }); + } const challenge = await fetch(`${webAuthEndpoint}?${urlParams.toString()}`); if (challenge.status !== 200) { @@ -99,6 +110,17 @@ export const sep10 = async ( throw new Error(`Invalid sequence number: ${transactionSigned.sequence}`); } + // let signer-service sign the challenge to authenticate our + // client domain definition. + if (requiresClientDomain) { + const { clientSignature, clientPublic } = await fetchClientDomainSep10( + transactionSigned.toXDR(), + outTokenCode, + ephemeralKeys.publicKey(), + ); + transactionSigned.addSignature(clientPublic, clientSignature); + } + // More tests required, ignore for prototype transactionSigned.sign(ephemeralKeys); @@ -117,7 +139,7 @@ export const sep10 = async ( // print the ephemeral secret, for testing renderEvent( - `Unique recovery code (Please keep safe in case something fails): ${ephemeralKeys.secret()}`, + `Unique recovery code (Please keep safe in case something fails): ${'testing master account'}`, EventStatus.Waiting, ); return token; @@ -185,7 +207,10 @@ export async function sep12First(sessionParams: IAnchorSessionParams): Promise???? }*/ -export async function sep24First(sessionParams: IAnchorSessionParams): Promise { +export async function sep24First( + sessionParams: IAnchorSessionParams, + stellarPublic: string, +): Promise { if (config.test.mockSep24) { return { url: 'https://www.example.com', id: '1234' }; } @@ -194,10 +219,21 @@ export async function sep24First(sessionParams: IAnchorSessionParams): Promise Date: Mon, 4 Nov 2024 08:22:24 -0300 Subject: [PATCH 02/61] WIP --- .../src/api/controllers/stellar.controller.js | 1 + signer-service/src/api/helpers/anchors.js | 92 ++++++++++++++++- .../src/api/services/stellar.service.js | 99 +++++-------------- signer-service/src/index.js | 28 +++--- src/constants/constants.ts | 4 +- src/hooks/useMainProcess.ts | 6 ++ src/services/anchor/index.ts | 40 +++++--- src/services/signingService.tsx | 3 +- 8 files changed, 168 insertions(+), 105 deletions(-) diff --git a/signer-service/src/api/controllers/stellar.controller.js b/signer-service/src/api/controllers/stellar.controller.js index 5b0cd6a7..2a538300 100644 --- a/signer-service/src/api/controllers/stellar.controller.js +++ b/signer-service/src/api/controllers/stellar.controller.js @@ -63,6 +63,7 @@ exports.signSep10Challenge = async (req, res, next) => { req.body.outToken, req.body.clientPublicKey, req.body.memo, + req.body.userChallengeSignature, ); return res.json({ clientSignature, clientPublic }); } catch (error) { diff --git a/signer-service/src/api/helpers/anchors.js b/signer-service/src/api/helpers/anchors.js index 846a2986..32b35422 100644 --- a/signer-service/src/api/helpers/anchors.js +++ b/signer-service/src/api/helpers/anchors.js @@ -24,4 +24,94 @@ const fetchTomlValues = async (tomlFileUrl) => { }; }; -module.exports = { fetchTomlValues }; +const verifyClientDomainChallengeOps = async ( + challengeXDR, + networkPassphrase, + signingKey, + clientPublicKey, + memo, + expectedKey, +) => { + const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, networkPassphrase); + if (transactionSigned.source !== signingKey) { + throw new Error(`Invalid source account: ${transactionSigned.source}`); + } + if (transactionSigned.sequence !== '0') { + throw new Error(`Invalid sequence number: ${transactionSigned.sequence}`); + } + + // See https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md#success + // memo field should match and not be empty. + if (transactionSigned.memo.value !== memo) { + throw new Error('Memo does not match'); + } + + // Verify manage_data operations + const operations = transactionSigned.operations; + // Verify the first manage_data operation + const firstOp = operations[0]; + if (firstOp.type !== 'manageData') { + throw new Error('The first operation should be manageData'); + } + // We don't want to accept a challenge that would authorize as Application client account! + // We DO accept a challenge where the source is the master account + memo + if (firstOp.source !== clientPublicKey || firstOp.source == signingKey) { + throw new Error('First manageData operation must have the client account as the source'); + } + + if (firstOp.name !== expectedKey) { + throw new Error(`First manageData operation should have key '${expectedKey}'`); + } + if (!firstOp.value || firstOp.value.length !== 64) { + throw new Error('First manageData operation should have a 64-byte random nonce as value'); + } + + // Flags to check presence of required operations + let hasWebAuthDomain = false; + let hasClientDomain = false; + + // Verify extra manage_data operations + for (let i = 1; i < operations.length; i++) { + const op = operations[i]; + + if (op.type !== 'manageData') { + throw new Error('All operations should be manage_data operations'); + } + + // Verify web_auth_domain operation + if (op.name === 'web_auth_domain') { + hasWebAuthDomain = true; + if (op.source !== signingKey) { + throw new Error('web_auth_domain manage_data operation must have the server account as the source'); + } + + // value web_auth_domain but in bytes + // if (op.value !== 'web_auth_domain') { + // throw new Error(`web_auth_domain manageData operation should have value 'web_auth_domain'`); + // } + } + + // Verify client_domain operation (if applicable) + if (op.name === 'client_domain') { + hasClientDomain = true; + // Replace 'CLIENT_DOMAIN_ACCOUNT' with the actual client domain account public key + if (op.source !== keypair.publicKey()) { + throw new Error('client_domain manage_data operation must have the client domain account as the source'); + } + // Also in bytes first + // if (op.value !== 'client_domain') { + // throw new Error(`client_domain manageData operation should have value 'client_domain'`); + // } + } + } + + // the web_auth_domain and client_domain operation must be present + if (!hasWebAuthDomain) { + throw new Error('Transaction must contain a web_auth_domain manageData operation'); + } + if (!hasClientDomain) { + throw new Error('Transaction must contain a client_domain manageData operation'); + } +}; + +module.exports = { fetchTomlValues, verifyClientDomainChallengeOps }; diff --git a/signer-service/src/api/services/stellar.service.js b/signer-service/src/api/services/stellar.service.js index 7368fad3..d12349bc 100644 --- a/signer-service/src/api/services/stellar.service.js +++ b/signer-service/src/api/services/stellar.service.js @@ -8,7 +8,7 @@ const { CLIENT_SECRET, } = require('../../constants/constants'); const { TOKEN_CONFIG, getTokenConfigByAssetCode } = require('../../constants/tokenConfig'); -const { fetchTomlValues } = require('../helpers/anchors'); +const { fetchTomlValues, verifyClientDomainChallengeOps } = require('../helpers/anchors'); // Derive funding pk const FUNDING_PUBLIC_KEY = Keypair.fromSecret(FUNDING_SECRET).publicKey(); const horizonServer = new Horizon.Server(HORIZON_URL); @@ -152,92 +152,37 @@ async function sendStatusWithPk() { } } -async function signSep10Challenge(challengeXDR, outToken, clientPublicKey, memo) { +async function signSep10Challenge(challengeXDR, outToken, clientPublicKey, memo, userChallengeSignature) { const keypair = Keypair.fromSecret(CLIENT_SECRET); const { signingKey } = await fetchTomlValues(TOKEN_CONFIG[outToken].tomlFileUrl); - const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, NETWORK_PASSPHRASE); - if (transactionSigned.source !== signingKey) { - throw new Error(`Invalid source account: ${transactionSigned.source}`); - } - if (transactionSigned.sequence !== '0') { - throw new Error(`Invalid sequence number: ${transactionSigned.sequence}`); - } - - // See https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md#success - // memo field should match and not be empty. - if (transactionSigned.memo.value !== memo && memo !== '') { - throw new Error('Memo does not match'); - } - - // Verify manage_data operations - const operations = transactionSigned.operations; - // Verify the first manage_data operation - const firstOp = operations[0]; - if (firstOp.type !== 'manageData') { - throw new Error('The first operation should be manageData'); - } - // We don't want to accept a challenge that would authorize as Application client account! - if (firstOp.source !== clientPublicKey || firstOp.source == signingKey) { - throw new Error('First manageData operation must have the client account as the source'); - } + //TODO verify userChallengeSignature if outToken requires so..... + // const fields = await siweMessage.verify({signature}) must return the pk, we + // need to verify that the memo is the corresponding one to the pk. // TODO how to make the expected key based on outToken? with a simple manual map? const expectedKey = `mykobo.co auth`; - if (firstOp.name !== expectedKey) { - throw new Error(`First manageData operation should have key '${expectedKey}'`); - } - if (!firstOp.value || firstOp.value.length !== 64) { - throw new Error('First manageData operation should have a 64-byte random nonce as value'); - } - // Flags to check presence of required operations - let hasWebAuthDomain = false; - let hasClientDomain = false; - - // Verify extra manage_data operations - for (let i = 1; i < operations.length; i++) { - const op = operations[i]; - - if (op.type !== 'manageData') { - throw new Error('All operations should be manage_data operations'); - } - - // Verify web_auth_domain operation - if (op.name === 'web_auth_domain') { - hasWebAuthDomain = true; - if (op.source !== signingKey) { - throw new Error('web_auth_domain manage_data operation must have the server account as the source'); - } - - // value web_auth_domain but in bytes - // if (op.value !== 'web_auth_domain') { - // throw new Error(`web_auth_domain manageData operation should have value 'web_auth_domain'`); - // } - } - - // Verify client_domain operation (if applicable) - if (op.name === 'client_domain') { - hasClientDomain = true; - // Replace 'CLIENT_DOMAIN_ACCOUNT' with the actual client domain account public key - if (op.source !== keypair.publicKey()) { - throw new Error('client_domain manage_data operation must have the client domain account as the source'); - } - // Also in bytes first - // if (op.value !== 'client_domain') { - // throw new Error(`client_domain manageData operation should have value 'client_domain'`); - // } - } + let expectedMemoInOps; + if (!memo) { + expectedMemoInOps = ''; + } else { + expectedMemoInOps = memo; } - // the web_auth_domain and client_domain operation must be present - if (!hasWebAuthDomain) { - throw new Error('Transaction must contain a web_auth_domain manageData operation'); - } - if (!hasClientDomain) { - throw new Error('Transaction must contain a client_domain manageData operation'); - } + // TODO clientPublicKey must be either ephemeral for non memo case, or + // the master for the memo case + + // incorrect verification leads to failure + verifyClientDomainChallengeOps( + challengeXDR, + NETWORK_PASSPHRASE, + signingKey, + clientPublicKey, + expectedMemoInOps, + expectedKey, + ); const signature = transactionSigned.getKeypairSignature(keypair); return { clientSignature: signature, clientPublic: keypair.publicKey() }; diff --git a/signer-service/src/index.js b/signer-service/src/index.js index ee0283b1..1797a889 100755 --- a/signer-service/src/index.js +++ b/signer-service/src/index.js @@ -7,22 +7,22 @@ require('dotenv').config(); const { FUNDING_SECRET, PENDULUM_FUNDING_SEED, MOONBEAM_EXECUTOR_PRIVATE_KEY } = require('./constants/constants'); // stop the application if the funding secret key is not set -if (!FUNDING_SECRET) { - logger.error('FUNDING_SECRET not set in the environment variables'); - process.exit(1); -} +// if (!FUNDING_SECRET) { +// logger.error('FUNDING_SECRET not set in the environment variables'); +// process.exit(1); +// } -// stop the application if the Pendulum funding seed is not set -if (!PENDULUM_FUNDING_SEED) { - logger.error('PENDULUM_FUNDING_SEED not set in the environment variables'); - process.exit(1); -} +// // stop the application if the Pendulum funding seed is not set +// if (!PENDULUM_FUNDING_SEED) { +// logger.error('PENDULUM_FUNDING_SEED not set in the environment variables'); +// process.exit(1); +// } -// stop the application if the Moonbeam executor private key is not set -if (!MOONBEAM_EXECUTOR_PRIVATE_KEY) { - logger.error('MOONBEAM_EXECUTOR_PRIVATE_KEY not set in the environment variables'); - process.exit(1); -} +// // stop the application if the Moonbeam executor private key is not set +// if (!MOONBEAM_EXECUTOR_PRIVATE_KEY) { +// logger.error('MOONBEAM_EXECUTOR_PRIVATE_KEY not set in the environment variables'); +// process.exit(1); +// } // listen to requests app.listen(port, () => logger.info(`server started on port ${port} (${env})`)); diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 85325639..c0a9c8a3 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -14,4 +14,6 @@ export const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; export const TRANSFER_WAITING_TIME_SECONDS = 6000; export const SIGNING_SERVICE_URL = config.maybeSignerServiceUrl || - (config.isProd ? 'https://prototype-signer-service-polygon.pendulumchain.tech' : 'http://localhost:3000'); + (config.isProd + ? 'https://prototype-signer-service-polygon.pendulumchain.tech' + : 'https://prototype-signer-service-polygon.pendulumchain.tech'); // TODO rollbcak after testing diff --git a/src/hooks/useMainProcess.ts b/src/hooks/useMainProcess.ts index 59b20b42..7f13ff69 100644 --- a/src/hooks/useMainProcess.ts +++ b/src/hooks/useMainProcess.ts @@ -132,11 +132,17 @@ export const useMainProcess = () => { const tomlValues = await fetchTomlValues(outputToken.tomlFileUrl!); const requiresClientDomain = outputToken.requiresClientDomain; + + // get or derive memo: + // for mykobo memo should be '' + const memo = ''; // derived from user's start chain account or empty. + const sep10Token = await sep10( tomlValues, stellarEphemeralSecret, requiresClientDomain, outputTokenType, + memo, addEvent, ); diff --git a/src/services/anchor/index.ts b/src/services/anchor/index.ts index 60243395..2e2c6730 100644 --- a/src/services/anchor/index.ts +++ b/src/services/anchor/index.ts @@ -2,8 +2,8 @@ import { Transaction, Keypair, Networks } from 'stellar-sdk'; import { EventStatus } from '../../components/GenericEvent'; import { OutputTokenDetails, OutputTokenType } from '../../constants/tokenConfig'; import { fetchSigningServiceAccountId } from '../signingService'; -import { config } from '../../config'; import { fetchClientDomainSep10 } from '../signingService'; +import { config } from '../../config'; interface TomlValues { signingKey?: string; @@ -70,6 +70,7 @@ export const sep10 = async ( stellarEphemeralSecret: string, requiresClientDomain: boolean, outTokenCode: OutputTokenType, + memo: string, renderEvent: (event: string, status: EventStatus) => void, ): Promise => { const { signingKey, webAuthEndpoint } = tomlValues; @@ -78,18 +79,28 @@ export const sep10 = async ( throw new Error('Missing values in TOML file'); } const NETWORK_PASSPHRASE = Networks.PUBLIC; - const ephemeralKeys = Keypair.fromSecret('///'); + const ephemeralKeys = Keypair.fromSecret(stellarEphemeralSecret); const accountId = ephemeralKeys.publicKey(); + let urlParams; + //TODO requires testing, unclear what the anchors expect on each case. if (requiresClientDomain) { urlParams = new URLSearchParams({ - account: accountId, + account: `G.....`, // TODO master account we use to sign along with memo + memo: memo, client_domain: config.applicationClientDomain, }); } else { - urlParams = new URLSearchParams({ - account: accountId, - }); + if (memo !== '') { + urlParams = new URLSearchParams({ + account: accountId, + memo: memo, + }); + } else { + urlParams = new URLSearchParams({ + account: accountId, + }); + } } const challenge = await fetch(`${webAuthEndpoint}?${urlParams.toString()}`); @@ -111,19 +122,26 @@ export const sep10 = async ( } // let signer-service sign the challenge to authenticate our - // client domain definition. - if (requiresClientDomain) { + // client domain definition AND/OR sign the transaction with master identifying memo + if (requiresClientDomain || memo !== '') { const { clientSignature, clientPublic } = await fetchClientDomainSep10( transactionSigned.toXDR(), outTokenCode, ephemeralKeys.publicKey(), + memo, + undefined, ); transactionSigned.addSignature(clientPublic, clientSignature); + + // TODO we are missing the signature from the master account } // More tests required, ignore for prototype - transactionSigned.sign(ephemeralKeys); + // Ephemeral only signs if NOT identified by memo + if (memo === '') { + transactionSigned.sign(ephemeralKeys); + } const jwt = await fetch(webAuthEndpoint, { method: 'POST', @@ -136,7 +154,6 @@ export const sep10 = async ( } const { token } = await jwt.json(); - // print the ephemeral secret, for testing renderEvent( `Unique recovery code (Please keep safe in case something fails): ${'testing master account'}`, @@ -224,9 +241,10 @@ export async function sep24First( // TODO change if (sessionParams.tokenConfig.stellarAsset.code.string === 'ARS\0') { sep24Params = new URLSearchParams({ - asset_code: sessionParams.tokenConfig.stellarAsset.code.string.replace('\0', ''), + asset_code: 'ARS', amount: sessionParams.offrampAmount, account: stellarPublic, + // TODO do we also need memo here? we shouldn't even need account, in theory, so we probably need it also. }); } else { sep24Params = new URLSearchParams({ diff --git a/src/services/signingService.tsx b/src/services/signingService.tsx index 664702d4..4778e9b6 100644 --- a/src/services/signingService.tsx +++ b/src/services/signingService.tsx @@ -42,12 +42,13 @@ export const fetchClientDomainSep10 = async ( outToken: OutputTokenType, clientPublicKey: string, memo: string, + maybeChallengeSignature: any, ): Promise => { // TODO remove after testing. const response = await fetch(`http://localhost:3000/v1/stellar/sep10`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ challengeXDR, outToken, clientPublicKey, memo }), + body: JSON.stringify({ challengeXDR, outToken, clientPublicKey, memo, maybeChallengeSignature }), }); if (response.status !== 200) { throw new Error(`Failed to fetch SEP10 challenge from server: ${response.statusText}`); From db71c336e32f3a1d90f8667c589d892d5875062c Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 5 Nov 2024 12:42:28 -0300 Subject: [PATCH 03/61] add message creation endpoint for testing --- signer-service/package.json | 2 + .../src/api/controllers/siwe.controller.js | 14 ++ signer-service/src/api/routes/v1/index.js | 7 +- .../src/api/routes/v1/siwe.route.js | 8 ++ .../src/api/services/siwe.service.js | 42 ++++++ .../src/api/services/stellar.service.js | 8 ++ signer-service/yarn.lock | 135 +++++++++++++++++- 7 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 signer-service/src/api/controllers/siwe.controller.js create mode 100644 signer-service/src/api/routes/v1/siwe.route.js create mode 100644 signer-service/src/api/services/siwe.service.js diff --git a/signer-service/package.json b/signer-service/package.json index 6dc0e309..2b6a4ddd 100644 --- a/signer-service/package.json +++ b/signer-service/package.json @@ -29,6 +29,7 @@ "cors": "^2.8.3", "cross-env": "^7.0.3", "dotenv": "^16.4.5", + "ethers": "^6.13.4", "express": "^5.0.1", "express-rate-limit": "^6.7.0", "express-validation": "^1.0.2", @@ -40,6 +41,7 @@ "method-override": "^3.0.0", "mongoose": "^5.2.17", "morgan": "^1.8.1", + "siwe": "^2.3.2", "stellar-sdk": "^11.3.0", "viem": "^2.21.3", "winston": "^3.1.0" diff --git a/signer-service/src/api/controllers/siwe.controller.js b/signer-service/src/api/controllers/siwe.controller.js new file mode 100644 index 00000000..3d4353fb --- /dev/null +++ b/signer-service/src/api/controllers/siwe.controller.js @@ -0,0 +1,14 @@ +const { createAndSendSiweMessage } = require('../services/siwe.service'); + +exports.sendSiweMessage = async (req, res) => { + const { walletAddress } = req.body; + try { + const siweMessage = await createAndSendSiweMessage(walletAddress); + return res.json({ + siweMessage, + }); + } catch (e) { + console.error(e); + return res.status(500).json({ error: 'Error while creating siwe message' }); + } +}; diff --git a/signer-service/src/api/routes/v1/index.js b/signer-service/src/api/routes/v1/index.js index 08a8dbc3..710bb64d 100644 --- a/signer-service/src/api/routes/v1/index.js +++ b/signer-service/src/api/routes/v1/index.js @@ -7,7 +7,7 @@ const storageRoutes = require('./storage.route'); const emailRoutes = require('./email.route'); const ratingRoutes = require('./rating.route'); const subsidizeRoutes = require('./subsidize.route'); - +const siweRoutes = require('./siwe.route'); const router = express.Router({ mergeParams: true }); const { sendStatusWithPk: sendStellarStatusWithPk } = require('../../services/stellar.service'); const { sendStatusWithPk: sendPendulumStatusWithPk } = require('../../services/pendulum.service'); @@ -70,4 +70,9 @@ router.use('/subsidize', subsidizeRoutes); */ router.use('/rating', ratingRoutes); +/** + * POST v1/siwe + */ +router.use('/siwe', siweRoutes); + module.exports = router; diff --git a/signer-service/src/api/routes/v1/siwe.route.js b/signer-service/src/api/routes/v1/siwe.route.js new file mode 100644 index 00000000..bd79fe72 --- /dev/null +++ b/signer-service/src/api/routes/v1/siwe.route.js @@ -0,0 +1,8 @@ +const express = require('express'); +const controller = require('../../controllers/siwe.controller'); + +const router = express.Router({ mergeParams: true }); + +router.route('/create').post(controller.sendSiweMessage); + +module.exports = router; diff --git a/signer-service/src/api/services/siwe.service.js b/signer-service/src/api/services/siwe.service.js new file mode 100644 index 00000000..3b892d61 --- /dev/null +++ b/signer-service/src/api/services/siwe.service.js @@ -0,0 +1,42 @@ +const siwe = require('siwe'); + +// Make constants on config +const scheme = 'https'; +const domain = 'localhost'; +const origin = 'https://localhost/login'; +const statement = 'Please sign the message to login and give me your money'; + +// Set that will hold the siwe messages sent + nonce we defined +const siweMessageSet = new Set(); + +exports.createAndSendSiweMessage = async (address) => { + const nonce = siwe.generateNonce(); + const siweMessage = new siwe.SiweMessage({ + scheme, + domain, + address, + statement, + uri: origin, + version: '1', + chainId: '1', + nonce, + expirationTime: new Date().toISOString(), + }); + const preparedMessage = siweMessage.prepareMessage(); + siweMessageSet.add({ nonce, preparedMessage }); + + return preparedMessage; +}; + +exports.verifySiweMessage = async (messageFromUser, signature) => { + const fields = await messageFromUser.verify({ signature }); + const expectedSentMessage = { nonce: fields.data.nonce, messageFromUser }; + + const isSiweMessage = siweMessageSet.has(expectedSentMessage); + if (!isSiweMessage) { + throw new Error('Message not found, we have not send this message or nonce is incorrect.'); + } + siweMessageSet.delete(expectedSentMessage); + + return fields; +}; diff --git a/signer-service/src/api/services/stellar.service.js b/signer-service/src/api/services/stellar.service.js index d12349bc..9734338f 100644 --- a/signer-service/src/api/services/stellar.service.js +++ b/signer-service/src/api/services/stellar.service.js @@ -152,6 +152,14 @@ async function sendStatusWithPk() { } } +// This function will receive the challenge in xdr format from the UI (relayed from the anchor), and will +// also receive the signature of our challenge message. From it we can derive the public key of the client +// and from the public key we can derive the memo. We will then verify that the memo (if exists) is the expected one +// given a particular derivation method. + +// Security assurances: we therefore assure that the client is in possession of the private key corresponding to the +// public from which the memo is derived. This signature(s) we provide are ONLY useful for getting a JWT from the anchor +// corresponding to the "virtual" account represented by master:memo. async function signSep10Challenge(challengeXDR, outToken, clientPublicKey, memo, userChallengeSignature) { const keypair = Keypair.fromSecret(CLIENT_SECRET); diff --git a/signer-service/yarn.lock b/signer-service/yarn.lock index 68f64572..6dcf088c 100644 --- a/signer-service/yarn.lock +++ b/signer-service/yarn.lock @@ -12,6 +12,13 @@ __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 + "@babel/code-frame@npm:7.12.11": version: 7.12.11 resolution: "@babel/code-frame@npm:7.12.11" @@ -133,6 +140,15 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.2.0": + version: 1.2.0 + resolution: "@noble/curves@npm:1.2.0" + dependencies: + "@noble/hashes": "npm:1.3.2" + checksum: 10/94e02e9571a9fd42a3263362451849d2f54405cb3ce9fa7c45bc6b9b36dcd7d1d20e2e1e14cfded24937a13d82f1e60eefc4d7a14982ce0bc219a9fc0f51d1f9 + languageName: node + linkType: hard + "@noble/curves@npm:1.4.0": version: 1.4.0 resolution: "@noble/curves@npm:1.4.0" @@ -160,6 +176,13 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:1.3.2": + version: 1.3.2 + resolution: "@noble/hashes@npm:1.3.2" + checksum: 10/685f59d2d44d88e738114b71011d343a9f7dce9dfb0a121f1489132f9247baa60bc985e5ec6f3213d114fbd1e1168e7294644e46cbd0ce2eba37994f28eeb51b + languageName: node + linkType: hard + "@noble/hashes@npm:1.4.0, @noble/hashes@npm:~1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" @@ -167,7 +190,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.5.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.5.0": +"@noble/hashes@npm:1.5.0, @noble/hashes@npm:^1.1.2, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:^1.4.0, @noble/hashes@npm:~1.5.0": version: 1.5.0 resolution: "@noble/hashes@npm:1.5.0" checksum: 10/da7fc7af52af7afcf59810a7eea6155075464ff462ffda2572dc6d57d53e2669b1ea2ec774e814f6273f1697e567f28d36823776c9bf7068cba2a2855140f26e @@ -211,6 +234,7 @@ __metadata: eslint-config-airbnb-base: "npm:^14.2.0" eslint-config-prettier: "npm:^8.8.0" eslint-plugin-import: "npm:^2.2.0" + ethers: "npm:^6.13.4" express: "npm:^5.0.1" express-rate-limit: "npm:^6.7.0" express-validation: "npm:^1.0.2" @@ -226,6 +250,7 @@ __metadata: morgan: "npm:^1.8.1" nodemon: "npm:^2.0.1" prettier: "npm:^2.8.7" + siwe: "npm:^2.3.2" stellar-sdk: "npm:^11.3.0" viem: "npm:^2.21.3" winston: "npm:^3.1.0" @@ -781,6 +806,51 @@ __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/binary@npm:^1.0.1": + version: 1.0.1 + resolution: "@stablelib/binary@npm:1.0.1" + dependencies: + "@stablelib/int": "npm:^1.0.1" + checksum: 10/c5ed769e2b5d607a5cdb72d325fcf98db437627862fade839daad934bd9ccf02a6f6e34f9de8cb3b18d72fce2ba6cc019a5d22398187d7d69d2607165f27f8bf + languageName: node + linkType: hard + +"@stablelib/int@npm:^1.0.1": + version: 1.0.1 + resolution: "@stablelib/int@npm:1.0.1" + checksum: 10/65bfbf50a382eea70c68e05366bf379cfceff8fbc076f1c267ef2f2411d7aed64fd140c415cb6c29f19a3910d3b8b7805d4b32ad5721a5007a8e744a808c7ae3 + languageName: node + linkType: hard + +"@stablelib/random@npm:^1.0.1": + version: 1.0.2 + resolution: "@stablelib/random@npm:1.0.2" + dependencies: + "@stablelib/binary": "npm:^1.0.1" + "@stablelib/wipe": "npm:^1.0.1" + checksum: 10/f5ace0a588dc4c21f01cb85837892d4c872e994ae77a58a8eb7dd61aa0b26fb1e9b46b0445e71af57d963ef7d9f5965c64258fc0d04df7b2947bc48f2d3560c5 + languageName: node + linkType: hard + +"@stablelib/wipe@npm:^1.0.1": + version: 1.0.1 + resolution: "@stablelib/wipe@npm:1.0.1" + checksum: 10/287802eb146810a46ba72af70b82022caf83a8aeebde23605f5ee0decf64fe2b97a60c856e43b6617b5801287c30cfa863cfb0469e7fcde6f02d143cf0c6cbf4 + languageName: node + linkType: hard + "@stellar/js-xdr@npm:^3.1.1": version: 3.1.2 resolution: "@stellar/js-xdr@npm:3.1.2" @@ -907,6 +977,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:22.7.5": + version: 22.7.5 + resolution: "@types/node@npm:22.7.5" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10/e8ba102f8c1aa7623787d625389be68d64e54fcbb76d41f6c2c64e8cf4c9f4a2370e7ef5e5f1732f3c57529d3d26afdcb2edc0101c5e413a79081449825c57ac + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -981,6 +1060,13 @@ __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:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": version: 7.1.1 resolution: "agent-base@npm:7.1.1" @@ -1101,6 +1187,13 @@ __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 + "argparse@npm:^1.0.7": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -2461,6 +2554,21 @@ __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 + "eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" @@ -5431,6 +5539,20 @@ __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" @@ -5890,7 +6012,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0": +"tslib@npm:2.7.0, tslib@npm:^2.1.0": version: 2.7.0 resolution: "tslib@npm:2.7.0" checksum: 10/9a5b47ddac65874fa011c20ff76db69f97cf90c78cff5934799ab8894a5342db2d17b4e7613a087046bc1d133d21547ddff87ac558abeec31ffa929c88b7fce6 @@ -6058,7 +6180,7 @@ __metadata: languageName: node linkType: hard -"uri-js@npm:^4.2.2": +"uri-js@npm:^4.2.2, uri-js@npm:^4.4.1": version: 4.4.1 resolution: "uri-js@npm:4.4.1" dependencies: @@ -6104,6 +6226,13 @@ __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 + "validate-npm-package-license@npm:^3.0.1": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4" From d8f2b2cab5bf8600922997d203ecc82a804d6193 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 5 Nov 2024 16:08:44 -0300 Subject: [PATCH 04/61] add simple solution for message signin --- src/components/SignIn/index.tsx | 37 ++++++++++++++++++++++++++ src/contexts/events.tsx | 1 + src/hooks/useSignChallenge.ts | 46 +++++++++++++++++++++++++++++++++ src/pages/swap/index.tsx | 3 +++ 4 files changed, 87 insertions(+) create mode 100644 src/components/SignIn/index.tsx create mode 100644 src/hooks/useSignChallenge.ts diff --git a/src/components/SignIn/index.tsx b/src/components/SignIn/index.tsx new file mode 100644 index 00000000..f61d6e6c --- /dev/null +++ b/src/components/SignIn/index.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useAccount } from 'wagmi'; +import { useSignChallenge } from '../../hooks/useSignChallenge'; +import { Modal } from 'react-daisyui'; + +export function SignInModal() { + const { address } = useAccount(); + console.log('address:', address); + + const { isModalOpen, handleSiweSignIn, closeModal } = useSignChallenge(address); + + if (!isModalOpen) { + return null; + } + + return ( + + + Sign In + + + +

Please sign the message to log-in

+
+ + + + +
+ ); +} diff --git a/src/contexts/events.tsx b/src/contexts/events.tsx index c429e115..ff80726c 100644 --- a/src/contexts/events.tsx +++ b/src/contexts/events.tsx @@ -108,6 +108,7 @@ type EventType = TrackableEvent['event']; type UseEventsContext = ReturnType; const useEvents = () => { const { address } = useAccount(); + const previousAddress = useRef<`0x${string}` | undefined>(undefined); const userClickedState = useRef(false); diff --git a/src/hooks/useSignChallenge.ts b/src/hooks/useSignChallenge.ts new file mode 100644 index 00000000..500b5391 --- /dev/null +++ b/src/hooks/useSignChallenge.ts @@ -0,0 +1,46 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useSignMessage } from 'wagmi'; + +export function useSignChallenge(address: `0x${string}` | undefined) { + const { signMessageAsync } = useSignMessage(); + + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleSiweSignIn = useCallback(async () => { + try { + // const response = await fetch('/api/siwe-challenge'); + // const { message } = await response.json(); + const message = 'Please sign the message to log in'; + const signature = await signMessageAsync({ message }); + + console.log('SIWE signature:', signature); + + localStorage.setItem(`siwe-signature-${address}`, signature); + + setIsModalOpen(false); + } catch (error) { + console.error('Error during SIWE sign-in:', error); + } + }, [address, signMessageAsync]); + + useEffect(() => { + if (!address) { + return; + } + + const storedSignature = localStorage.getItem(`siwe-signature-${address}`); + console.log('Stored SIWE signature:', storedSignature); + if (!storedSignature) { + setIsModalOpen(true); + console.log('Opening SIWE sign-in modal'); + } else { + setIsModalOpen(false); + } + }, [address]); + + return { + isModalOpen, + handleSiweSignIn, + closeModal: () => setIsModalOpen(false), + }; +} diff --git a/src/pages/swap/index.tsx b/src/pages/swap/index.tsx index e65d95ce..54ed5564 100644 --- a/src/pages/swap/index.tsx +++ b/src/pages/swap/index.tsx @@ -34,6 +34,8 @@ import { initialChecks } from '../../services/initialChecks'; import { getVaultsForCurrency } from '../../services/polkadot/spacewalk'; import { SPACEWALK_REDEEM_SAFETY_MARGIN } from '../../constants/constants'; +import { SignInModal } from '../../components/SignIn'; + const Arrow = () => (
@@ -335,6 +337,7 @@ export const SwapPage = () => { const main = (
+
Date: Wed, 6 Nov 2024 16:53:36 -0300 Subject: [PATCH 05/61] work in progress memo solution --- .../src/api/controllers/siwe.controller.js | 3 +- .../src/api/controllers/stellar.controller.js | 3 +- .../src/api/services/sep10.service.js | 32 +++++++++++++-- .../src/api/services/siwe.service.js | 38 +++++++++++------- src/constants/tokenConfig.ts | 3 ++ src/hooks/useMainProcess.ts | 8 ++-- src/hooks/useSignChallenge.ts | 18 ++++++--- src/services/anchor/index.ts | 39 ++++++++++++++++--- src/services/signingService.tsx | 5 ++- 9 files changed, 114 insertions(+), 35 deletions(-) diff --git a/signer-service/src/api/controllers/siwe.controller.js b/signer-service/src/api/controllers/siwe.controller.js index 3d4353fb..588073a5 100644 --- a/signer-service/src/api/controllers/siwe.controller.js +++ b/signer-service/src/api/controllers/siwe.controller.js @@ -3,9 +3,10 @@ const { createAndSendSiweMessage } = require('../services/siwe.service'); exports.sendSiweMessage = async (req, res) => { const { walletAddress } = req.body; try { - const siweMessage = await createAndSendSiweMessage(walletAddress); + const { siweMessage, nonce } = await createAndSendSiweMessage(walletAddress); return res.json({ siweMessage, + nonce, }); } catch (e) { console.error(e); diff --git a/signer-service/src/api/controllers/stellar.controller.js b/signer-service/src/api/controllers/stellar.controller.js index 4dd8ab30..5ae9c714 100644 --- a/signer-service/src/api/controllers/stellar.controller.js +++ b/signer-service/src/api/controllers/stellar.controller.js @@ -58,7 +58,8 @@ exports.signSep10Challenge = async (req, res, next) => { req.body.challengeXDR, req.body.outToken, req.body.clientPublicKey, - req.body.userChallengeSignature, + req.body.maybeChallengeSignature, + req.body.maybeNonce, ); return res.json({ masterSignature, masterPublic, clientSignature, clientPublic }); } catch (error) { diff --git a/signer-service/src/api/services/sep10.service.js b/signer-service/src/api/services/sep10.service.js index 7eea25eb..8b00c50f 100644 --- a/signer-service/src/api/services/sep10.service.js +++ b/signer-service/src/api/services/sep10.service.js @@ -1,16 +1,42 @@ const { Keypair } = require('stellar-sdk'); const { TransactionBuilder, Networks } = require('stellar-sdk'); const { fetchTomlValues } = require('../helpers/anchors'); +const { verifySiweMessage } = require('./siwe.service'); const { TOKEN_CONFIG } = require('../../constants/tokenConfig'); const { SEP10_MASTER_SECRET, CLIENT_SECRET } = require('../../constants/constants'); const NETWORK_PASSPHRASE = Networks.PUBLIC; -exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey) => { +const getAndValidateMemo = async (nonce, userChallengeSignature) => { + if (!userChallengeSignature || !nonce) { + return ''; + } + const siweData = await verifySiweMessage(nonce, userChallengeSignature); + + const memo = deriveMemoFromAddress(siweData.address); + return memo; +}; + +const deriveMemoFromAddress = (address) => { + return address.slice(5, 15).replace(/\D/g, ''); +}; + +exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, userChallengeSignature, nonce) => { const masterStellarKeypair = Keypair.fromSecret(SEP10_MASTER_SECRET); const clientDomainStellarKeypair = Keypair.fromSecret(CLIENT_SECRET); + // 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 + + let memo = ''; // Default memo value when single stellar account is used + try { + memo = getAndValidateMemo(nonce, userChallengeSignature); + } catch (e) { + console.log(e); + throw new Error(`Invalid evm account verification`); + } + const { signingKey: anchorSigningKey } = await fetchTomlValues(TOKEN_CONFIG[outToken].tomlFileUrl); const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, NETWORK_PASSPHRASE); @@ -24,8 +50,8 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey) => // See https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md#success // memo field should be empty as we assume (in this implementation) that we use the ephemeral (or master, in case of ARS) // to authenticate. But no memo sub account derivation. - if (transactionSigned.memo.value === '') { - throw new Error('Memo does not match'); + if (transactionSigned.memo.value === memo) { + throw new Error('Memo does not match with specified user signature or address. Could not validate.'); } const { operations } = transactionSigned; diff --git a/signer-service/src/api/services/siwe.service.js b/signer-service/src/api/services/siwe.service.js index 3b892d61..2e13995d 100644 --- a/signer-service/src/api/services/siwe.service.js +++ b/signer-service/src/api/services/siwe.service.js @@ -4,10 +4,10 @@ const siwe = require('siwe'); const scheme = 'https'; const domain = 'localhost'; const origin = 'https://localhost/login'; -const statement = 'Please sign the message to login and give me your money'; +const statement = 'Please sign the message to login!'; // Set that will hold the siwe messages sent + nonce we defined -const siweMessageSet = new Set(); +const siweMessagesMap = new Map(); exports.createAndSendSiweMessage = async (address) => { const nonce = siwe.generateNonce(); @@ -20,23 +20,35 @@ exports.createAndSendSiweMessage = async (address) => { version: '1', chainId: '1', nonce, - expirationTime: new Date().toISOString(), + expirationTime: new Date(Date.now() + 360 * 60 * 1000).toISOString(), }); const preparedMessage = siweMessage.prepareMessage(); - siweMessageSet.add({ nonce, preparedMessage }); + siweMessagesMap.set(nonce, siweMessage); - return preparedMessage; + return { siweMessage: preparedMessage, nonce }; }; -exports.verifySiweMessage = async (messageFromUser, signature) => { - const fields = await messageFromUser.verify({ signature }); - const expectedSentMessage = { nonce: fields.data.nonce, messageFromUser }; - - const isSiweMessage = siweMessageSet.has(expectedSentMessage); - if (!isSiweMessage) { +exports.verifySiweMessage = async (nonce, signature) => { + const maybeSiweMessage = siweMessagesMap.get(nonce); + if (!maybeSiweMessage) { throw new Error('Message not found, we have not send this message or nonce is incorrect.'); } - siweMessageSet.delete(expectedSentMessage); + // TODO DEFINE at some point we need to delete them (?) + //siweMessagesMap.delete(nonce); + + // Verify the signature and other message fields + const { data } = await maybeSiweMessage.verify({ signature }); + + // Perform additional checks to ensure message integrity + if (data.nonce !== nonce) { + throw new Error('Nonce mismatch.'); + } - return fields; + if (data.expirationTime && new Date(data.expirationTime) < new Date()) { + throw new Error('Message has expired.'); + } + + return data; }; + +// TODO we need some sort of session log-out. diff --git a/src/constants/tokenConfig.ts b/src/constants/tokenConfig.ts index c909d5a9..6884b904 100644 --- a/src/constants/tokenConfig.ts +++ b/src/constants/tokenConfig.ts @@ -40,6 +40,7 @@ export interface OutputTokenDetails { offrampFeesBasisPoints: number; offrampFeesFixedComponent?: number; requiresClientMasterOverride: boolean; + usesMemo: boolean; } export const INPUT_TOKEN_CONFIG: Record = { usdc: { @@ -91,6 +92,7 @@ export const OUTPUT_TOKEN_CONFIG: Record = maxWithdrawalAmountRaw: '10000000000000000', offrampFeesBasisPoints: 125, requiresClientMasterOverride: false, + usesMemo: false, }, ars: { tomlFileUrl: 'https://api.anclap.com/.well-known/stellar.toml', @@ -116,6 +118,7 @@ export const OUTPUT_TOKEN_CONFIG: Record = offrampFeesBasisPoints: 200, // 2% offrampFeesFixedComponent: 10, // 10 ARS requiresClientMasterOverride: true, + usesMemo: true, }, }; diff --git a/src/hooks/useMainProcess.ts b/src/hooks/useMainProcess.ts index 417a6360..35437d71 100644 --- a/src/hooks/useMainProcess.ts +++ b/src/hooks/useMainProcess.ts @@ -6,7 +6,7 @@ import { INPUT_TOKEN_CONFIG, InputTokenType, OUTPUT_TOKEN_CONFIG, OutputTokenTyp import { fetchTomlValues, sep10, sep24Second } from '../services/anchor'; // Utils -import { useConfig, useSwitchChain } from 'wagmi'; +import { useAccount, useConfig, useSwitchChain } from 'wagmi'; import { polygon } from 'wagmi/chains'; import { OfframpingState, @@ -59,6 +59,7 @@ export const useMainProcess = () => { const [signingPhase, setSigningPhase] = useState(undefined); const wagmiConfig = useConfig(); + const { address } = useAccount(); const { switchChain } = useSwitchChain(); const { trackEvent, resetUniqueEvents } = useEventsContext(); @@ -141,6 +142,7 @@ export const useMainProcess = () => { tomlValues, stellarEphemeralSecret, outputTokenType, + address, addEvent, ); @@ -157,7 +159,7 @@ export const useMainProcess = () => { setAnchorSessionParams(anchorSessionParams); const fetchAndUpdateSep24Url = async () => { - const firstSep24Response = await sep24First(anchorSessionParams, sep10Account, outputTokenType); + const firstSep24Response = await sep24First(anchorSessionParams, sep10Account, outputTokenType, address); const url = new URL(firstSep24Response.url); url.searchParams.append('callback', 'postMessage'); firstSep24Response.url = url.toString(); @@ -185,7 +187,7 @@ export const useMainProcess = () => { } })(); }, - [offrampingStarted, offrampingState, switchChain, trackEvent], + [offrampingStarted, offrampingState, switchChain, trackEvent, address], ); const handleOnAnchorWindowOpen = useCallback(async () => { diff --git a/src/hooks/useSignChallenge.ts b/src/hooks/useSignChallenge.ts index 500b5391..bd04c2dd 100644 --- a/src/hooks/useSignChallenge.ts +++ b/src/hooks/useSignChallenge.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; import { useSignMessage } from 'wagmi'; +import { SIGNING_SERVICE_URL } from '../constants/constants'; export function useSignChallenge(address: `0x${string}` | undefined) { const { signMessageAsync } = useSignMessage(); @@ -8,14 +9,19 @@ export function useSignChallenge(address: `0x${string}` | undefined) { const handleSiweSignIn = useCallback(async () => { try { - // const response = await fetch('/api/siwe-challenge'); - // const { message } = await response.json(); - const message = 'Please sign the message to log in'; - const signature = await signMessageAsync({ message }); + const response = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ walletAddress: address }), + }); + const { siweMessage, nonce } = await response.json(); + console.log('SIWE message:', siweMessage, 'nonce:', nonce); + const signature = await signMessageAsync({ message: siweMessage }); console.log('SIWE signature:', signature); - - localStorage.setItem(`siwe-signature-${address}`, signature); + localStorage.setItem(`siwe-signature-${address}`, JSON.stringify({ nonce, signature })); setIsModalOpen(false); } catch (error) { diff --git a/src/services/anchor/index.ts b/src/services/anchor/index.ts index cc8f036a..474e6fee 100644 --- a/src/services/anchor/index.ts +++ b/src/services/anchor/index.ts @@ -65,6 +65,8 @@ export const fetchTomlValues = async (TOML_FILE_URL: string): Promise { let sep10Account; if (requiresClientMasterOverride) { @@ -85,16 +87,31 @@ async function getUrlParams( sep10Account = ephemeralAccount; } + if (usesMemo) { + return { + urlParams: new URLSearchParams({ + account: sep10Account, + client_domain: config.applicationClientDomain, + memo: deriveMemoFromAddress(address), + }), + sep10Account, + }; + } return { urlParams: new URLSearchParams({ account: sep10Account, client_domain: config.applicationClientDomain }), sep10Account, }; } +const deriveMemoFromAddress = (address: `0x${string}`) => { + return address.slice(5, 15).replace(/\D/g, ''); +}; + export const sep10 = async ( tomlValues: TomlValues, stellarEphemeralSecret: string, outputToken: OutputTokenType, + address: `0x${string}` | undefined, renderEvent: (event: string, status: EventStatus) => void, ): Promise<{ sep10Account: string; token: string }> => { const { signingKey, webAuthEndpoint } = tomlValues; @@ -106,10 +123,10 @@ export const sep10 = async ( const ephemeralKeys = Keypair.fromSecret(stellarEphemeralSecret); const accountId = ephemeralKeys.publicKey(); - const { requiresClientMasterOverride } = OUTPUT_TOKEN_CONFIG[outputToken]; + const { requiresClientMasterOverride, usesMemo } = OUTPUT_TOKEN_CONFIG[outputToken]; // will select either clientMaster or the ephemeral account - const { urlParams, sep10Account } = await getUrlParams(accountId, requiresClientMasterOverride); + const { urlParams, sep10Account } = await getUrlParams(accountId, requiresClientMasterOverride, usesMemo, address!); const challenge = await fetch(`${webAuthEndpoint}?${urlParams.toString()}`); if (challenge.status !== 200) { @@ -129,16 +146,23 @@ export const sep10 = async ( throw new Error(`Invalid sequence number: ${transactionSigned.sequence}`); } - // More tests required, ignore for prototype + const maybeStoredSignatureString = localStorage.getItem(`siwe-signature-${address}`); + let nonce; + let signature; - // TODO fetch and check signing signature from localstore, - const maybeChallengeSignature = undefined; + // TODO actually, if usesMemo and not maybeStored.. we need to ask for it again. + if (maybeStoredSignatureString && usesMemo) { + const storedSignatureObject = JSON.parse(maybeStoredSignatureString); + nonce = storedSignatureObject.nonce; + signature = storedSignatureObject.signature; + } // sign both for client_domain + an extra signature for Anclap workaround const { masterSignature, clientSignature, clientPublic } = await fetchSep10Signatures( transactionSigned.toXDR(), outputToken, sep10Account, - maybeChallengeSignature, + signature, + nonce, ); transactionSigned.addSignature(clientPublic, clientSignature); @@ -233,6 +257,7 @@ export async function sep24First( sessionParams: IAnchorSessionParams, sep10Account: string, outputToken: OutputTokenType, + address: `0x${string}` | undefined, ): Promise { if (config.test.mockSep24) { return { url: 'https://www.example.com', id: '1234' }; @@ -253,6 +278,8 @@ export async function sep24First( amount: sessionParams.offrampAmount, account: sep10Account, // THIS is a particularity of Anclap. Should be able to work just with the epmhemeral account // Since we signed with the master from the service, we need to specify the corresponding public here + // memo: deriveMemoFromAddress(address!), + // memo_type: 'id', }); } else { sep24Params = new URLSearchParams({ diff --git a/src/services/signingService.tsx b/src/services/signingService.tsx index be2f94aa..b2606aa6 100644 --- a/src/services/signingService.tsx +++ b/src/services/signingService.tsx @@ -43,12 +43,13 @@ export const fetchSep10Signatures = async ( challengeXDR: string, outToken: OutputTokenType, clientPublicKey: string, - maybeChallengeSignature: any, + maybeChallengeSignature: string, + maybeNonce: string, ): Promise => { const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ challengeXDR, outToken, clientPublicKey, maybeChallengeSignature }), + body: JSON.stringify({ challengeXDR, outToken, clientPublicKey, maybeChallengeSignature, maybeNonce }), }); if (response.status !== 200) { throw new Error(`Failed to fetch SEP10 challenge from server: ${response.statusText}`); From 780b17b847dfe176a6ddc72a558cd30f0c518a7f Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 6 Nov 2024 17:05:33 -0300 Subject: [PATCH 06/61] remove comments --- signer-service/src/api/helpers/anchors.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/signer-service/src/api/helpers/anchors.js b/signer-service/src/api/helpers/anchors.js index 961d90cd..0c99e2f1 100644 --- a/signer-service/src/api/helpers/anchors.js +++ b/signer-service/src/api/helpers/anchors.js @@ -61,7 +61,6 @@ const verifyClientDomainChallengeOps = async ( throw new Error('First manageData operation should have a 64-byte random nonce as value'); } - // Flags to check presence of required operations let hasWebAuthDomain = false; let hasClientDomain = false; @@ -79,11 +78,6 @@ const verifyClientDomainChallengeOps = async ( if (op.source !== signingKey) { throw new Error('web_auth_domain manage_data operation must have the server account as the source'); } - - // value web_auth_domain but in bytes - // if (op.value !== 'web_auth_domain') { - // throw new Error(`web_auth_domain manageData operation should have value 'web_auth_domain'`); - // } } // Verify client_domain operation (if applicable) @@ -93,10 +87,6 @@ const verifyClientDomainChallengeOps = async ( if (op.source !== keypair.publicKey()) { throw new Error('client_domain manage_data operation must have the client domain account as the source'); } - // Also in bytes first - // if (op.value !== 'client_domain') { - // throw new Error(`client_domain manageData operation should have value 'client_domain'`); - // } } } From 1996e64467ca834f219d1806262e102258ad0581 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 6 Nov 2024 17:14:21 -0300 Subject: [PATCH 07/61] remove and improve comments --- signer-service/src/api/helpers/anchors.js | 82 +------------------ .../src/api/services/sep10.service.js | 9 +- .../src/api/services/siwe.service.js | 2 +- .../src/api/services/stellar.service.js | 2 +- signer-service/src/index.js | 42 ++++++---- 5 files changed, 34 insertions(+), 103 deletions(-) diff --git a/signer-service/src/api/helpers/anchors.js b/signer-service/src/api/helpers/anchors.js index 0c99e2f1..ba599ef9 100644 --- a/signer-service/src/api/helpers/anchors.js +++ b/signer-service/src/api/helpers/anchors.js @@ -19,84 +19,4 @@ const fetchTomlValues = async (tomlFileUrl) => { }; }; -const verifyClientDomainChallengeOps = async ( - challengeXDR, - networkPassphrase, - signingKey, - clientPublicKey, - memo, - expectedKey, -) => { - const transactionSigned = new TransactionBuilder.fromXDR(challengeXDR, networkPassphrase); - if (transactionSigned.source !== signingKey) { - throw new Error(`Invalid source account: ${transactionSigned.source}`); - } - if (transactionSigned.sequence !== '0') { - throw new Error(`Invalid sequence number: ${transactionSigned.sequence}`); - } - - // See https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md#success - // memo field should match and not be empty. - if (transactionSigned.memo.value !== memo) { - throw new Error('Memo does not match'); - } - - // Verify manage_data operations - const operations = transactionSigned.operations; - // Verify the first manage_data operation - const firstOp = operations[0]; - if (firstOp.type !== 'manageData') { - throw new Error('The first operation should be manageData'); - } - // We don't want to accept a challenge that would authorize as Application client account! - // We DO accept a challenge where the source is the master account + memo - if (firstOp.source !== clientPublicKey || firstOp.source == signingKey) { - throw new Error('First manageData operation must have the client account as the source'); - } - - if (firstOp.name !== expectedKey) { - throw new Error(`First manageData operation should have key '${expectedKey}'`); - } - if (!firstOp.value || firstOp.value.length !== 64) { - throw new Error('First manageData operation should have a 64-byte random nonce as value'); - } - - let hasWebAuthDomain = false; - let hasClientDomain = false; - - // Verify extra manage_data operations - for (let i = 1; i < operations.length; i++) { - const op = operations[i]; - - if (op.type !== 'manageData') { - throw new Error('All operations should be manage_data operations'); - } - - // Verify web_auth_domain operation - if (op.name === 'web_auth_domain') { - hasWebAuthDomain = true; - if (op.source !== signingKey) { - throw new Error('web_auth_domain manage_data operation must have the server account as the source'); - } - } - - // Verify client_domain operation (if applicable) - if (op.name === 'client_domain') { - hasClientDomain = true; - // Replace 'CLIENT_DOMAIN_ACCOUNT' with the actual client domain account public key - if (op.source !== keypair.publicKey()) { - throw new Error('client_domain manage_data operation must have the client domain account as the source'); - } - } - } - - // the web_auth_domain and client_domain operation must be present - if (!hasWebAuthDomain) { - throw new Error('Transaction must contain a web_auth_domain manageData operation'); - } - if (!hasClientDomain) { - throw new Error('Transaction must contain a client_domain manageData operation'); - } -}; - -module.exports = { fetchTomlValues, verifyClientDomainChallengeOps }; +module.exports = { fetchTomlValues }; diff --git a/signer-service/src/api/services/sep10.service.js b/signer-service/src/api/services/sep10.service.js index 8b00c50f..2e60c434 100644 --- a/signer-service/src/api/services/sep10.service.js +++ b/signer-service/src/api/services/sep10.service.js @@ -8,6 +8,8 @@ const { SEP10_MASTER_SECRET, CLIENT_SECRET } = require('../../constants/constant const NETWORK_PASSPHRASE = Networks.PUBLIC; +// 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 getAndValidateMemo = async (nonce, userChallengeSignature) => { if (!userChallengeSignature || !nonce) { return ''; @@ -62,14 +64,15 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, use } // Temporary. This check will be removed when we have the memo solution. - if (outToken === 'ars') { + if (memo !== '') { // We only want to accept a challenge that would authorize the master key. if (firstOp.source !== masterStellarKeypair.publicKey()) { throw new Error('First manageData operation must have the master signing key as the source'); } } else { // Only authorize a session that corresponds with the ephemeral client account - if (firstOp.source !== clientPublicKey) { + // if no memo, we should not authorize a session for our client domain master + if (firstOp.source !== clientPublicKey || firstOp.source == masterStellarKeypair.publicKey()) { throw new Error('First manageData operation must have the client account as the source'); } } @@ -82,11 +85,9 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, use throw new Error('First manageData operation should have a 64-byte random nonce as value'); } - // Flags to check presence of required operations let hasWebAuthDomain = false; let hasClientDomain = false; - // Verify extra manage_data operations, web_auth and proper client domain. for (let i = 1; i < operations.length; i++) { const op = operations[i]; diff --git a/signer-service/src/api/services/siwe.service.js b/signer-service/src/api/services/siwe.service.js index 2e13995d..3f83af7a 100644 --- a/signer-service/src/api/services/siwe.service.js +++ b/signer-service/src/api/services/siwe.service.js @@ -6,7 +6,7 @@ const domain = 'localhost'; const origin = 'https://localhost/login'; const statement = 'Please sign the message to login!'; -// Set that will hold the siwe messages sent + nonce we defined +// Map that will hold the siwe messages sent + nonce we defined const siweMessagesMap = new Map(); exports.createAndSendSiweMessage = async (address) => { diff --git a/signer-service/src/api/services/stellar.service.js b/signer-service/src/api/services/stellar.service.js index 559ec2c5..f6d39372 100644 --- a/signer-service/src/api/services/stellar.service.js +++ b/signer-service/src/api/services/stellar.service.js @@ -7,7 +7,7 @@ const { STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS, } = require('../../constants/constants'); const { TOKEN_CONFIG, getTokenConfigByAssetCode } = require('../../constants/tokenConfig'); -const { fetchTomlValues, verifyClientDomainChallengeOps } = require('../helpers/anchors'); + // Derive funding pk const FUNDING_PUBLIC_KEY = Keypair.fromSecret(FUNDING_SECRET).publicKey(); const horizonServer = new Horizon.Server(HORIZON_URL); diff --git a/signer-service/src/index.js b/signer-service/src/index.js index 1797a889..a3fe7ebd 100755 --- a/signer-service/src/index.js +++ b/signer-service/src/index.js @@ -4,25 +4,35 @@ const logger = require('./config/logger'); const app = require('./config/express'); require('dotenv').config(); -const { FUNDING_SECRET, PENDULUM_FUNDING_SEED, MOONBEAM_EXECUTOR_PRIVATE_KEY } = require('./constants/constants'); +const { + FUNDING_SECRET, + PENDULUM_FUNDING_SEED, + MOONBEAM_EXECUTOR_PRIVATE_KEY, + CLIENT_SECRET, +} = require('./constants/constants'); -// stop the application if the funding secret key is not set -// if (!FUNDING_SECRET) { -// logger.error('FUNDING_SECRET not set in the environment variables'); -// process.exit(1); -// } +//stop the application if the funding secret key is not set +if (!FUNDING_SECRET) { + logger.error('FUNDING_SECRET not set in the environment variables'); + process.exit(1); +} -// // stop the application if the Pendulum funding seed is not set -// if (!PENDULUM_FUNDING_SEED) { -// logger.error('PENDULUM_FUNDING_SEED not set in the environment variables'); -// process.exit(1); -// } +// stop the application if the Pendulum funding seed is not set +if (!PENDULUM_FUNDING_SEED) { + logger.error('PENDULUM_FUNDING_SEED not set in the environment variables'); + process.exit(1); +} -// // stop the application if the Moonbeam executor private key is not set -// if (!MOONBEAM_EXECUTOR_PRIVATE_KEY) { -// logger.error('MOONBEAM_EXECUTOR_PRIVATE_KEY not set in the environment variables'); -// process.exit(1); -// } +// stop the application if the Moonbeam executor private key is not set +if (!MOONBEAM_EXECUTOR_PRIVATE_KEY) { + logger.error('MOONBEAM_EXECUTOR_PRIVATE_KEY not set in the environment variables'); + process.exit(1); +} + +if (!CLIENT_SECRET) { + logger.error('CLIENT_SECRET not set in the environment variables'); + process.exit(1); +} // listen to requests app.listen(port, () => logger.info(`server started on port ${port} (${env})`)); From b4eaddf5bc8fa5965db85ed4584e55168a71896d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 8 Nov 2024 17:40:52 -0300 Subject: [PATCH 08/61] remove master account, either use memo or not --- src/constants/tokenConfig.ts | 3 --- src/services/anchor/index.ts | 36 +++++++++++++++--------------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/constants/tokenConfig.ts b/src/constants/tokenConfig.ts index 6884b904..7db36768 100644 --- a/src/constants/tokenConfig.ts +++ b/src/constants/tokenConfig.ts @@ -39,7 +39,6 @@ export interface OutputTokenDetails { erc20WrapperAddress: string; offrampFeesBasisPoints: number; offrampFeesFixedComponent?: number; - requiresClientMasterOverride: boolean; usesMemo: boolean; } export const INPUT_TOKEN_CONFIG: Record = { @@ -91,7 +90,6 @@ export const OUTPUT_TOKEN_CONFIG: Record = minWithdrawalAmountRaw: '10000000000000', maxWithdrawalAmountRaw: '10000000000000000', offrampFeesBasisPoints: 125, - requiresClientMasterOverride: false, usesMemo: false, }, ars: { @@ -117,7 +115,6 @@ export const OUTPUT_TOKEN_CONFIG: Record = maxWithdrawalAmountRaw: '500000000000000000', // 500000 ARS offrampFeesBasisPoints: 200, // 2% offrampFeesFixedComponent: 10, // 10 ARS - requiresClientMasterOverride: true, usesMemo: true, }, }; diff --git a/src/services/anchor/index.ts b/src/services/anchor/index.ts index 474e6fee..5480fde5 100644 --- a/src/services/anchor/index.ts +++ b/src/services/anchor/index.ts @@ -62,32 +62,25 @@ export const fetchTomlValues = async (TOML_FILE_URL: string): Promise { - let sep10Account; - if (requiresClientMasterOverride) { + if (usesMemo) { const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`); if (!response.ok) { throw new Error('Failed to fetch client master SEP-10 public account.'); } - const { masterSep10Public } = await response.json(); - if (!masterSep10Public) { throw new Error('masterSep10Public not found in response.'); } - sep10Account = masterSep10Public; - } else { - sep10Account = ephemeralAccount; - } + const sep10Account = masterSep10Public; - if (usesMemo) { return { urlParams: new URLSearchParams({ account: sep10Account, @@ -98,11 +91,12 @@ async function getUrlParams( }; } return { - urlParams: new URLSearchParams({ account: sep10Account, client_domain: config.applicationClientDomain }), - sep10Account, + urlParams: new URLSearchParams({ account: ephemeralAccount, client_domain: config.applicationClientDomain }), + sep10Account: ephemeralAccount, }; } +//TODO A very naive memo derivation for testing. NOT SECURE const deriveMemoFromAddress = (address: `0x${string}`) => { return address.slice(5, 15).replace(/\D/g, ''); }; @@ -123,10 +117,10 @@ export const sep10 = async ( const ephemeralKeys = Keypair.fromSecret(stellarEphemeralSecret); const accountId = ephemeralKeys.publicKey(); - const { requiresClientMasterOverride, usesMemo } = OUTPUT_TOKEN_CONFIG[outputToken]; + const { usesMemo } = OUTPUT_TOKEN_CONFIG[outputToken]; // will select either clientMaster or the ephemeral account - const { urlParams, sep10Account } = await getUrlParams(accountId, requiresClientMasterOverride, usesMemo, address!); + const { urlParams, sep10Account } = await getUrlParams(accountId, usesMemo, address!); const challenge = await fetch(`${webAuthEndpoint}?${urlParams.toString()}`); if (challenge.status !== 200) { @@ -146,6 +140,8 @@ export const sep10 = async ( throw new Error(`Invalid sequence number: ${transactionSigned.sequence}`); } + // TODO change to add a fx that will either try to get the signature from storage, + // check if it's still valid, and if not ask for another one. const maybeStoredSignatureString = localStorage.getItem(`siwe-signature-${address}`); let nonce; let signature; @@ -166,7 +162,7 @@ export const sep10 = async ( ); transactionSigned.addSignature(clientPublic, clientSignature); - if (!requiresClientMasterOverride) { + if (!usesMemo) { transactionSigned.sign(ephemeralKeys); } else { transactionSigned.addSignature(sep10Account, masterSignature); @@ -266,18 +262,16 @@ export async function sep24First( const { token, tomlValues } = sessionParams; const { sep24Url } = tomlValues; - const { requiresClientMasterOverride } = OUTPUT_TOKEN_CONFIG[outputToken]; + const { usesMemo } = OUTPUT_TOKEN_CONFIG[outputToken]; let sep24Params; - if (requiresClientMasterOverride) { - if (!sep10Account) { - throw new Error('Master must be defined at this point.'); - } + if (usesMemo) { sep24Params = new URLSearchParams({ asset_code: sessionParams.tokenConfig.stellarAsset.code.string.replace('\0', ''), amount: sessionParams.offrampAmount, account: sep10Account, // THIS is a particularity of Anclap. Should be able to work just with the epmhemeral account - // Since we signed with the master from the service, we need to specify the corresponding public here + // or at least the anchor should be able to get it from the JWT. + // Since we signed with the master/omnibus from the service, we need to specify the corresponding public here // memo: deriveMemoFromAddress(address!), // memo_type: 'id', }); From 79beb7752c983003b56177c68a4e26692b3a4e0c Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 8 Nov 2024 19:22:53 -0300 Subject: [PATCH 09/61] improve signature hook --- package.json | 2 + src/constants/localStorage.ts | 1 + src/hooks/useMainProcess.ts | 4 +- src/hooks/useSignChallenge.ts | 74 +++++++++++++++++++----- src/services/anchor/index.ts | 8 ++- yarn.lock | 105 +++++++++++++++++++++++++++++++--- 6 files changed, 168 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 47283bfa..10f1565b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "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", @@ -56,6 +57,7 @@ "react-hook-form": "^7.51.5", "react-router-dom": "^6.8.1", "react-toastify": "^10.0.5", + "siwe": "^2.3.2", "stellar-base": "^11.0.1", "stellar-sdk": "^11.3.0", "tailwind": "^4.0.0", diff --git a/src/constants/localStorage.ts b/src/constants/localStorage.ts index 6c22744b..df293d32 100644 --- a/src/constants/localStorage.ts +++ b/src/constants/localStorage.ts @@ -10,6 +10,7 @@ export const storageKeys = { ANCHOR_SESSION_PARAMS: 'ANCHOR_SESSION_PARAMS', STELLAR_OPERATIONS: 'STELLAR_OPERATIONS', TOKEN_BRIDGED_AMOUNT: 'TOKEN_BRIDGED_AMOUNT', + SIWE_SIGNATURE_KEY_PREFIX: 'siwe-signature-', // Internal squidrouter recovery states SQUIDROUTER_RECOVERY_STATE: 'SQUIDROUTER_TRANSACTION_STATE', diff --git a/src/hooks/useMainProcess.ts b/src/hooks/useMainProcess.ts index 35437d71..e2492186 100644 --- a/src/hooks/useMainProcess.ts +++ b/src/hooks/useMainProcess.ts @@ -22,7 +22,7 @@ import { createTransactionEvent, useEventsContext } from '../contexts/events'; import { showToast, ToastMessage } from '../helpers/notifications'; import { IAnchorSessionParams, ISep24Intermediate } from '../services/anchor'; import { OFFRAMPING_PHASE_SECONDS } from '../pages/progress'; - +import { useGetOrRefreshSiweSignature } from './useSignChallenge'; export type SigningPhase = 'started' | 'approved' | 'signed' | 'finished'; export interface ExecutionInput { @@ -64,6 +64,7 @@ export const useMainProcess = () => { const { trackEvent, resetUniqueEvents } = useEventsContext(); const [, setEvents] = useState([]); + const { getOrRefreshSiweSignature } = useGetOrRefreshSiweSignature(address); const updateHookStateFromState = useCallback( (state: OfframpingState | undefined) => { @@ -143,6 +144,7 @@ export const useMainProcess = () => { stellarEphemeralSecret, outputTokenType, address, + getOrRefreshSiweSignature, addEvent, ); diff --git a/src/hooks/useSignChallenge.ts b/src/hooks/useSignChallenge.ts index bd04c2dd..8fbd060b 100644 --- a/src/hooks/useSignChallenge.ts +++ b/src/hooks/useSignChallenge.ts @@ -1,13 +1,42 @@ import { useEffect, useState, useCallback } from 'react'; import { useSignMessage } from 'wagmi'; import { SIGNING_SERVICE_URL } from '../constants/constants'; +import { storageKeys } from '../constants/localStorage'; +import { SiweMessage } from 'siwe'; -export function useSignChallenge(address: `0x${string}` | undefined) { +type SiweSignatureData = { + nonce: string; + signature: string; + expirationDate: string; +}; + +export function useGetOrRefreshSiweSignature(address: `0x${string}` | undefined) { const { signMessageAsync } = useSignMessage(); + const [signatureData, setSignatureData] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); + const getOrRefreshSiweSignature = useCallback(async (): Promise => { + if (!address) { + return; + } - const handleSiweSignIn = useCallback(async () => { + const storageKey = `${storageKeys.SIWE_SIGNATURE_KEY_PREFIX}${address}`; + const maybeStoredSignatureData = localStorage.getItem(storageKey); + + if (maybeStoredSignatureData) { + const storedSignatureData: SiweSignatureData = JSON.parse(maybeStoredSignatureData); + const expirationDate = new Date(storedSignatureData.expirationDate); + + if (expirationDate > new Date()) { + // Signature is still valid + setSignatureData(storedSignatureData); + return storedSignatureData; + } else { + // Signature expired, remove it + localStorage.removeItem(storageKey); + } + } + + // Signature not found or expired, fetch a new one try { const response = await fetch(`${SIGNING_SERVICE_URL}/v1/siwe/create`, { method: 'POST', @@ -16,37 +45,56 @@ export function useSignChallenge(address: `0x${string}` | undefined) { }, body: JSON.stringify({ walletAddress: address }), }); + const { siweMessage, nonce } = await response.json(); - console.log('SIWE message:', siweMessage, 'nonce:', nonce); + + // Parse the SIWE message to extract the expiration date + const message = new SiweMessage(siweMessage); + const expirationDate = message.expirationTime!; + const signature = await signMessageAsync({ message: siweMessage }); - console.log('SIWE signature:', signature); - localStorage.setItem(`siwe-signature-${address}`, JSON.stringify({ nonce, signature })); + const newSignatureData: SiweSignatureData = { + nonce, + signature, + expirationDate, + }; - setIsModalOpen(false); + localStorage.setItem(storageKey, JSON.stringify(newSignatureData)); + setSignatureData(newSignatureData); + return newSignatureData; } catch (error) { console.error('Error during SIWE sign-in:', error); } }, [address, signMessageAsync]); + return { signatureData, getOrRefreshSiweSignature }; +} + +export function useSignChallenge(address: `0x${string}` | undefined) { + const { signatureData, getOrRefreshSiweSignature } = useGetOrRefreshSiweSignature(address); + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + getOrRefreshSiweSignature(); + }, [address]); + useEffect(() => { if (!address) { + setIsModalOpen(false); return; } - const storedSignature = localStorage.getItem(`siwe-signature-${address}`); - console.log('Stored SIWE signature:', storedSignature); - if (!storedSignature) { + if (!signatureData) { setIsModalOpen(true); - console.log('Opening SIWE sign-in modal'); } else { setIsModalOpen(false); } - }, [address]); + }, [address, signatureData]); return { isModalOpen, - handleSiweSignIn, + handleSiweSignIn: getOrRefreshSiweSignature, closeModal: () => setIsModalOpen(false), }; } diff --git a/src/services/anchor/index.ts b/src/services/anchor/index.ts index 5480fde5..423badc3 100644 --- a/src/services/anchor/index.ts +++ b/src/services/anchor/index.ts @@ -106,6 +106,7 @@ export const sep10 = async ( stellarEphemeralSecret: string, outputToken: OutputTokenType, address: `0x${string}` | undefined, + getOrRefreshSiweSignature: any, renderEvent: (event: string, status: EventStatus) => void, ): Promise<{ sep10Account: string; token: string }> => { const { signingKey, webAuthEndpoint } = tomlValues; @@ -142,13 +143,14 @@ export const sep10 = async ( // TODO change to add a fx that will either try to get the signature from storage, // check if it's still valid, and if not ask for another one. - const maybeStoredSignatureString = localStorage.getItem(`siwe-signature-${address}`); + const signatureData = await getOrRefreshSiweSignature(); + console.log('fetched: ', signatureData); let nonce; let signature; // TODO actually, if usesMemo and not maybeStored.. we need to ask for it again. - if (maybeStoredSignatureString && usesMemo) { - const storedSignatureObject = JSON.parse(maybeStoredSignatureString); + if (signatureData && usesMemo) { + const storedSignatureObject = signatureData.signature; nonce = storedSignatureObject.nonce; signature = storedSignatureObject.signature; } diff --git a/yarn.lock b/yarn.lock index 98bf645b..cea380f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"@adraffy/ens-normalize@npm:^1.8.8": +"@adraffy/ens-normalize@npm:1.10.1, @adraffy/ens-normalize@npm:^1.8.8": version: 1.10.1 resolution: "@adraffy/ens-normalize@npm:1.10.1" checksum: 10/4cb938c4abb88a346d50cb0ea44243ab3574330c81d4f5aaaf9dfee584b96189d0faa404de0fcbef5a1b73909ea4ebc3e63d84bd23f9949e5c8d4085207a5091 @@ -2621,6 +2621,13 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:^1.1.2": + version: 1.5.0 + resolution: "@noble/hashes@npm:1.5.0" + checksum: 10/da7fc7af52af7afcf59810a7eea6155075464ff462ffda2572dc6d57d53e2669b1ea2ec774e814f6273f1697e567f28d36823776c9bf7068cba2a2855140f26e + languageName: node + linkType: hard + "@noble/hashes@npm:~1.3.0, @noble/hashes@npm:~1.3.2": version: 1.3.3 resolution: "@noble/hashes@npm:1.3.3" @@ -4455,6 +4462,18 @@ __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" @@ -5013,6 +5032,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:22.7.5": + version: 22.7.5 + resolution: "@types/node@npm:22.7.5" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10/e8ba102f8c1aa7623787d625389be68d64e54fcbb76d41f6c2c64e8cf4c9f4a2370e7ef5e5f1732f3c57529d3d26afdcb2edc0101c5e413a79081449825c57ac + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -5953,6 +5981,13 @@ __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" @@ -6090,6 +6125,13 @@ __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" @@ -9060,6 +9102,21 @@ __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" @@ -14821,6 +14878,20 @@ __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" @@ -15672,6 +15743,13 @@ __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" @@ -15693,13 +15771,6 @@ __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" @@ -15942,6 +16013,13 @@ __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 + languageName: node + linkType: hard + "unenv@npm:^1.9.0": version: 1.9.0 resolution: "unenv@npm:1.9.0" @@ -16179,7 +16257,7 @@ __metadata: languageName: node linkType: hard -"uri-js@npm:^4.2.2": +"uri-js@npm:^4.2.2, uri-js@npm:^4.4.1": version: 4.4.1 resolution: "uri-js@npm:4.4.1" dependencies: @@ -16355,6 +16433,13 @@ __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" @@ -16606,6 +16691,7 @@ __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" @@ -16619,6 +16705,7 @@ __metadata: react-hook-form: "npm:^7.51.5" react-router-dom: "npm:^6.8.1" react-toastify: "npm:^10.0.5" + siwe: "npm:^2.3.2" stellar-base: "npm:^11.0.1" stellar-sdk: "npm:^11.3.0" tailwind: "npm:^4.0.0" From ec8e798931afd1656facfac31c67281700cfe941 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 11 Nov 2024 11:55:33 -0300 Subject: [PATCH 10/61] use viem to validate message on backend, error handling --- .../src/api/services/sep10.service.js | 16 ++++++--- .../src/api/services/siwe.service.js | 34 +++++++++++++------ src/hooks/useSignChallenge.ts | 2 +- src/services/anchor/index.ts | 12 +++---- src/services/signingService.tsx | 4 +-- 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/signer-service/src/api/services/sep10.service.js b/signer-service/src/api/services/sep10.service.js index af2feff0..540c3f3f 100644 --- a/signer-service/src/api/services/sep10.service.js +++ b/signer-service/src/api/services/sep10.service.js @@ -14,9 +14,15 @@ const getAndValidateMemo = async (nonce, userChallengeSignature) => { if (!userChallengeSignature || !nonce) { return null; // Default memo value when single stellar account is used } - const siweData = await verifySiweMessage(nonce, userChallengeSignature); - const memo = deriveMemoFromAddress(siweData.address); + let message; + try { + message = await verifySiweMessage(nonce, userChallengeSignature); + } catch (e) { + throw new Error(`Could not verify signature: ${e.message}`); + } + + const memo = deriveMemoFromAddress(message.address); return memo; }; @@ -33,10 +39,10 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, use let memo; try { - memo = getAndValidateMemo(nonce, userChallengeSignature); + memo = await getAndValidateMemo(nonce, userChallengeSignature); } catch (e) { console.log(e); - throw new Error(`Invalid evm account verification`); + throw new Error(`Could not verify signature or derive memo: ${e.message}`); } const { signingKey: anchorSigningKey } = await fetchTomlValues(TOKEN_CONFIG[outToken].tomlFileUrl); @@ -53,7 +59,7 @@ exports.signSep10Challenge = async (challengeXDR, outToken, clientPublicKey, use // See https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md#success // memo field should be empty as we assume for the ephemeral case, or the corresponding evm address // derivation. - if (transactionSigned.memo.value === memo) { + if (transactionSigned.memo.value !== memo) { throw new Error('Memo does not match with specified user signature or address. Could not validate.'); } diff --git a/signer-service/src/api/services/siwe.service.js b/signer-service/src/api/services/siwe.service.js index 3f83af7a..94622006 100644 --- a/signer-service/src/api/services/siwe.service.js +++ b/signer-service/src/api/services/siwe.service.js @@ -1,4 +1,6 @@ const siwe = require('siwe'); +const { createPublicClient, http } = require('viem'); +const { polygon } = require('viem/chains'); // Make constants on config const scheme = 'https'; @@ -22,33 +24,43 @@ exports.createAndSendSiweMessage = async (address) => { nonce, expirationTime: new Date(Date.now() + 360 * 60 * 1000).toISOString(), }); - const preparedMessage = siweMessage.prepareMessage(); - siweMessagesMap.set(nonce, siweMessage); + const preparedMessage = siweMessage.toMessage(); + siweMessagesMap.set(nonce, { siweMessage, address }); return { siweMessage: preparedMessage, nonce }; }; exports.verifySiweMessage = async (nonce, signature) => { - const maybeSiweMessage = siweMessagesMap.get(nonce); - if (!maybeSiweMessage) { + const maybeSiweData = siweMessagesMap.get(nonce); + if (!maybeSiweData) { throw new Error('Message not found, we have not send this message or nonce is incorrect.'); } - // TODO DEFINE at some point we need to delete them (?) - //siweMessagesMap.delete(nonce); - // Verify the signature and other message fields - const { data } = await maybeSiweMessage.verify({ signature }); + const publicClient = createPublicClient({ + chain: polygon, + transport: http(), + }); + + const valid = await publicClient.verifyMessage({ + address: maybeSiweData.address, + message: maybeSiweData.siweMessage.toMessage(), + signature, + }); + + if (!valid) { + throw new Error('Invalid signature.'); + } // Perform additional checks to ensure message integrity - if (data.nonce !== nonce) { + if (maybeSiweData.siweMessage.nonce !== nonce) { throw new Error('Nonce mismatch.'); } - if (data.expirationTime && new Date(data.expirationTime) < new Date()) { + if (maybeSiweData.expirationTime && new Date(maybeSiweData.expirationTime) < new Date()) { throw new Error('Message has expired.'); } - return data; + return maybeSiweData.siweMessage; }; // TODO we need some sort of session log-out. diff --git a/src/hooks/useSignChallenge.ts b/src/hooks/useSignChallenge.ts index 8fbd060b..5625cf9e 100644 --- a/src/hooks/useSignChallenge.ts +++ b/src/hooks/useSignChallenge.ts @@ -4,7 +4,7 @@ import { SIGNING_SERVICE_URL } from '../constants/constants'; import { storageKeys } from '../constants/localStorage'; import { SiweMessage } from 'siwe'; -type SiweSignatureData = { +export type SiweSignatureData = { nonce: string; signature: string; expirationDate: string; diff --git a/src/services/anchor/index.ts b/src/services/anchor/index.ts index 7c8423d5..667630b9 100644 --- a/src/services/anchor/index.ts +++ b/src/services/anchor/index.ts @@ -2,6 +2,7 @@ import { Transaction, Keypair, Networks } from 'stellar-sdk'; import { EventStatus } from '../../components/GenericEvent'; import { OutputTokenDetails, OutputTokenType } from '../../constants/tokenConfig'; import { fetchSep10Signatures, fetchSigningServiceAccountId } from '../signingService'; +import { SiweSignatureData } from '../../hooks/useSignChallenge'; import { config } from '../../config'; import { OUTPUT_TOKEN_CONFIG } from '../../constants/tokenConfig'; @@ -144,15 +145,14 @@ export const sep10 = async ( // TODO change to add a fx that will either try to get the signature from storage, // check if it's still valid, and if not ask for another one. - const signatureData = await getOrRefreshSiweSignature(); - console.log('fetched: ', signatureData); + const signatureData: SiweSignatureData = await getOrRefreshSiweSignature(); + + // undefined if not using memo let nonce; let signature; - - // TODO actually, if usesMemo and not maybeStored.. we need to ask for it again. if (signatureData && usesMemo) { - nonce = signatureData.signature.nonce; - signature = signatureData.signature.signature; + nonce = signatureData.nonce; + signature = signatureData.signature; } // sign both for client_domain + an extra signature for Anclap workaround const { masterClientSignature, clientSignature, clientPublic } = await fetchSep10Signatures( diff --git a/src/services/signingService.tsx b/src/services/signingService.tsx index 557e78c6..0099ba36 100644 --- a/src/services/signingService.tsx +++ b/src/services/signingService.tsx @@ -43,8 +43,8 @@ export const fetchSep10Signatures = async ( challengeXDR: string, outToken: OutputTokenType, clientPublicKey: string, - maybeChallengeSignature: string, - maybeNonce: string, + maybeChallengeSignature: string | undefined, + maybeNonce: string | undefined, ): Promise => { const response = await fetch(`${SIGNING_SERVICE_URL}/v1/stellar/sep10`, { method: 'POST', From 2b14bbebe708ad72a35daa313f0a0eb84d6f79c3 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 11 Nov 2024 13:55:40 -0300 Subject: [PATCH 11/61] improve sign in hooks --- .../src/api/controllers/stellar.controller.js | 13 ++++ src/components/SignIn/index.tsx | 21 +++--- src/hooks/useMainProcess.ts | 13 ++-- src/hooks/useSignChallenge.ts | 71 ++++++++++--------- src/pages/swap/index.tsx | 7 +- src/services/anchor/index.ts | 18 +++-- src/services/signingService.tsx | 3 + 7 files changed, 90 insertions(+), 56 deletions(-) diff --git a/signer-service/src/api/controllers/stellar.controller.js b/signer-service/src/api/controllers/stellar.controller.js index 6cad8ee7..ea7b7e91 100644 --- a/signer-service/src/api/controllers/stellar.controller.js +++ b/signer-service/src/api/controllers/stellar.controller.js @@ -63,6 +63,19 @@ exports.signSep10Challenge = async (req, res, next) => { ); 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. + try { + return res.status(401).json({ + error: 'Signature validation failed.', + details: error.message, + }); + } catch (error) { + console.error('Error in signSep10Challenge:', error); + return res.status(500).json({ error: 'Failed to sign challenge', 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/src/components/SignIn/index.tsx b/src/components/SignIn/index.tsx index f61d6e6c..68726b94 100644 --- a/src/components/SignIn/index.tsx +++ b/src/components/SignIn/index.tsx @@ -1,20 +1,23 @@ -import React from 'react'; +import { FC } from 'react'; import { useAccount } from 'wagmi'; -import { useSignChallenge } from '../../hooks/useSignChallenge'; import { Modal } from 'react-daisyui'; -export function SignInModal() { +interface SignInModalProps { + requiresSign: boolean; + closeModal: any; + handleSignIn: any; +} + +export const SignInModal: FC = ({ requiresSign, closeModal, handleSignIn }) => { const { address } = useAccount(); console.log('address:', address); - const { isModalOpen, handleSiweSignIn, closeModal } = useSignChallenge(address); - - if (!isModalOpen) { + if (!requiresSign) { return null; } return ( - + Sign In diff --git a/src/services/nabla.ts b/src/services/nabla.ts index f37b1759..7fe28c72 100644 --- a/src/services/nabla.ts +++ b/src/services/nabla.ts @@ -137,8 +137,9 @@ export async function nablaApprove( const inputToken = INPUT_TOKEN_CONFIG[inputTokenType]; if (!transactions) { - console.error('Missing transactions for nablaApprove'); - return { ...state, failure: 'unrecoverable' }; + const message = 'Missing transactions for nablaApprove'; + console.error(message); + return { ...state, failure: { type: 'unrecoverable', message } }; } const successorState = { @@ -319,8 +320,9 @@ export async function nablaSwap(state: OfframpingState, { renderEvent }: Executi const outputToken = OUTPUT_TOKEN_CONFIG[outputTokenType]; if (transactions === undefined) { - console.error('Missing transactions for nablaSwap'); - return { ...state, failure: 'unrecoverable' }; + const message = 'Missing transactions for nablaSwap'; + console.error(message); + return { ...state, failure: { type: 'unrecoverable', message } }; } const { api, ss58Format } = (await getApiManagerInstance()).apiData!; diff --git a/src/services/offrampingFlow.ts b/src/services/offrampingFlow.ts index 3e3ac297..04ad223e 100644 --- a/src/services/offrampingFlow.ts +++ b/src/services/offrampingFlow.ts @@ -30,7 +30,10 @@ import * as Sentry from '@sentry/react'; const minutesInMs = (minutes: number) => minutes * 60 * 1000; -export type FailureType = 'recoverable' | 'unrecoverable'; +export interface FailureType { + type: 'recoverable' | 'unrecoverable'; + message?: string; +} export type OfframpingPhase = | 'prepareTransactions' @@ -281,7 +284,7 @@ export async function advanceOfframpingState( } console.error('Error advancing offramping state', error); - newState = { ...state, failure: 'recoverable' }; + newState = { ...state, failure: { type: 'recoverable', message: error?.toString() } }; } if (newState !== undefined) { diff --git a/src/services/polkadot/index.tsx b/src/services/polkadot/index.tsx index 6a11bd48..7666bc84 100644 --- a/src/services/polkadot/index.tsx +++ b/src/services/polkadot/index.tsx @@ -124,8 +124,9 @@ export async function executeSpacewalkRedeem( } if (!transactions) { - console.error('Transactions not prepared, cannot execute Spacewalk redeem'); - return { ...state, failure: 'unrecoverable' }; + const message = 'Transactions not prepared, cannot execute Spacewalk redeem'; + console.error(message); + return { ...state, failure: { type: 'unrecoverable', message } }; } let redeemRequestEvent; diff --git a/src/services/squidrouter/process.ts b/src/services/squidrouter/process.ts index d8ee81c0..12ec81b8 100644 --- a/src/services/squidrouter/process.ts +++ b/src/services/squidrouter/process.ts @@ -61,7 +61,7 @@ export async function squidRouter( }); console.error('Error in squidRouter: ', e); - return { ...state, failure: 'unrecoverable' }; + return { ...state, failure: { type: 'unrecoverable', message: e?.toString() } }; } setSigningPhase?.('approved'); @@ -93,7 +93,7 @@ export async function squidRouter( }); console.error('Error in squidRouter: ', e); - return { ...state, failure: 'unrecoverable' }; + return { ...state, failure: { type: 'unrecoverable', message: e?.toString() } }; } setSigningPhase?.('signed'); From 0b7bc3240d6552079d7ed62b4baad4311127c6c5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 22 Nov 2024 17:43:31 +0100 Subject: [PATCH 44/61] Add function to schedule quotes --- src/contexts/events.tsx | 44 ++++++++++++++++++++++++++++++++++-- src/services/quotes/index.ts | 2 +- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/contexts/events.tsx b/src/contexts/events.tsx index 211ad013..6864de80 100644 --- a/src/contexts/events.tsx +++ b/src/contexts/events.tsx @@ -1,11 +1,12 @@ import { createContext } from 'preact'; -import { PropsWithChildren, useCallback, useContext, useEffect, useRef } from 'preact/compat'; +import { PropsWithChildren, useCallback, useContext, useEffect, useRef, useState } from 'preact/compat'; import Big from 'big.js'; import * as Sentry from '@sentry/react'; import { useAccount } from 'wagmi'; import { INPUT_TOKEN_CONFIG, OUTPUT_TOKEN_CONFIG } from '../constants/tokenConfig'; import { OfframpingState } from '../services/offrampingFlow'; import { calculateTotalReceive } from '../components/FeeCollapse'; +import { QuoteService } from '../services/quotes'; declare global { interface Window { @@ -50,7 +51,13 @@ interface OfframpingParameters { } export type TransactionEvent = OfframpingParameters & { - event: 'transaction_confirmation' | 'kyc_started' | 'kyc_completed' | 'transaction_success' | 'transaction_failure'; + event: + | 'transaction_confirmation' + | 'kyc_started' + | 'kyc_completed' + | 'transaction_success' + | 'transaction_failure' + | 'compare_quote'; }; export type TransactionFailedEvent = OfframpingParameters & { @@ -60,6 +67,13 @@ export type TransactionFailedEvent = OfframpingParameters & { error_message: string; }; +export type CompareQuoteEvent = OfframpingParameters & { + event: 'compare_quote'; + moonpay_quote: string; + alchemypay_quote: string; + transak_quote: string; +}; + export interface ProgressEvent { event: 'progress'; phase_name: string; @@ -107,6 +121,7 @@ export type TrackableEvent = | WalletConnectEvent | TransactionEvent | TransactionFailedEvent + | CompareQuoteEvent | ClickSupportEvent | FormErrorEvent | EmailSubmissionEvent @@ -125,9 +140,33 @@ const useEvents = () => { const previousChainId = useRef(undefined); const userClickedState = useRef(false); + const [scheduledQuotes, setScheduledQuotes] = useState>>({}); + const trackedEventTypes = useRef>(new Set()); const firedFormErrors = useRef>(new Set()); + const scheduleQuote = useCallback((service: QuoteService, quote: string, state: OfframpingState) => { + setScheduledQuotes((prev) => { + const newQuotes = { ...prev, [service]: quote }; + const sizeChanged = Object.keys(newQuotes).length !== Object.keys(prev).length; + + // If all quotes are ready, emit the event + if (sizeChanged && Object.keys(scheduledQuotes).length === 3) { + trackEvent({ + ...createTransactionEvent('compare_quote', state), + event: 'compare_quote', + transak_quote: newQuotes.transak, + moonpay_quote: newQuotes.moonpay, + alchemypay_quote: newQuotes.alchemypay, + }); + // Reset the quotes + return {}; + } + + return newQuotes; + }); + }, []); + const trackEvent = useCallback((event: TrackableEvent) => { if (UNIQUE_EVENT_TYPES.includes(event.event)) { if (trackedEventTypes.current.has(event.event)) { @@ -217,6 +256,7 @@ const useEvents = () => { trackEvent, resetUniqueEvents, handleUserClickWallet, + scheduleQuote, }; }; diff --git a/src/services/quotes/index.ts b/src/services/quotes/index.ts index 36577167..aed457f1 100644 --- a/src/services/quotes/index.ts +++ b/src/services/quotes/index.ts @@ -4,7 +4,7 @@ import { polygon } from 'wagmi/chains'; const QUOTE_ENDPOINT = `${SIGNING_SERVICE_URL}/v1/quotes`; -type QuoteService = 'moonpay' | 'transak' | 'alchemypay'; +export type QuoteService = 'moonpay' | 'transak' | 'alchemypay'; type SupportedNetworks = typeof polygon.name; From 98b751ca77951ae88611aef71f1bc30962e57a9c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 22 Nov 2024 18:36:25 +0100 Subject: [PATCH 45/61] Refactor --- src/components/FeeComparison/index.tsx | 24 ++++++- .../FeeComparison/quoteProviders.tsx | 10 +-- src/contexts/events.tsx | 71 ++++++++++++------- src/pages/swap/index.tsx | 2 +- 4 files changed, 74 insertions(+), 33 deletions(-) diff --git a/src/components/FeeComparison/index.tsx b/src/components/FeeComparison/index.tsx index da1acb08..0fa0b693 100644 --- a/src/components/FeeComparison/index.tsx +++ b/src/components/FeeComparison/index.tsx @@ -1,11 +1,13 @@ import Big from 'big.js'; import { useMemo } from 'preact/hooks'; +import { useEffect } from 'preact/compat'; import { useQuery } from '@tanstack/react-query'; import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { Skeleton } from '../Skeleton'; import vortexIcon from '../../assets/logo/blue.svg'; import { QuoteProvider, quoteProviders } from './quoteProviders'; import { NetworkType } from '../../constants/tokenConfig'; +import { OfframpingParameters, useEventsContext } from '../../contexts/events'; type FeeProviderRowProps = FeeComparisonProps & { provider: QuoteProvider }; @@ -35,14 +37,18 @@ function FeeProviderRow({ vortexPrice, network, }: FeeProviderRowProps) { - const { isLoading, error, data } = useQuery({ + const { scheduleQuote } = useEventsContext(); + + const { + isLoading, + error, + data: providerPrice, + } = useQuery({ queryKey: [sourceAssetSymbol, targetAssetSymbol, vortexPrice, provider.name, network], queryFn: () => provider.query(sourceAssetSymbol, targetAssetSymbol, amount, network), retry: false, // We don't want to retry the request to avoid spamming the server }); - const providerPrice = data?.lt(0) ? undefined : data; - const priceDiff = useMemo(() => { if (isLoading || error || !providerPrice) { return undefined; @@ -51,6 +57,18 @@ function FeeProviderRow({ return providerPrice.minus(vortexPrice); }, [isLoading, error, providerPrice, vortexPrice]); + useEffect(() => { + if (providerPrice || error) { + const parameters: OfframpingParameters = { + from_amount: amount.toFixed(2), + from_asset: sourceAssetSymbol, + to_amount: vortexPrice.toFixed(2), + to_asset: targetAssetSymbol, + }; + scheduleQuote(provider.name, providerPrice ? providerPrice.toFixed(2, 0) : '-1', parameters); + } + }, [amount, provider.name, scheduleQuote, sourceAssetSymbol, targetAssetSymbol, providerPrice, error, vortexPrice]); + return (
diff --git a/src/components/FeeComparison/quoteProviders.tsx b/src/components/FeeComparison/quoteProviders.tsx index 0a3b2e63..7d239ca8 100644 --- a/src/components/FeeComparison/quoteProviders.tsx +++ b/src/components/FeeComparison/quoteProviders.tsx @@ -1,10 +1,10 @@ -import { getQueryFnForService, QuoteQuery } from '../../services/quotes'; +import { getQueryFnForService, QuoteQuery, QuoteService } from '../../services/quotes'; import alchemyPayIcon from '../../assets/alchemypay.svg'; import moonpayIcon from '../../assets/moonpay.svg'; import transakIcon from '../../assets/transak.svg'; export interface QuoteProvider { - name: string; + name: QuoteService; icon?: JSX.Element; query: QuoteQuery; href: string; @@ -12,19 +12,19 @@ export interface QuoteProvider { export const quoteProviders: QuoteProvider[] = [ { - name: 'AlchemyPay', + name: 'alchemypay', icon: AlchemyPay, query: getQueryFnForService('alchemypay'), href: 'https://alchemypay.org', }, { - name: 'MoonPay', + name: 'moonpay', icon: Moonpay, query: getQueryFnForService('moonpay'), href: 'https://moonpay.com', }, { - name: 'Transak', + name: 'transak', icon: Transak, query: getQueryFnForService('transak'), href: 'https://transak.com', diff --git a/src/contexts/events.tsx b/src/contexts/events.tsx index 6864de80..fd140f0a 100644 --- a/src/contexts/events.tsx +++ b/src/contexts/events.tsx @@ -43,7 +43,7 @@ export interface WalletConnectEvent { account_address?: string; } -interface OfframpingParameters { +export interface OfframpingParameters { from_asset: string; to_asset: string; from_amount: string; @@ -140,33 +140,18 @@ const useEvents = () => { const previousChainId = useRef(undefined); const userClickedState = useRef(false); - const [scheduledQuotes, setScheduledQuotes] = useState>>({}); + const [_scheduledQuotes, setScheduledQuotes] = useState< + | { + from_asset: string; + to_asset: string; + quotes: Partial>; + } + | undefined + >(undefined); const trackedEventTypes = useRef>(new Set()); const firedFormErrors = useRef>(new Set()); - const scheduleQuote = useCallback((service: QuoteService, quote: string, state: OfframpingState) => { - setScheduledQuotes((prev) => { - const newQuotes = { ...prev, [service]: quote }; - const sizeChanged = Object.keys(newQuotes).length !== Object.keys(prev).length; - - // If all quotes are ready, emit the event - if (sizeChanged && Object.keys(scheduledQuotes).length === 3) { - trackEvent({ - ...createTransactionEvent('compare_quote', state), - event: 'compare_quote', - transak_quote: newQuotes.transak, - moonpay_quote: newQuotes.moonpay, - alchemypay_quote: newQuotes.alchemypay, - }); - // Reset the quotes - return {}; - } - - return newQuotes; - }); - }, []); - const trackEvent = useCallback((event: TrackableEvent) => { if (UNIQUE_EVENT_TYPES.includes(event.event)) { if (trackedEventTypes.current.has(event.event)) { @@ -196,6 +181,44 @@ const useEvents = () => { trackedEventTypes.current = new Set(); }, []); + /// This function is used to schedule a quote returned by a quote service. Once all quotes are ready, it emits a compare_quote event. + /// Calling this function with a quote of '-1' will make the function emit the quote as undefined. + const scheduleQuote = useCallback( + (service: QuoteService, quote: string, parameters: OfframpingParameters) => { + setScheduledQuotes((prev) => { + // Check if there is a mismatch in tokens used previously vs the ones passed n the latest state + const newQuotes = + prev && (prev.from_asset !== parameters.from_asset || prev.to_asset !== parameters.to_asset) + ? { + from_asset: parameters.from_asset, + to_asset: parameters.to_asset, + quotes: { [service]: quote }, + } + : { + from_asset: parameters.from_asset, + to_asset: parameters.to_asset, + quotes: { ...prev?.quotes, [service]: quote }, + }; + + // If all quotes are ready, emit the event + if (Object.keys(newQuotes.quotes).length === 3) { + trackEvent({ + ...parameters, + event: 'compare_quote', + transak_quote: newQuotes.quotes.transak !== '-1' ? newQuotes.quotes.transak : undefined, + moonpay_quote: newQuotes.quotes.moonpay !== '-1' ? newQuotes.quotes.moonpay : undefined, + alchemypay_quote: newQuotes.quotes.alchemypay !== '-1' ? newQuotes.quotes.alchemypay : undefined, + }); + // Reset the quotes + return undefined; + } + + return newQuotes; + }); + }, + [trackEvent], + ); + useEffect(() => { if (!chainId) return; diff --git a/src/pages/swap/index.tsx b/src/pages/swap/index.tsx index c0e6cdb6..89ea8136 100644 --- a/src/pages/swap/index.tsx +++ b/src/pages/swap/index.tsx @@ -264,7 +264,7 @@ export const SwapPage = () => { form.setValue('fromAmount', amount)} /> ), - [form, fromToken, setModalType], + [form, fromToken, setModalType, trackEvent], ); function getCurrentErrorMessage() { From 494bd7303fe402f2d325ba297fb72f23eeedb02c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 25 Nov 2024 13:07:11 +0100 Subject: [PATCH 46/61] Memoize values in swap form --- src/components/Nabla/useSwapForm.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/Nabla/useSwapForm.tsx b/src/components/Nabla/useSwapForm.tsx index 8d19fdc0..c26095f8 100644 --- a/src/components/Nabla/useSwapForm.tsx +++ b/src/components/Nabla/useSwapForm.tsx @@ -52,8 +52,8 @@ export const useSwapForm = () => { const from = useWatch({ control, name: 'from' }); const to = useWatch({ control, name: 'to' }); - const fromToken = from ? INPUT_TOKEN_CONFIG[from] : undefined; - const toToken = to ? OUTPUT_TOKEN_CONFIG[to] : undefined; + const fromToken = useMemo(() => (from ? INPUT_TOKEN_CONFIG[from] : undefined), [from]); + const toToken = useMemo(() => (to ? OUTPUT_TOKEN_CONFIG[to] : undefined), [to]); const onFromChange = useCallback( (tokenKey: string) => { @@ -96,12 +96,13 @@ export const useSwapForm = () => { defaultValue: '0', }); - let fromAmount: Big | undefined; - try { - fromAmount = new Big(fromAmountString); - } catch { - // no action required - } + const fromAmount: Big | undefined = useMemo(() => { + try { + return new Big(fromAmountString); + } catch { + return undefined; + } + }, [fromAmountString]); return { form, From aff3402edb0dae36567158536f173a6045c427a6 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 25 Nov 2024 13:26:45 +0100 Subject: [PATCH 47/61] Fix rerenders --- src/components/FeeComparison/index.tsx | 2 +- src/pages/swap/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/FeeComparison/index.tsx b/src/components/FeeComparison/index.tsx index 0fa0b693..244e9a3a 100644 --- a/src/components/FeeComparison/index.tsx +++ b/src/components/FeeComparison/index.tsx @@ -67,7 +67,7 @@ function FeeProviderRow({ }; scheduleQuote(provider.name, providerPrice ? providerPrice.toFixed(2, 0) : '-1', parameters); } - }, [amount, provider.name, scheduleQuote, sourceAssetSymbol, targetAssetSymbol, providerPrice, error, vortexPrice]); + }, [amount, provider.name, scheduleQuote, sourceAssetSymbol, targetAssetSymbol, providerPrice, vortexPrice, error]); return (
diff --git a/src/pages/swap/index.tsx b/src/pages/swap/index.tsx index 89ea8136..cfa1b9de 100644 --- a/src/pages/swap/index.tsx +++ b/src/pages/swap/index.tsx @@ -111,7 +111,7 @@ export const SwapPage = () => { const fromToken = INPUT_TOKEN_CONFIG[from]; const toToken = OUTPUT_TOKEN_CONFIG[to]; const formToAmount = form.watch('toAmount'); - const vortexPrice = formToAmount ? Big(formToAmount) : Big(0); + const vortexPrice = useMemo(() => (formToAmount ? Big(formToAmount) : Big(0)), [formToAmount]); const userInputTokenBalance = useInputTokenBalance({ fromToken }); From 52cf5f01c0d726c09a8b52a782a9963ab35a97d2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Mon, 25 Nov 2024 13:44:43 +0100 Subject: [PATCH 48/61] Fix compare_quote emitted too often --- src/contexts/events.tsx | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/contexts/events.tsx b/src/contexts/events.tsx index fd140f0a..870f8037 100644 --- a/src/contexts/events.tsx +++ b/src/contexts/events.tsx @@ -142,8 +142,7 @@ const useEvents = () => { const [_scheduledQuotes, setScheduledQuotes] = useState< | { - from_asset: string; - to_asset: string; + parameters: OfframpingParameters; quotes: Partial>; } | undefined @@ -186,34 +185,30 @@ const useEvents = () => { const scheduleQuote = useCallback( (service: QuoteService, quote: string, parameters: OfframpingParameters) => { setScheduledQuotes((prev) => { - // Check if there is a mismatch in tokens used previously vs the ones passed n the latest state + // Do a deep comparison of the parameters to check if they are the same. + // If they are not, reset the quotes. const newQuotes = - prev && (prev.from_asset !== parameters.from_asset || prev.to_asset !== parameters.to_asset) - ? { - from_asset: parameters.from_asset, - to_asset: parameters.to_asset, - quotes: { [service]: quote }, - } - : { - from_asset: parameters.from_asset, - to_asset: parameters.to_asset, - quotes: { ...prev?.quotes, [service]: quote }, - }; + prev && JSON.stringify(prev.parameters) !== JSON.stringify(parameters) + ? { [service]: quote } + : { ...prev?.quotes, [service]: quote }; // If all quotes are ready, emit the event - if (Object.keys(newQuotes.quotes).length === 3) { + if (Object.keys(newQuotes).length === 3) { trackEvent({ ...parameters, event: 'compare_quote', - transak_quote: newQuotes.quotes.transak !== '-1' ? newQuotes.quotes.transak : undefined, - moonpay_quote: newQuotes.quotes.moonpay !== '-1' ? newQuotes.quotes.moonpay : undefined, - alchemypay_quote: newQuotes.quotes.alchemypay !== '-1' ? newQuotes.quotes.alchemypay : undefined, + transak_quote: newQuotes.transak !== '-1' ? newQuotes.transak : undefined, + moonpay_quote: newQuotes.moonpay !== '-1' ? newQuotes.moonpay : undefined, + alchemypay_quote: newQuotes.alchemypay !== '-1' ? newQuotes.alchemypay : undefined, }); // Reset the quotes return undefined; } - return newQuotes; + return { + parameters, + quotes: newQuotes, + }; }); }, [trackEvent], From 4effa4d72cb26c2f09d281849cc5839961bd5d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torsten=20Stu=CC=88ber?= <15174476+TorstenStueber@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:53:12 -0300 Subject: [PATCH 49/61] Use netlify as a proxy --- .prettierignore | 3 ++- README.md | 11 ++++++++--- _redirects | 3 +++ package.json | 2 +- src/config/index.ts | 2 +- src/constants/constants.ts | 3 +-- 6 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 _redirects diff --git a/.prettierignore b/.prettierignore index 712a9294..ff87240a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,4 +19,5 @@ favicon.png CHANGELOG.md **/*.svg .prettierignore -.gitignore \ No newline at end of file +.gitignore +_redirects diff --git a/README.md b/README.md index a2d7e6e0..80c30326 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,15 @@ matter what URL the browser requests. ## Env Variables -- `VITE_SIGNING_SERVICE_URL`: Optional variable to point to a specific signing backend service URL. If undefined, it +- `VITE_SIGNING_SERVICE_PATH`: Optional variable to point to a specific signing backend service URL. If undefined, it will default to either: - - http://localhost:3000 (if in development mode) - - https://prototype-signer-service-polygon.pendulumchain.tech (if in production mode) + - `http://localhost:3000` (if in development mode) + - `/api/production` (if in production mode) + - this will use the `_redirects` file to direct Netlify to proxy all requests to `/api/production` to + `https://prototype-signer-service-polygon.pendulumchain.tech` + - `/api/staging` (if in staging mode) + - this will use the `_redirects` file to direct Netlify to proxy all requests to `/api/staging` to + `https://prototype-signer-service-polygon-staging.pendulumchain.tech` - `VITE_ALCHEMY_API_KEY`: Optional variable to set the Alchemy API key for the custom RPC provider. If undefined, it will use dhe default endpoint. diff --git a/_redirects b/_redirects new file mode 100644 index 00000000..b8c597fe --- /dev/null +++ b/_redirects @@ -0,0 +1,3 @@ +/api/production/* https://prototype-signer-service-polygon.pendulumchain.tech/:splat 200 +/api/staging/* https://prototype-signer-service-polygon-staging.pendulumchain.tech/:splat 200 +/* /index.html 200 diff --git a/package.json b/package.json index 7ea6876e..7de31ae1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "packageManager": "yarn@4.5.0+sha512.837566d24eec14ec0f5f1411adb544e892b3454255e61fdef8fd05f3429480102806bac7446bc9daff3896b01ae4b62d00096c7e989f1596f2af10b927532f39", "scripts": { "dev": "vite --host", - "build": "tsc && vite build && cp -R src/assets/coins dist/assets/coins && echo '/* /index.html 200' | cat > dist/_redirects", + "build": "tsc && vite build && cp -R src/assets/coins dist/assets/coins && cp _redirects dist/_redirects", "preview": "vite preview", "lint": "eslint . --ext .ts,.tsx", "lint:fix": "eslint . --ext .ts,.tsx --fix", diff --git a/src/config/index.ts b/src/config/index.ts index 68ae70f8..ce738c8d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -13,7 +13,7 @@ type TenantConfig = Record< type Environment = 'development' | 'staging' | 'production'; const nodeEnv = process.env.NODE_ENV as Environment; -const maybeSignerServiceUrl = import.meta.env.VITE_SIGNING_SERVICE_URL; +const maybeSignerServiceUrl = import.meta.env.VITE_SIGNING_SERVICE_PATH; const alchemyApiKey = import.meta.env.VITE_ALCHEMY_API_KEY; const env = (import.meta.env.VITE_ENVIRONMENT || nodeEnv) as Environment; diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 46c861a6..7ef85d10 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -14,5 +14,4 @@ export const AMM_MINIMUM_OUTPUT_HARD_MARGIN = 0.05; export const TRANSFER_WAITING_TIME_SECONDS = 6000; export const DEFAULT_LOGIN_EXPIRATION_TIME_HOURS = 7 * 24; export const SIGNING_SERVICE_URL = - config.maybeSignerServiceUrl || - (config.isProd ? 'https://prototype-signer-service-polygon.pendulumchain.tech' : 'http://localhost:3000'); + config.maybeSignerServiceUrl || (config.isProd ? '/api/production' : 'http://localhost:3000'); From b302f4ed594478705023ee6885156d3f1bf2db75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torsten=20Stu=CC=88ber?= <15174476+TorstenStueber@users.noreply.github.com> Date: Mon, 25 Nov 2024 20:24:37 -0300 Subject: [PATCH 50/61] Make cookie strict --- signer-service/src/api/controllers/siwe.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/signer-service/src/api/controllers/siwe.controller.js b/signer-service/src/api/controllers/siwe.controller.js index b9da9290..b07ddd62 100644 --- a/signer-service/src/api/controllers/siwe.controller.js +++ b/signer-service/src/api/controllers/siwe.controller.js @@ -27,7 +27,7 @@ exports.validateSiweSignature = async (req, res) => { res.cookie('authToken', token, { httpOnly: true, secure: true, - sameSite: 'None', + sameSite: 'Strict', maxAge: DEFAULT_LOGIN_EXPIRATION_TIME_HOURS * 60 * 60 * 1000, }); From f7d3816838b2a6b81f54d1f328cc60edbe8f4bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torsten=20Stu=CC=88ber?= <15174476+TorstenStueber@users.noreply.github.com> Date: Tue, 26 Nov 2024 08:38:16 -0300 Subject: [PATCH 51/61] Remove siwe uri and hosts checks --- signer-service/src/api/services/siwe.service.js | 15 +-------------- signer-service/src/constants/constants.js | 12 ------------ 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/signer-service/src/api/services/siwe.service.js b/signer-service/src/api/services/siwe.service.js index 7217fd36..a3dda7a6 100644 --- a/signer-service/src/api/services/siwe.service.js +++ b/signer-service/src/api/services/siwe.service.js @@ -1,12 +1,7 @@ const siwe = require('siwe'); const { createPublicClient, http } = require('viem'); const { polygon } = require('viem/chains'); -const { - DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, - VALID_SIWE_DOMAINS, - VALID_SIWE_CHAINS, - VALID_SIWE_LOGIN_ORIGINS, -} = require('../../constants/constants'); +const { DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, VALID_SIWE_CHAINS } = require('../../constants/constants'); class ValidationError extends Error { constructor(message) { @@ -79,14 +74,6 @@ const verifyInitialMessageFields = (siweMessage) => { const chainId = siweMessage.chainId; const expirationTime = siweMessage.expirationTime; - if (!VALID_SIWE_LOGIN_ORIGINS.includes(uri)) { - throw new ValidationError('Origin not in the list of allowed origins'); - } - - if (!VALID_SIWE_DOMAINS.includes(domain)) { - throw new ValidationError('Incorrect domain'); - } - if (!VALID_SIWE_CHAINS.includes(chainId)) { throw new ValidationError('Incorrect chain ID'); } diff --git a/signer-service/src/constants/constants.js b/signer-service/src/constants/constants.js index 47c4b798..5d1c57d2 100644 --- a/signer-service/src/constants/constants.js +++ b/signer-service/src/constants/constants.js @@ -10,17 +10,7 @@ const MOONBEAM_RECEIVER_CONTRACT_ADDRESS = '0x0004446021fe650c15fb0b2e046b39130e 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_DOMAINS = [ - 'localhost:5173', - 'polygon-prototype-staging--pendulum-pay.netlify.app', - 'app.vortexfinance.co', -]; const VALID_SIWE_CHAINS = [137]; // 137: Polygon -const VALID_SIWE_LOGIN_ORIGINS = [ - 'http://localhost:5173', - 'https://polygon-prototype-staging--pendulum-pay.netlify.app', - 'https://app.vortexfinance.co', -]; require('dotenv').config(); @@ -48,7 +38,5 @@ module.exports = { SEP10_MASTER_SECRET, CLIENT_DOMAIN_SECRET, DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, - VALID_SIWE_DOMAINS, VALID_SIWE_CHAINS, - VALID_SIWE_LOGIN_ORIGINS, }; From f9c9732a89ef8adbdc9ce1c672d294788a1a2df0 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 26 Nov 2024 16:30:31 +0100 Subject: [PATCH 52/61] Add downward arrow to asset dropdown --- src/components/buttons/AssetButton/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/buttons/AssetButton/index.tsx b/src/components/buttons/AssetButton/index.tsx index 29bd703e..23cc0331 100644 --- a/src/components/buttons/AssetButton/index.tsx +++ b/src/components/buttons/AssetButton/index.tsx @@ -1,10 +1,12 @@ import { AssetIconType, useGetIcon } from '../../../hooks/useGetIcon'; +import { ChevronDownIcon } from '@heroicons/react/20/solid'; interface AssetButtonProps { assetIcon: AssetIconType; tokenSymbol: string; onClick: () => void; } + export function AssetButton({ assetIcon, tokenSymbol, onClick }: AssetButtonProps) { const icon = useGetIcon(assetIcon); @@ -18,6 +20,7 @@ export function AssetButton({ assetIcon, tokenSymbol, onClick }: AssetButtonProp {assetIcon} {tokenSymbol} + ); } From 9e1ce1f9a48c6931ca0fa4eb7fb15eccc4f09963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torsten=20Stu=CC=88ber?= <15174476+TorstenStueber@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:34:26 -0300 Subject: [PATCH 53/61] Make main buttons 50% width --- src/components/buttons/SwapSubmitButton/index.tsx | 4 ++-- src/pages/swap/index.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/buttons/SwapSubmitButton/index.tsx b/src/components/buttons/SwapSubmitButton/index.tsx index 08ee51b3..56ceeb6f 100644 --- a/src/components/buttons/SwapSubmitButton/index.tsx +++ b/src/components/buttons/SwapSubmitButton/index.tsx @@ -17,7 +17,7 @@ export const SwapSubmitButton: FC = ({ text, disabled, pe if (!isConnected) { return ( -
+
@@ -26,7 +26,7 @@ export const SwapSubmitButton: FC = ({ text, disabled, pe } return ( -
+