diff --git a/src/test/helpers/index.ts b/src/test/helpers/index.ts new file mode 100644 index 00000000..337fb001 --- /dev/null +++ b/src/test/helpers/index.ts @@ -0,0 +1,167 @@ +import { + http, + type Address, + type Client, + type WalletClient, + createWalletClient, + publicActions, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { getRpcUrl } from "../../lib/helpers/test/setupTestEnv"; +import setupTestWallet from "../../lib/helpers/test/setupTestWallet"; +import { type SuperWalletClient, VALID_CHAINS } from "../../lib/helpers/types"; +import { generateKeysFromSignature } from "../../utils/helpers"; + +// Default private key for testing; the setupTestWallet function uses the first anvil default key, so the below will be different +const ANVIL_DEFAULT_PRIVATE_KEY_2 = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + +/* Gets the signature to be able to generate reproducible public/private viewing/spending keys */ +export const getSignature = async ({ + walletClient, +}: { + walletClient: WalletClient; +}) => { + if (!walletClient.chain) throw new Error("Chain not found"); + if (!walletClient.account) throw new Error("Account not found"); + + const MESSAGE = `Signing message for stealth transaction on chain id: ${walletClient.chain.id}`; + const signature = await walletClient.signMessage({ + message: MESSAGE, + account: walletClient.account, + }); + + return signature; +}; + +/* Generates the public/private viewing/spending keys from the signature */ +export const getKeys = async ({ + walletClient, +}: { + walletClient: WalletClient; +}) => { + const signature = await getSignature({ walletClient }); + const keys = generateKeysFromSignature(signature); + return keys; +}; + +/* Sets up the sending and receiving wallet clients for testing */ +export const getWalletClients = async () => { + const sendingWalletClient = await setupTestWallet(); + + const chain = sendingWalletClient.chain; + if (!chain) throw new Error("Chain not found"); + if (!(chain.id in VALID_CHAINS)) { + throw new Error("Invalid chain"); + } + + const rpcUrl = getRpcUrl(); + + const receivingWalletClient: SuperWalletClient = createWalletClient({ + account: privateKeyToAccount(ANVIL_DEFAULT_PRIVATE_KEY_2), + chain, + transport: http(rpcUrl), + }).extend(publicActions); + + return { sendingWalletClient, receivingWalletClient }; +}; + +export const getAccount = (walletClient: WalletClient | Client) => { + if (!walletClient.account) throw new Error("Account not found"); + return walletClient.account; +}; + +/* Gets the wallet clients, accounts, and keys for the sending and receiving wallets */ +export const getWalletClientsAndKeys = async () => { + const { sendingWalletClient, receivingWalletClient } = + await getWalletClients(); + + const sendingAccount = getAccount(sendingWalletClient); + const receivingAccount = getAccount(receivingWalletClient); + + const receivingAccountKeys = await getKeys({ + walletClient: receivingWalletClient, + }); + + return { + sendingWalletClient, + receivingWalletClient, + sendingAccount, + receivingAccount, + receivingAccountKeys, + }; +}; + +/* Set up the initial balance details for the sending and receiving wallets */ +export const setupInitialBalances = async ({ + sendingWalletClient, + receivingWalletClient, +}: { + sendingWalletClient: SuperWalletClient; + receivingWalletClient: SuperWalletClient; +}) => { + const sendingAccount = getAccount(sendingWalletClient); + const receivingAccount = getAccount(receivingWalletClient); + const sendingWalletStartingBalance = await sendingWalletClient.getBalance({ + address: sendingAccount.address, + }); + const receivingWalletStartingBalance = await receivingWalletClient.getBalance( + { + address: receivingAccount.address, + } + ); + + return { + sendingWalletStartingBalance, + receivingWalletStartingBalance, + }; +}; + +/* Send ETH and wait for the transaction to be confirmed */ +export const sendEth = async ({ + sendingWalletClient, + to, + value, +}: { + sendingWalletClient: SuperWalletClient; + to: Address; + value: bigint; +}) => { + const account = getAccount(sendingWalletClient); + const hash = await sendingWalletClient.sendTransaction({ + value, + to, + account, + chain: sendingWalletClient.chain, + }); + + const receipt = await sendingWalletClient.waitForTransactionReceipt({ hash }); + + const gasPriceSend = receipt.effectiveGasPrice; + const gasEstimate = receipt.gasUsed * gasPriceSend; + + return { hash, gasEstimate }; +}; + +/* Get the ending balances for the sending and receiving wallets */ +export const getEndingBalances = async ({ + sendingWalletClient, + receivingWalletClient, +}: { + sendingWalletClient: SuperWalletClient; + receivingWalletClient: SuperWalletClient; +}) => { + const sendingAccount = getAccount(sendingWalletClient); + const receivingAccount = getAccount(receivingWalletClient); + const sendingWalletEndingBalance = await sendingWalletClient.getBalance({ + address: sendingAccount.address, + }); + const receivingWalletEndingBalance = await receivingWalletClient.getBalance({ + address: receivingAccount.address, + }); + + return { + sendingWalletEndingBalance, + receivingWalletEndingBalance, + }; +}; diff --git a/src/test/sendReceive.test.ts b/src/test/sendReceive.test.ts new file mode 100644 index 00000000..5b00bbe0 --- /dev/null +++ b/src/test/sendReceive.test.ts @@ -0,0 +1,106 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { http, createWalletClient, parseEther, publicActions } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { getRpcUrl } from '../lib/helpers/test/setupTestEnv'; +import { + VALID_SCHEME_ID, + computeStealthKey, + generateStealthAddress +} from '../utils'; +import { generateStealthMetaAddressFromSignature } from '../utils/helpers'; +import { + getEndingBalances, + getSignature, + getWalletClientsAndKeys, + sendEth, + setupInitialBalances +} from './helpers'; + +/** + * @description Tests for sending and receiving a payment + * Sending means generating a stealth address using the sdk, then sending funds to that stealth address; the sending account is the account that sends the funds + * Withdrawing means computing the stealth address private key using the sdk, then withdrawing funds from the stealth address; the receiving account is the account that receives the funds + * + * The tests need to be run using foundry because the tests utilize the default anvil private keys + */ + +describe('Send and receive payment', () => { + const sendAmount = parseEther('1.0'); + const withdrawBuffer = parseEther('0.01'); + const withdrawAmount = sendAmount - withdrawBuffer; + const schemeId = VALID_SCHEME_ID.SCHEME_ID_1; + + let gasEstimateSend: bigint; + let sendingWalletBalanceChange: bigint; + let receivingWalletBalanceChange: bigint; + + beforeAll(async () => { + const { + receivingAccount, + receivingAccountKeys, + receivingWalletClient, + sendingWalletClient + } = await getWalletClientsAndKeys(); + + const { stealthAddress, ephemeralPublicKey } = generateStealthAddress({ + stealthMetaAddressURI: generateStealthMetaAddressFromSignature( + await getSignature({ walletClient: receivingWalletClient }) + ), + schemeId + }); + + const { sendingWalletStartingBalance, receivingWalletStartingBalance } = + await setupInitialBalances({ + receivingWalletClient, + sendingWalletClient + }); + + // Send ETH to the stealth address + const { gasEstimate } = await sendEth({ + sendingWalletClient, + to: stealthAddress, + value: sendAmount + }); + + gasEstimateSend = gasEstimate; + + // Compute the stealth key to be able to withdraw the funds from the stealth address to the receiving account + const stealthAddressPrivateKey = computeStealthKey({ + schemeId, + ephemeralPublicKey, + spendingPrivateKey: receivingAccountKeys.spendingPrivateKey, + viewingPrivateKey: receivingAccountKeys.viewingPrivateKey + }); + + // Set up a wallet client using the stealth address private key + const stealthAddressWalletClient = createWalletClient({ + account: privateKeyToAccount(stealthAddressPrivateKey), + chain: sendingWalletClient.chain, + transport: http(getRpcUrl()) + }).extend(publicActions); + + // Withdraw from the stealth address to the receiving account + await sendEth({ + sendingWalletClient: stealthAddressWalletClient, + to: receivingAccount.address, + value: withdrawAmount + }); + + const { sendingWalletEndingBalance, receivingWalletEndingBalance } = + await getEndingBalances({ + sendingWalletClient, + receivingWalletClient + }); + + // Get the balance changes for the sending and receiving wallets + sendingWalletBalanceChange = + sendingWalletEndingBalance - sendingWalletStartingBalance; + receivingWalletBalanceChange = + receivingWalletEndingBalance - receivingWalletStartingBalance; + }); + + test('Can successfully send a stealth transaction from an account and withdraw from a different account by computing the stealth key', () => { + expect(sendingWalletBalanceChange).toBe(-(sendAmount + gasEstimateSend)); + expect(receivingWalletBalanceChange).toBe(withdrawAmount); + }); +}); diff --git a/src/utils/crypto/generateStealthAddress.ts b/src/utils/crypto/generateStealthAddress.ts index ad93c485..75792f8f 100644 --- a/src/utils/crypto/generateStealthAddress.ts +++ b/src/utils/crypto/generateStealthAddress.ts @@ -100,7 +100,7 @@ function generateStealthAddress({ * Validates the structure and format of the stealth meta-address. * * @param {object} params - Parameters for parsing the stealth meta-address URI: - * - stealthMetaAddressURI: The URI containing the stealth meta-address. + * - stealthMetaAddressURI: The URI containing the stealth meta-address, or alternatively, the stealth meta-address itself. * - schemeId: The scheme identifier. * @returns {HexString} The extracted stealth meta-address. */ @@ -108,11 +108,15 @@ function parseStealthMetaAddressURI({ stealthMetaAddressURI, schemeId }: { - stealthMetaAddressURI: string; + stealthMetaAddressURI: string | HexString; schemeId: VALID_SCHEME_ID; }): HexString { handleSchemeId(schemeId); + // If the stealth meta-address is provided directly + if (stealthMetaAddressURI.startsWith('0x')) + return stealthMetaAddressURI as HexString; + const parts = stealthMetaAddressURI.split(':'); if (parts.length !== 3 || parts[0] !== 'st') { diff --git a/src/utils/crypto/test/generateStealthAddress.test.ts b/src/utils/crypto/test/generateStealthAddress.test.ts index e6ba8190..c793853e 100644 --- a/src/utils/crypto/test/generateStealthAddress.test.ts +++ b/src/utils/crypto/test/generateStealthAddress.test.ts @@ -15,61 +15,24 @@ describe('generateStealthAddress', () => { const schemeId = VALID_SCHEME_ID.SCHEME_ID_1; - test('should throw an error when given a valid uri format, but an invalid stealth meta-address', () => { - const invalid = 'st:eth:invalid'; - - expect(() => - generateStealthAddress({ - stealthMetaAddressURI: invalid, - schemeId - }) - ).toThrow(new Error('Invalid stealth meta-address')); - }); - - test('should throw an error when given an invalid uri format', () => { - const invalid = 'invalid'; + test('parseStealthMetaAddressURI should return the stealth meta-address', () => { + const expectedStealthMetaAddress = + '0x033404e82cd2a92321d51e13064ec13a0fb0192a9fdaaca1cfb47b37bd27ec13970390ad5eca026c05ab5cf4d620a2ac65241b11df004ddca360e954db1b26e3846e'; + // Passing the valid stealth meta-address URI and the scheme ID + const result = parseStealthMetaAddressURI({ + stealthMetaAddressURI: validStealthMetaAddressURI, + schemeId + }); - expect(() => - generateStealthAddress({ - stealthMetaAddressURI: invalid, - schemeId - }) - ).toThrow(new Error('Invalid stealth meta-address URI format')); - }); + expect(result).toBe(expectedStealthMetaAddress); - test('should throw an error when given an invalid length stealth meta-address', () => { - const stealthMetaAddress = parseStealthMetaAddressURI({ - stealthMetaAddressURI: validStealthMetaAddressURI, + // Passing only the stealth meta-address + const result2 = parseStealthMetaAddressURI({ + stealthMetaAddressURI: expectedStealthMetaAddress, schemeId }); - // Intentionally alter the stealth meta-address to have an invalid length - const invalid = `st:eth:${stealthMetaAddress.slice(7, -1)}0`; - - expect(() => - generateStealthAddress({ - stealthMetaAddressURI: invalid, - schemeId - }) - ).toThrow(new Error('Invalid stealth meta-address')); - }); - test('should throw an error with stealth meta-address leading to invalid public keys', async () => { - // stealthMetaAddressURI with invalid public key lengths or prefixes - const invalidURIs = [ - `st:eth:02${'1'.repeat(63)}`, // Invalid length - `st:eth:04${ - '1'.repeat(64) // Invalid prefix - }` - ]; - - for (const uri of invalidURIs) { - expect(() => - generateStealthAddress({ - stealthMetaAddressURI: uri, - schemeId: VALID_SCHEME_ID.SCHEME_ID_1 - }) - ).toThrow(new Error('Invalid stealth meta-address')); - } + expect(result2).toBe(expectedStealthMetaAddress); }); test('should generate a valid stealth address given a valid stealth meta-address URI', () => {