diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 0b766a9..930dcd6 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -1,3 +1,8 @@ +# E2E / Testing +# -------------- +# Base URL for staging backend used by transaction.staging.e2e-spec.ts +STAGING_API_BASE_URL=https://api.testnet.polypay.pro + # Database DATABASE_URL="postgresql://polypay_user:polypay_password@localhost:5433/polypay_multisig_db" diff --git a/packages/backend/package.json b/packages/backend/package.json index f9d5e92..e00b7e2 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,6 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:e2e:staging": "jest --config ./test/jest-staging-e2e.json", "report": "ts-node scripts/generate-analytics-report.ts" }, "dependencies": { diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index f2dbb9c..c5b6b2e 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -1,45 +1,45 @@ -import * as request from 'supertest'; -import { type Hex, formatEther } from 'viem'; import { + resetDatabase, setupTestApp, teardownTestApp, - resetDatabase, - getHttpServer, - getTestApp, } from '../setup'; import { getSignerA, getSignerB } from '../fixtures/test-users'; -import { createTestSigner, TestSigner } from '../utils/signer.util'; -import { - generateSecret, - generateCommitment, - generateTestProof, -} from '../utils/proof.util'; +import { loginUser, AuthTokens } from '../utils/auth.util'; +import { TestIdentity, createTestIdentity } from '../utils/identity.util'; import { - depositToAccount, - getTransactionHash, - getAccountBalance, -} from '../utils/contract.util'; -import { getPrismaService } from '../utils/cleanup.util'; -import { loginUser, getAuthHeader, AuthTokens } from '../utils/auth.util'; + apiCreateAccount, + apiCreateBatchItem, + apiReserveNonce, + apiCreateTransaction, + apiApproveTransaction, + apiExecuteTransaction, + apiGetTransaction, + generateVotePayload, +} from '../utils/transaction.util'; +import { CreateAccountDto, TxStatus, TxType } from '@polypay/shared'; import { - API_ENDPOINTS, - CreateAccountDto, - TxStatus, - TxType, -} from '@polypay/shared'; - -// Timeout 5 minutes for blockchain calls -jest.setTimeout(600000); + TEST_CHAIN_ID, + TEST_RECIPIENT, + TEST_THRESHOLD, + TEST_TRANSFER_AMOUNT, + SCENARIOS, + type CreatedTx, + type ScenarioAmount, + type ParsedBatchItem, + getCreatedTxLabel, + buildSingleTransferParams, + buildSingleApproveParams, + buildBatchCallData, + buildBatchCallDataFromParsed, + fundAccountForScenario, +} from '../utils/multi-asset-flow.shared'; + +// Timeout 20 minutes for blockchain calls +jest.setTimeout(1200000); describe('Transaction E2E', () => { - let signerA: TestSigner; - let signerB: TestSigner; - let secretA: bigint; - let secretB: bigint; - let commitmentA: string; - let commitmentB: string; - let signerDtoA: any; - let signerDtoB: any; + let identityA: TestIdentity; + let identityB: TestIdentity; let tokensA: AuthTokens; let tokensB: AuthTokens; @@ -47,43 +47,15 @@ describe('Transaction E2E', () => { // Setup NestJS app await setupTestApp(); - // Setup test signers - signerA = createTestSigner(getSignerA()); - signerB = createTestSigner(getSignerB()); - - // Generate secrets and commitments - secretA = await generateSecret(signerA); - secretB = await generateSecret(signerB); - - const commitmentABigInt = await generateCommitment(secretA); - const commitmentBBigInt = await generateCommitment(secretB); - - commitmentA = commitmentABigInt.toString(); - commitmentB = commitmentBBigInt.toString(); - signerDtoA = { - commitment: commitmentA, - name: 'Signer A', - }; - signerDtoB = { - commitment: commitmentB, - name: 'Signer B', - }; - - console.log('Test setup complete:'); - console.log(' Signer A address:', signerA.address); - console.log(' Signer B address:', signerB.address); - console.log(' Commitment A:', commitmentA); - console.log(' Commitment B:', commitmentB); + identityA = await createTestIdentity(getSignerA, 'Signer A'); + identityB = await createTestIdentity(getSignerB, 'Signer B'); }); beforeEach(async () => { await resetDatabase(); // Login both users - console.log('\n--- Login Users ---'); - tokensA = await loginUser(secretA, commitmentA); - tokensB = await loginUser(secretB, commitmentB); - console.log(' User A logged in'); - console.log(' User B logged in'); + tokensA = await loginUser(identityA.secret, identityA.commitment); + tokensB = await loginUser(identityB.secret, identityB.commitment); }); afterAll(async () => { @@ -91,171 +63,262 @@ describe('Transaction E2E', () => { }); describe('Full transaction flow', () => { - it('should complete full flow: create account → create tx → approve → execute', async () => { - const server = getHttpServer(); + it('should complete full flow for ETH, ZEN and USDC transfers', async () => { + console.log('\n=== Multi-asset Transaction E2E: Start ==='); // ============ STEP 1: Create Account ============ - console.log('\n--- Step 1: Create Account ---'); - + console.log('Phase 1: Create Account - start'); + const createdAt = new Date().toISOString().replace('T', ' ').slice(0, 19); const dataCreateAccount: CreateAccountDto = { - name: 'Test Multi-Sig Account', - signers: [signerDtoA, signerDtoB], - threshold: 2, - chainId: 2651420, + name: `Multi-Sig Account ${createdAt}`, + signers: [identityA.signerDto, identityB.signerDto], + threshold: TEST_THRESHOLD, + chainId: TEST_CHAIN_ID, }; - const accountResponse = await request(server) - .post(API_ENDPOINTS.accounts.base) - .set(getAuthHeader(tokensA.accessToken)) - .send(dataCreateAccount) - .expect(201); - - expect(accountResponse.body).toHaveProperty('address'); - - const accountAddress = accountResponse.body.address as `0x${string}`; - console.log('Account created:'); - console.log(' Address:', accountAddress); - - // ============ STEP 2: Deposit ETH to Account ============ - console.log('\n--- Step 2: Deposit ETH to Account ---'); - - const balanceBefore = await getAccountBalance(accountAddress); - console.log(' Balance before:', formatEther(balanceBefore), 'ETH'); - - await depositToAccount(signerA, accountAddress, '0.001'); - - const balanceAfter = await getAccountBalance(accountAddress); - console.log(' Balance after:', formatEther(balanceAfter), 'ETH'); - - expect(balanceAfter).toBeGreaterThan(balanceBefore); - - // ============ STEP 3: Create Transaction ============ - console.log('\n--- Step 3: Create Transaction ---'); - - // 3.1 Reserve nonce - const reserveNonceResponse = await request(server) - .post(API_ENDPOINTS.transactions.reserveNonce) - .set(getAuthHeader(tokensA.accessToken)) - .send({ accountAddress }) - .expect(201); - - const nonce = reserveNonceResponse.body.nonce; - console.log(' Reserved nonce:', nonce); - - // 3.2 Prepare transfer params - const recipient = - '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; - const value = BigInt('1000000000000000'); // 0.001 ETH - const callData = '0x' as Hex; - - // 3.3 Get txHash from contract - const txHash = await getTransactionHash( - accountAddress, - BigInt(nonce), - recipient, - value, - callData, + const { address: accountAddress } = await apiCreateAccount( + tokensA.accessToken, + dataCreateAccount, ); - console.log(' TxHash from contract:', txHash); - - // 3.4 Generate proof for signer A - console.log(' Generating proof for Signer A...'); - const proofA = await generateTestProof(signerA, secretA, txHash); - console.log(' Proof A generated'); - - // 3.5 Create transaction - const createTxResponse = await request(server) - .post(API_ENDPOINTS.transactions.base) - .set(getAuthHeader(tokensA.accessToken)) - .send({ - nonce: nonce, + console.log('Phase 1: Create Account - done', { + accountAddress, + }); + + // ============ STEP 2: Fund account for all scenarios ============ + console.log('Phase 2: Fund account for all scenarios - start'); + const scenarioAmounts: ScenarioAmount[] = []; + + for (const scenario of SCENARIOS) { + console.log(`[${scenario.name}] Funding start`); + const funded = await fundAccountForScenario( + scenario, + accountAddress, + identityA, + ); + scenarioAmounts.push(funded); + console.log(`[${scenario.name}] Funding recorded`, { + amount: TEST_TRANSFER_AMOUNT, + }); + } + console.log('Phase 2: Fund account for all scenarios - done'); + + // ============ STEP 3: Create Transactions (3 single + 1 batch) ============ + console.log('Phase 3: Create transactions - start'); + const createdTxs: CreatedTx[] = []; + + // 3 single transfers (ETH, ZEN, USDC) + for (const amount of scenarioAmounts) { + console.log(`[${amount.scenario.name}] Create single transaction - start`); + + const { nonce } = await apiReserveNonce( + tokensA.accessToken, + accountAddress, + ); + + const { to, value, callData } = buildSingleTransferParams( + amount, + TEST_RECIPIENT, + ); + + const votePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(nonce), + to, + value, + callData, + ); + + const { txId } = await apiCreateTransaction(tokensA.accessToken, { + nonce, type: TxType.TRANSFER, - accountAddress: accountAddress, - to: recipient, - value: value.toString(), - threshold: 2, - creatorCommitment: commitmentA, - proof: proofA.proof, - publicInputs: proofA.publicInputs, - nullifier: proofA.nullifier, - }) - .expect(201); - - const txId = createTxResponse.body.txId; - console.log(' Transaction created, txId:', txId); - - // ============ STEP 4: Approve Transaction (Signer B) ============ - console.log('\n--- Step 4: Approve Transaction (Signer B) ---'); - - // 4.1 Get transaction details - const getTxResponse = await request(server) - .get(API_ENDPOINTS.transactions.byTxId(txId)) - .set(getAuthHeader(tokensA.accessToken)) - .expect(200); - - console.log(' Transaction status:', getTxResponse.body.status); - - // 4.2 Get txHash for approve - const txHashForApprove = await getTransactionHash( + accountAddress, + to: TEST_RECIPIENT, + value: amount.amountString, + threshold: TEST_THRESHOLD, + proof: votePayloadA.proof, + publicInputs: votePayloadA.publicInputs, + nullifier: votePayloadA.nullifier, + ...(amount.scenario.isNative + ? {} + : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), + }); + + createdTxs.push({ kind: 'single', scenario: amount.scenario, amount, txId }); + console.log(`[${amount.scenario.name}] Create single transaction - done`, { + txId, + }); + } + + // 1 batch tx (ETH + ZEN + USDC, same amounts) + console.log('Batch: Create batch items - start'); + const batchItemIds: string[] = []; + for (const amount of scenarioAmounts) { + const item = await apiCreateBatchItem(tokensA.accessToken, { + recipient: TEST_RECIPIENT, + amount: amount.amountString, + tokenAddress: amount.scenario.isNative + ? undefined + : (amount.scenario.tokenAddress as string), + }); + batchItemIds.push(item.id); + } + console.log('Batch: Create batch items - done', { batchItemIds }); + + const batchCallData = buildBatchCallData(scenarioAmounts, TEST_RECIPIENT); + + const { nonce: batchNonce } = await apiReserveNonce( + tokensA.accessToken, accountAddress, - BigInt(getTxResponse.body.nonce), - getTxResponse.body.to as `0x${string}`, - BigInt(getTxResponse.body.value), - callData, ); - // 4.3 Generate proof for signer B - console.log(' Generating proof for Signer B...'); - const proofB = await generateTestProof( - signerB, - secretB, - txHashForApprove, + const batchVotePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(batchNonce), + accountAddress as `0x${string}`, + 0n, + batchCallData, ); - console.log(' Proof B generated'); - - // 4.4 Approve transaction - await request(server) - .post(API_ENDPOINTS.transactions.approve(txId)) - .set(getAuthHeader(tokensB.accessToken)) - .send({ - voterCommitment: commitmentB, - proof: proofB.proof, - publicInputs: proofB.publicInputs, - nullifier: proofB.nullifier, - }) - .expect(201); - - console.log(' Transaction approved by Signer B'); - - // ============ STEP 5: Execute Transaction ============ - console.log('\n--- Step 5: Execute Transaction ---'); - const executeResponse = await request(server) - .post(API_ENDPOINTS.transactions.execute(txId)) - .set(getAuthHeader(tokensA.accessToken)) - .expect(201); - - expect(executeResponse.body).toHaveProperty('txHash'); - console.log(' Execute TxHash:', executeResponse.body.txHash); - - // ============ STEP 6: Verify Final State ============ - console.log('\n--- Step 6: Verify Final State ---'); - - const prisma = getPrismaService(getTestApp()); - - const finalTx = await prisma.transaction.findUnique({ - where: { txId: txId }, - include: { votes: true }, - }); - - expect(finalTx).not.toBeNull(); - expect(finalTx!.status).toBe(TxStatus.EXECUTED); - expect(finalTx!.votes.length).toBe(2); - - console.log('Final verification:'); - console.log(' Status:', finalTx!.status); - console.log(' Vote count:', finalTx!.votes.length); - console.log('\nāœ… Full transaction flow completed successfully!'); + const { txId: batchTxId } = await apiCreateTransaction( + tokensA.accessToken, + { + nonce: batchNonce, + type: TxType.BATCH, + accountAddress, + to: accountAddress, + value: '0', + threshold: TEST_THRESHOLD, + proof: batchVotePayloadA.proof, + publicInputs: batchVotePayloadA.publicInputs, + nullifier: batchVotePayloadA.nullifier, + batchItemIds, + }, + ); + createdTxs.push({ kind: 'batch', txId: batchTxId }); + console.log('Batch: Create batch transaction - done', { batchTxId }); + + console.log('Phase 3: Create transactions - done'); + + // ============ STEP 4: Approve all 4 Transactions (Signer B) ============ + console.log('Phase 4: Approve transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Approve transaction - start`, { txId }); + + const txDetails = (await apiGetTransaction( + tokensA.accessToken, + txId, + )) as { + nonce: number; + to?: string; + value?: string; + tokenAddress?: string | null; + batchData?: string; + }; + + if (entry.kind === 'batch') { + if (txDetails.batchData == null) { + throw new Error(`Batch tx ${txId} missing batchData`); + } + const parsedBatch = JSON.parse(txDetails.batchData) as ParsedBatchItem[]; + const callDataApprove = buildBatchCallDataFromParsed(parsedBatch); + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + accountAddress as `0x${string}`, + 0n, + callDataApprove, + ); + await apiApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } else { + const { to: toApprove, value: valueApprove, callData: callDataApprove } = + buildSingleApproveParams(txDetails); + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + toApprove, + valueApprove, + callDataApprove, + ); + await apiApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } + + console.log(`[${label}] Approve transaction - done`, { txId }); + } + console.log('Phase 4: Approve transactions - done'); + + // ============ STEP 5: Execute all 4 Transactions sequentially ============ + console.log('Phase 5: Execute transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Execute transaction - start`, { txId }); + + const { txHash } = await apiExecuteTransaction( + tokensA.accessToken, + txId, + ); + expect(txHash).toBeDefined(); + + console.log(`[${label}] Execute transaction - done`, { txId, txHash }); + } + console.log('Phase 5: Execute transactions - done'); + + // ============ STEP 6: Verify Final State for all 4 transactions ============ + console.log('Phase 6: Verify final state - start'); + + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + + const finalTx = (await apiGetTransaction( + tokensA.accessToken, + txId, + )) as { + status: TxStatus; + votes: unknown[]; + tokenAddress?: string | null; + value?: string; + } | null; + + expect(finalTx).not.toBeNull(); + expect(finalTx!.status).toBe(TxStatus.EXECUTED); + expect(finalTx!.votes.length).toBe(2); + + if (entry.kind === 'single') { + if (entry.scenario.isNative) { + expect(finalTx!.tokenAddress).toBeNull(); + } else { + expect(finalTx!.tokenAddress?.toLowerCase()).toBe( + (entry.scenario.tokenAddress as string).toLowerCase(), + ); + } + expect(finalTx!.value).toBe(entry.amount.amountString); + } + + console.log(`[${label}] Final verification - done`, { + status: finalTx?.status, + votes: finalTx?.votes.length, + }); + } + + console.log('Phase 6: Verify final state - done'); + console.log('=== Multi-asset Transaction E2E: Done ===\n'); }); }); }); diff --git a/packages/backend/test/e2e/transaction.staging.e2e-spec.ts b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts new file mode 100644 index 0000000..472f4e3 --- /dev/null +++ b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts @@ -0,0 +1,345 @@ +import { getSignerA, getSignerB } from '../fixtures/test-users'; +import { TestIdentity, createTestIdentity } from '../utils/identity.util'; +import { + stagingLogin, + stagingCreateAccount, + stagingReserveNonce, + stagingCreateTransaction, + stagingApproveTransaction, + stagingExecuteTransaction, + stagingGetTransaction, + stagingCreateBatchItem, +} from '../utils/staging-api.util'; +import { CreateAccountDto, TxStatus, TxType } from '@polypay/shared'; +import { generateVotePayload } from '../utils/transaction.util'; +import { + TEST_CHAIN_ID, + TEST_RECIPIENT, + TEST_THRESHOLD, + TEST_TRANSFER_AMOUNT, + SCENARIOS, + type CreatedTx, + type ScenarioAmount, + type ParsedBatchItem, + getCreatedTxLabel, + buildSingleTransferParams, + buildSingleApproveParams, + buildBatchCallData, + buildBatchCallDataFromParsed, + fundAccountForScenario, +} from '../utils/multi-asset-flow.shared'; + +async function waitForExecuted( + accessToken: string, + txId: string, + label: string, +): Promise { + const maxAttempts = 20; + const intervalMs = 15000; + + let lastStatus: string | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const tx = await stagingGetTransaction(accessToken, txId); + lastStatus = tx.status; + + if (tx.status === TxStatus.EXECUTED) { + return tx; + } + + console.log( + `[${label}] Waiting for EXECUTED - attempt ${attempt}, status=${tx.status}`, + ); + + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + throw new Error( + `[${label}] Transaction ${txId} did not reach EXECUTED within timeout. Last status=${lastStatus}`, + ); +} + +// Timeout 20 minutes for blockchain calls +jest.setTimeout(1200000); + +describe('Transaction Staging E2E', () => { + let identityA: TestIdentity; + let identityB: TestIdentity; + let tokensA: { accessToken: string; refreshToken: string }; + let tokensB: { accessToken: string; refreshToken: string }; + + beforeAll(async () => { + identityA = await createTestIdentity(getSignerA, 'Signer A'); + identityB = await createTestIdentity(getSignerB, 'Signer B'); + }); + + beforeEach(async () => { + tokensA = await stagingLogin(identityA.secret, identityA.commitment); + tokensB = await stagingLogin(identityB.secret, identityB.commitment); + }); + + describe('Full transaction flow (staging)', () => { + it('should complete full flow for ETH, ZEN and USDC transfers + batch on staging', async () => { + console.log('\n=== Multi-asset Transaction E2E (Staging): Start ==='); + + // ============ STEP 1: Create Account ============ + console.log('Phase 1: Create Account - start'); + const createdAt = new Date().toISOString().replace('T', ' ').slice(0, 19); + const dataCreateAccount: CreateAccountDto = { + name: `Multi-Sig Account ${createdAt}`, + signers: [identityA.signerDto, identityB.signerDto], + threshold: TEST_THRESHOLD, + chainId: TEST_CHAIN_ID, + }; + + const { address: accountAddress } = await stagingCreateAccount( + tokensA.accessToken, + dataCreateAccount, + ); + console.log('Phase 1: Create Account - done', { + accountAddress, + }); + + // ============ STEP 2: Fund account for all scenarios ============ + console.log('Phase 2: Fund account for all scenarios - start'); + const scenarioAmounts: ScenarioAmount[] = []; + + for (const scenario of SCENARIOS) { + console.log(`[${scenario.name}] Funding start`); + const funded = await fundAccountForScenario( + scenario, + accountAddress, + identityA, + ); + scenarioAmounts.push(funded); + console.log(`[${scenario.name}] Funding recorded`, { + amount: TEST_TRANSFER_AMOUNT, + }); + } + console.log('Phase 2: Fund account for all scenarios - done'); + + // ============ STEP 3: Create Transactions (3 single + 1 batch) ============ + console.log('Phase 3: Create transactions - start'); + const createdTxs: CreatedTx[] = []; + + // 3 single transfers (ETH, ZEN, USDC) + for (const amount of scenarioAmounts) { + console.log(`[${amount.scenario.name}] Create single transaction - start`); + + const { nonce } = await stagingReserveNonce( + tokensA.accessToken, + accountAddress, + ); + + const { to, value, callData } = buildSingleTransferParams( + amount, + TEST_RECIPIENT, + ); + + const votePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(nonce), + to, + value, + callData, + ); + + const { txId } = await stagingCreateTransaction(tokensA.accessToken, { + nonce, + type: TxType.TRANSFER, + accountAddress, + to: TEST_RECIPIENT, + value: amount.amountString, + threshold: TEST_THRESHOLD, + proof: votePayloadA.proof, + publicInputs: votePayloadA.publicInputs, + nullifier: votePayloadA.nullifier, + ...(amount.scenario.isNative + ? {} + : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), + }); + + createdTxs.push({ kind: 'single', scenario: amount.scenario, amount, txId }); + console.log(`[${amount.scenario.name}] Create single transaction - done`, { + txId, + }); + } + + // 1 batch tx (ETH + ZEN + USDC, same amounts) + console.log('Batch: Create batch items - start'); + const batchItemIds: string[] = []; + for (const amount of scenarioAmounts) { + const item = await stagingCreateBatchItem(tokensA.accessToken, { + recipient: TEST_RECIPIENT, + amount: amount.amountString, + tokenAddress: amount.scenario.isNative + ? undefined + : (amount.scenario.tokenAddress as string), + }); + batchItemIds.push(item.id); + } + console.log('Batch: Create batch items - done', { batchItemIds }); + + const batchCallData = buildBatchCallData( + scenarioAmounts, + TEST_RECIPIENT, + ); + + const { nonce: batchNonce } = await stagingReserveNonce( + tokensA.accessToken, + accountAddress, + ); + + const batchVotePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(batchNonce), + accountAddress as `0x${string}`, + 0n, + batchCallData, + ); + + const { txId: batchTxId } = await stagingCreateTransaction( + tokensA.accessToken, + { + nonce: batchNonce, + type: TxType.BATCH, + accountAddress, + to: accountAddress, + value: '0', + threshold: TEST_THRESHOLD, + proof: batchVotePayloadA.proof, + publicInputs: batchVotePayloadA.publicInputs, + nullifier: batchVotePayloadA.nullifier, + batchItemIds, + }, + ); + createdTxs.push({ kind: 'batch', txId: batchTxId }); + console.log('Batch: Create batch transaction - done', { batchTxId }); + + console.log('Phase 3: Create transactions - done'); + + // ============ STEP 4: Approve all 4 Transactions (Signer B) ============ + console.log('Phase 4: Approve transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Approve transaction - start`, { txId }); + + const txDetails = (await stagingGetTransaction( + tokensA.accessToken, + txId, + )) as { + nonce: number; + to?: string; + value?: string; + tokenAddress?: string | null; + batchData?: string; + }; + + if (entry.kind === 'batch') { + if (txDetails.batchData == null) { + throw new Error(`Batch tx ${txId} missing batchData`); + } + const parsedBatch = JSON.parse(txDetails.batchData) as ParsedBatchItem[]; + const callDataApprove = buildBatchCallDataFromParsed(parsedBatch); + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + accountAddress as `0x${string}`, + 0n, + callDataApprove, + ); + await stagingApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } else { + const { to: toApprove, value: valueApprove, callData: callDataApprove } = + buildSingleApproveParams(txDetails); + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + toApprove, + valueApprove, + callDataApprove, + ); + await stagingApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } + + console.log(`[${label}] Approve transaction - done`, { txId }); + } + console.log('Phase 4: Approve transactions - done'); + + // ============ STEP 5: Execute all 4 Transactions sequentially ============ + console.log('Phase 5: Execute transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Execute transaction - start`, { txId }); + + const { txHash } = await stagingExecuteTransaction( + tokensA.accessToken, + txId, + ); + expect(txHash).toBeDefined(); + + console.log(`[${label}] Execute transaction - done`, { txId, txHash }); + } + console.log('Phase 5: Execute transactions - done'); + + // ============ STEP 6: Verify Final State for all 4 transactions ============ + console.log('Phase 6: Verify final state - start'); + + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + + const finalTx = (await waitForExecuted( + tokensA.accessToken, + txId, + label, + )) as { + status: TxStatus; + votes: unknown[]; + tokenAddress?: string | null; + value?: string; + } | null; + + expect(finalTx).not.toBeNull(); + expect(finalTx!.status).toBe(TxStatus.EXECUTED); + + if (entry.kind === 'single') { + if (entry.scenario.isNative) { + expect(finalTx!.tokenAddress).toBeNull(); + } else { + expect(finalTx!.tokenAddress?.toLowerCase()).toBe( + (entry.scenario.tokenAddress as string).toLowerCase(), + ); + } + expect(finalTx!.value).toBe(entry.amount.amountString); + } + + console.log(`[${label}] Final verification - done`, { + status: finalTx?.status, + }); + } + + console.log('Phase 6: Verify final state - done'); + console.log('=== Multi-asset Transaction E2E (Staging): Done ===\n'); + }); + }); +}); + diff --git a/packages/backend/test/jest-e2e.json b/packages/backend/test/jest-e2e.json index 85eb9f6..0fbc892 100644 --- a/packages/backend/test/jest-e2e.json +++ b/packages/backend/test/jest-e2e.json @@ -2,7 +2,7 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "testRegex": "transaction.e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/packages/backend/test/jest-staging-e2e.json b/packages/backend/test/jest-staging-e2e.json new file mode 100644 index 0000000..8165d84 --- /dev/null +++ b/packages/backend/test/jest-staging-e2e.json @@ -0,0 +1,19 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": "transaction.staging.e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@polypay/shared$": "/../../shared/src", + "^@polypay/shared/(.*)$": "/../../shared/src/$1", + "^@/(.*)$": "/../src/$1" + }, + "testTimeout": 1200000, + "verbose": true, + "forceExit": true, + "detectOpenHandles": true +} + diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts index ee6c6a5..c82a2c4 100644 --- a/packages/backend/test/jest.setup.ts +++ b/packages/backend/test/jest.setup.ts @@ -6,11 +6,17 @@ // Increase timeout for blockchain calls jest.setTimeout(300000); -// Load environment variables from .env.test if exists +// Load environment variables from .env.test if exists, otherwise fallback to .env import * as dotenv from 'dotenv'; import * as path from 'path'; +import * as fs from 'fs'; -dotenv.config({ path: path.resolve(__dirname, '../.env.test') }); +const envTestPath = path.resolve(__dirname, '../.env.test'); +const envPath = fs.existsSync(envTestPath) + ? envTestPath + : path.resolve(__dirname, '../.env'); + +dotenv.config({ path: envPath }); // Validate required environment variables const requiredEnvVars = [ diff --git a/packages/backend/test/utils/identity.util.ts b/packages/backend/test/utils/identity.util.ts new file mode 100644 index 0000000..0454fe0 --- /dev/null +++ b/packages/backend/test/utils/identity.util.ts @@ -0,0 +1,34 @@ +import { createTestSigner, TestSigner } from './signer.util'; +import { generateCommitment, generateSecret } from './proof.util'; +import { type TestUser } from '../fixtures/test-users'; + +export interface TestIdentity { + signer: TestSigner; + secret: bigint; + commitment: string; + signerDto: { + commitment: string; + name: string; + }; +} + +export async function createTestIdentity( + getUser: () => TestUser, + name: string, +): Promise { + const signer = createTestSigner(getUser()); + const secret = await generateSecret(signer); + const commitmentBigInt = await generateCommitment(secret); + const commitment = commitmentBigInt.toString(); + + return { + signer, + secret, + commitment, + signerDto: { + commitment, + name, + }, + }; +} + diff --git a/packages/backend/test/utils/multi-asset-flow.shared.ts b/packages/backend/test/utils/multi-asset-flow.shared.ts new file mode 100644 index 0000000..de7a16b --- /dev/null +++ b/packages/backend/test/utils/multi-asset-flow.shared.ts @@ -0,0 +1,231 @@ +import { type Hex, formatEther, parseEther } from 'viem'; +import { + encodeERC20Transfer, + encodeBatchTransferMulti, + formatTokenAmount, + ZEN_TOKEN, + USDC_TOKEN, + ZERO_ADDRESS, +} from '@polypay/shared'; +import { depositToAccount, getAccountBalance } from './contract.util'; +import { + toTokenAmount, + transferErc20FromSigner, + getErc20Balance, +} from './transaction.util'; +import type { TestIdentity } from './identity.util'; + +export const TEST_CHAIN_ID = 2651420; +export const TEST_TRANSFER_AMOUNT = '0.0001'; +export const TEST_FUND_ETH_AMOUNT = '0.0003'; +export const TEST_FUND_ERC20_MULTIPLIER = 2; +export const TEST_RECIPIENT = + '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; +export const TEST_THRESHOLD = 2; + +export type AssetName = 'ETH' | 'ZEN' | 'USDC'; + +export interface AssetScenario { + name: AssetName; + isNative: boolean; + tokenAddress: `0x${string}` | null; + decimals: number; +} + +export const SCENARIOS: AssetScenario[] = [ + { + name: 'ETH', + isNative: true, + tokenAddress: null, + decimals: 18, + }, + { + name: 'ZEN', + isNative: false, + tokenAddress: ZEN_TOKEN.addresses[TEST_CHAIN_ID] as `0x${string}`, + decimals: ZEN_TOKEN.decimals, + }, + { + name: 'USDC', + isNative: false, + tokenAddress: USDC_TOKEN.addresses[TEST_CHAIN_ID] as `0x${string}`, + decimals: USDC_TOKEN.decimals, + }, +]; + +export interface ScenarioAmount { + scenario: AssetScenario; + amountBigInt: bigint; + amountString: string; +} + +export type CreatedTx = + | { + kind: 'single'; + scenario: AssetScenario; + amount: ScenarioAmount; + txId: string; + } + | { kind: 'batch'; txId: string }; + +export interface ParsedBatchItem { + recipient: string; + amount: string; + tokenAddress?: string | null; +} + +export function getCreatedTxLabel(entry: CreatedTx): string { + return entry.kind === 'single' ? entry.scenario.name : 'batch'; +} + +/** Match frontend: ETH = (recipient, value, 0x); ERC20 = (tokenAddress, 0, encodeERC20Transfer) */ +export function buildSingleTransferParams( + amount: ScenarioAmount, + recipient: `0x${string}`, +): { to: `0x${string}`; value: bigint; callData: Hex } { + const to = amount.scenario.isNative + ? recipient + : (amount.scenario.tokenAddress as `0x${string}`); + const value = amount.scenario.isNative ? amount.amountBigInt : 0n; + const callData = amount.scenario.isNative + ? ('0x' as Hex) + : (encodeERC20Transfer(recipient, amount.amountBigInt) as Hex); + return { to, value, callData }; +} + +/** Build approve params from API tx details (single transfer). */ +export function buildSingleApproveParams(txDetails: { + to?: string; + value?: string; + tokenAddress?: string | null; +}): { to: `0x${string}`; value: bigint; callData: Hex } { + if (txDetails.to === undefined || txDetails.value === undefined) { + throw new Error('Single transfer txDetails must have to and value'); + } + const to = (txDetails.tokenAddress ?? txDetails.to) as `0x${string}`; + const value = txDetails.tokenAddress ? 0n : BigInt(txDetails.value); + const callData = txDetails.tokenAddress + ? (encodeERC20Transfer(txDetails.to, BigInt(txDetails.value)) as Hex) + : ('0x' as Hex); + return { to, value, callData }; +} + +export function buildBatchCallData( + scenarioAmounts: ScenarioAmount[], + recipient: `0x${string}`, +): Hex { + const recipients = [ + recipient, + recipient, + recipient, + ] as `0x${string}`[]; + const amounts = scenarioAmounts.map((a) => a.amountBigInt); + const tokenAddresses = scenarioAmounts.map((a) => + a.scenario.tokenAddress ?? (ZERO_ADDRESS as `0x${string}`), + ); + return encodeBatchTransferMulti( + recipients, + amounts, + tokenAddresses as string[], + ) as Hex; +} + +export function buildBatchCallDataFromParsed( + parsedBatch: ParsedBatchItem[], +): Hex { + const recipients = parsedBatch.map((p) => p.recipient as `0x${string}`); + const amounts = parsedBatch.map((p) => BigInt(p.amount)); + const tokenAddresses = parsedBatch.map( + (p) => p.tokenAddress || ZERO_ADDRESS, + ); + return encodeBatchTransferMulti( + recipients, + amounts, + tokenAddresses, + ) as Hex; +} + +/** + * Fund account: 2x per asset (single tx 0.0001 + batch item 0.0001). + * Returns ScenarioAmount for one transfer (0.0001) used by both single tx and batch item. + */ +export async function fundAccountForScenario( + scenario: AssetScenario, + accountAddress: `0x${string}`, + identityA: TestIdentity, +): Promise { + if (scenario.isNative) { + const balanceBefore = await getAccountBalance(accountAddress); + await depositToAccount( + identityA.signer, + accountAddress, + TEST_FUND_ETH_AMOUNT, + ); + const balanceAfter = await getAccountBalance(accountAddress); + + console.log(`[${scenario.name}] Fund native ETH - done`, { + balanceBefore: formatEther(balanceBefore), + balanceAfter: formatEther(balanceAfter), + }); + + const value = parseEther(TEST_TRANSFER_AMOUNT); + return { + scenario, + amountBigInt: value, + amountString: value.toString(), + }; + } + + if (!scenario.tokenAddress) { + throw new Error( + `Token address is required for ERC20 scenario: ${scenario.name}`, + ); + } + + const fundHuman = ( + parseFloat(TEST_TRANSFER_AMOUNT) * TEST_FUND_ERC20_MULTIPLIER + ).toFixed(scenario.decimals); + const { amountBigInt: fundAmount } = toTokenAmount( + fundHuman, + scenario.decimals, + ); + const { amountBigInt, amountString } = toTokenAmount( + TEST_TRANSFER_AMOUNT, + scenario.decimals, + ); + + const balanceBefore = await getErc20Balance( + accountAddress, + scenario.tokenAddress, + ); + + await transferErc20FromSigner( + identityA.signer, + scenario.tokenAddress, + accountAddress, + fundAmount, + ); + + const balanceAfter = await getErc20Balance( + accountAddress, + scenario.tokenAddress, + ); + + console.log(`[${scenario.name}] Fund ERC20 (2x) - done`, { + tokenAddress: scenario.tokenAddress, + balanceBefore: formatTokenAmount( + balanceBefore.toString(), + scenario.decimals, + ), + balanceAfter: formatTokenAmount( + balanceAfter.toString(), + scenario.decimals, + ), + }); + + return { + scenario, + amountBigInt, + amountString, + }; +} diff --git a/packages/backend/test/utils/staging-api.util.ts b/packages/backend/test/utils/staging-api.util.ts new file mode 100644 index 0000000..ad7699d --- /dev/null +++ b/packages/backend/test/utils/staging-api.util.ts @@ -0,0 +1,197 @@ +import axios from 'axios'; +import { + API_ENDPOINTS, + CreateAccountDto, + TxType, +} from '@polypay/shared'; +import type { CreateTransactionPayload, ApproveTransactionPayload, CreateBatchItemPayload } from './transaction.util'; +import type { AuthTokens } from './auth.util'; +import { generateTestAuthProof } from './proof.util'; + +const BASE_URL = + process.env.STAGING_API_BASE_URL || 'https://api.testnet.polypay.pro'; + +function authHeaders(accessToken: string) { + return { Authorization: `Bearer ${accessToken}` }; +} + +export async function stagingLogin( + secret: bigint, + commitment: string, +): Promise { + const authProof = await generateTestAuthProof(secret); + + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.auth.login}`, + { + commitment, + proof: authProof.proof, + publicInputs: authProof.publicInputs, + }, + ); + + return { + accessToken: response.data.accessToken, + refreshToken: response.data.refreshToken, + }; +} + +export async function stagingCreateAccount( + accessToken: string, + dto: CreateAccountDto, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.accounts.base}`, + dto, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { address: `0x${string}` }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingCreateAccount error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingCreateBatchItem( + accessToken: string, + payload: CreateBatchItemPayload, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.batchItems.base}`, + payload, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { id: string }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingCreateBatchItem error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingReserveNonce( + accessToken: string, + accountAddress: `0x${string}`, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.reserveNonce}`, + { accountAddress }, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { nonce: number }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingReserveNonce error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingCreateTransaction( + accessToken: string, + payload: CreateTransactionPayload, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.base}`, + payload, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { txId: string }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingCreateTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingApproveTransaction( + accessToken: string, + txId: string, + payload: ApproveTransactionPayload, +) { + try { + await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.approve(Number(txId))}`, + payload, + { headers: authHeaders(accessToken) }, + ); + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingApproveTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingExecuteTransaction( + accessToken: string, + txId: string, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.execute(Number(txId))}`, + undefined, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { txHash: string }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingExecuteTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingGetTransaction( + accessToken: string, + txId: string, +) { + try { + const response = await axios.get( + `${BASE_URL}${API_ENDPOINTS.transactions.byTxId(Number(txId))}`, + { headers: authHeaders(accessToken) }, + ); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingGetTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + diff --git a/packages/backend/test/utils/transaction.util.ts b/packages/backend/test/utils/transaction.util.ts new file mode 100644 index 0000000..695846a --- /dev/null +++ b/packages/backend/test/utils/transaction.util.ts @@ -0,0 +1,250 @@ +import * as request from 'supertest'; +import { type Hex } from 'viem'; +import { + API_ENDPOINTS, + CreateAccountDto, + TxType, +} from '@polypay/shared'; +import { parseTokenAmount } from '@polypay/shared'; +import { getHttpServer } from '../setup'; +import { getAuthHeader } from './auth.util'; +import { createTestPublicClient, getTransactionHash } from './contract.util'; +import { generateTestProof } from './proof.util'; +import { type TestIdentity } from './identity.util'; +import { type TestSigner } from './signer.util'; +import { waitForReceiptWithRetry } from '@/common/utils/retry'; + +export interface CreateTransactionPayload { + nonce: number; + type: TxType; + accountAddress: `0x${string}`; + to: `0x${string}`; + value: string; + threshold: number; + tokenAddress?: string | null; + proof: number[]; + publicInputs: string[]; + nullifier: string; + /** For TxType.BATCH: IDs from apiCreateBatchItem (order = execution order) */ + batchItemIds?: string[]; +} + +export interface ApproveTransactionPayload { + proof: number[]; + publicInputs: string[]; + nullifier: string; +} + +export interface ParsedTokenAmount { + amountString: string; + amountBigInt: bigint; +} + +export async function apiCreateAccount( + accessToken: string, + dto: CreateAccountDto, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.accounts.base) + .set(getAuthHeader(accessToken)) + .send(dto) + .expect(201); + + return response.body as { address: `0x${string}` }; +} + +export async function apiReserveNonce( + accessToken: string, + accountAddress: `0x${string}`, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.transactions.reserveNonce) + .set(getAuthHeader(accessToken)) + .send({ accountAddress }) + .expect(201); + + return response.body as { nonce: number }; +} + +export async function apiCreateTransaction( + accessToken: string, + payload: CreateTransactionPayload, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.transactions.base) + .set(getAuthHeader(accessToken)) + .send(payload) + .expect(201); + + return response.body as { txId: string }; +} + +export async function apiApproveTransaction( + accessToken: string, + txId: string, + payload: ApproveTransactionPayload, +) { + const server = getHttpServer(); + + await request(server) + .post(API_ENDPOINTS.transactions.approve(Number(txId))) + .set(getAuthHeader(accessToken)) + .send(payload) + .expect(201); +} + +export async function apiExecuteTransaction( + accessToken: string, + txId: string, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.transactions.execute(Number(txId))) + .set(getAuthHeader(accessToken)) + .expect(201); + + return response.body as { txHash: string }; +} + +export async function apiGetTransaction(accessToken: string, txId: string) { + const server = getHttpServer(); + + const response = await request(server) + .get(API_ENDPOINTS.transactions.byTxId(Number(txId))) + .set(getAuthHeader(accessToken)) + .expect(200); + + return response.body; +} + +export interface CreateBatchItemPayload { + recipient: string; + amount: string; + tokenAddress?: string | null; +} + +export async function apiCreateBatchItem( + accessToken: string, + payload: CreateBatchItemPayload, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.batchItems.base) + .set(getAuthHeader(accessToken)) + .send(payload) + .expect(201); + + return response.body as { id: string }; +} + +export async function generateVotePayload( + identity: TestIdentity, + accountAddress: `0x${string}`, + nonce: bigint, + to: `0x${string}`, + value: bigint, + callData: Hex = '0x', +): Promise { + const txHash = await getTransactionHash( + accountAddress, + nonce, + to, + value, + callData, + ); + + const proof = await generateTestProof(identity.signer, identity.secret, txHash); + + return { + proof: proof.proof, + publicInputs: proof.publicInputs, + nullifier: proof.nullifier, + }; +} + +/** + * Convert human-readable token amount to smallest unit (string + bigint) + * using shared parseTokenAmount helper. + */ +export function toTokenAmount( + humanAmount: string, + decimals: number, +): ParsedTokenAmount { + const amountString = parseTokenAmount(humanAmount, decimals); + return { + amountString, + amountBigInt: BigInt(amountString), + }; +} + +/** + * Transfer ERC20 tokens from a test signer to a recipient address. + * Uses standard ERC20 transfer(address,uint256) and waits for confirmation. + */ +export async function transferErc20FromSigner( + signer: TestSigner, + tokenAddress: `0x${string}`, + to: `0x${string}`, + amount: bigint, +): Promise { + const hash = await signer.walletClient.writeContract({ + account: signer.account, + address: tokenAddress, + abi: [ + { + name: 'transfer', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'success', internalType: 'bool' as const }], + }, + ], + functionName: 'transfer', + args: [to, amount], + } as any); + + const publicClient = createTestPublicClient(); + await waitForReceiptWithRetry(publicClient as any, hash); + + return hash as Hex; +} + +/** + * Read ERC20 token balance for an address. + */ +export async function getErc20Balance( + accountAddress: `0x${string}`, + tokenAddress: `0x${string}`, +): Promise { + const publicClient = createTestPublicClient(); + + const balance = await publicClient.readContract({ + address: tokenAddress, + abi: [ + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: 'balance', type: 'uint256' }], + }, + ], + functionName: 'balanceOf', + args: [accountAddress], + }); + + return balance as bigint; +} + + diff --git a/packages/shared/src/constants/token.ts b/packages/shared/src/constants/token.ts index 2cf7e3e..645ba26 100644 --- a/packages/shared/src/constants/token.ts +++ b/packages/shared/src/constants/token.ts @@ -39,7 +39,7 @@ export const NATIVE_ETH: Token = { export const ZEN_TOKEN: Token = { addresses: { - [HORIZEN_TESTNET]: "0x4b36cb6E7c257E9aA246122a997be0F7Dc1eFCd1", + [HORIZEN_TESTNET]: "0xb06EC4ce262D8dbDc24Fac87479A49A7DC4cFb87", [HORIZEN_MAINNET]: "0x57da2D504bf8b83Ef304759d9f2648522D7a9280", [BASE_MAINNET]: "0xf43eB8De897Fbc7F2502483B2Bef7Bb9EA179229", [BASE_SEPOLIA]: "0x107fdE93838e3404934877935993782F977324BB",