diff --git a/src/wallet/memo.test.ts b/src/wallet/memo.test.ts new file mode 100644 index 0000000..0297fa0 --- /dev/null +++ b/src/wallet/memo.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { validateStellarMemo, MAX_STELLAR_MEMO_LENGTH } from './memo' + +describe('Stellar memo validation', () => { + it('defines maximum memo length as 28 bytes', () => { + expect(MAX_STELLAR_MEMO_LENGTH).toBe(28) + }) + + it('passes when memo is undefined or empty', () => { + expect(validateStellarMemo(undefined)).toEqual({ valid: true }) + expect(validateStellarMemo('')).toEqual({ valid: true }) + }) + + it('passes when memo is within 28 bytes', () => { + const validMemo = 'Green bond deposit' + expect(validateStellarMemo(validMemo)).toEqual({ valid: true }) + }) + + it('passes when memo is exactly 28 bytes', () => { + const exact28CharMemo = '1234567890123456789012345678' + expect(exact28CharMemo.length).toBe(28) + expect(validateStellarMemo(exact28CharMemo)).toEqual({ valid: true }) + }) + + it('fails when memo is 29 bytes', () => { + const invalid29CharMemo = '12345678901234567890123456789' + expect(invalid29CharMemo.length).toBe(29) + const result = validateStellarMemo(invalid29CharMemo) + expect(result.valid).toBe(false) + expect(result.error).toContain('Memo text cannot exceed 28 bytes') + }) + + it('fails when memo is 100 characters (Issue #284 reproduction)', () => { + const hundredCharMemo = 'a'.repeat(100) + expect(hundredCharMemo.length).toBe(100) + const result = validateStellarMemo(hundredCharMemo) + expect(result.valid).toBe(false) + expect(result.error).toContain('Memo text cannot exceed 28 bytes') + }) + + it('correctly measures multi-byte UTF-8 character length', () => { + const multiByteMemo = '🌞'.repeat(10) + const result = validateStellarMemo(multiByteMemo) + expect(result.valid).toBe(false) + expect(result.error).toContain('Memo text cannot exceed 28 bytes') + expect(result.error).toContain('40 bytes provided') + }) +}) diff --git a/src/wallet/memo.ts b/src/wallet/memo.ts new file mode 100644 index 0000000..f12a20b --- /dev/null +++ b/src/wallet/memo.ts @@ -0,0 +1,30 @@ +/** + * Maximum byte length allowed for Stellar MEMO_TEXT field. + * Per Stellar protocol specification, text memos are limited to 28 bytes. + */ +export const MAX_STELLAR_MEMO_LENGTH = 28 + +export interface MemoValidationResult { + valid: boolean + error?: string +} + +/** + * Validate Stellar memo text length prior to building or submitting transactions. + * + * @param memo Optional memo string + * @returns Validation result with descriptive error if byte length > 28 + */ +export function validateStellarMemo(memo?: string): MemoValidationResult { + if (!memo) return { valid: true } + + const byteLength = new TextEncoder().encode(memo).length + if (byteLength > MAX_STELLAR_MEMO_LENGTH) { + return { + valid: false, + error: `Memo text cannot exceed ${MAX_STELLAR_MEMO_LENGTH} bytes (${byteLength} bytes provided).`, + } + } + + return { valid: true } +} diff --git a/src/wallet/vault.test.ts b/src/wallet/vault.test.ts index 1bb2653..53e99aa 100644 --- a/src/wallet/vault.test.ts +++ b/src/wallet/vault.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { vault, SHARE_PRICE } from './vault' +import { vault, SHARE_PRICE, submitDeposit, submitWithdraw } from './vault' describe('Vault math functions', () => { describe('convertToShares', () => { @@ -181,4 +181,35 @@ describe('Vault math functions', () => { expect(backToUsdc).toBeCloseTo(usdc) }) }) + + describe('submitDeposit and submitWithdraw memo validation', () => { + const dummyAddress = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFXYSFTXF4VGWVJ5SZ3BG' + const dummySign = async (xdr: string) => xdr + + it('rejects deposit with memo exceeding 28 bytes', async () => { + const invalidMemo = 'a'.repeat(100) + await expect( + submitDeposit(100, dummyAddress, dummySign, undefined, invalidMemo), + ).rejects.toThrow('Memo text cannot exceed 28 bytes') + }) + + it('rejects withdraw with memo exceeding 28 bytes', async () => { + const invalidMemo = 'a'.repeat(100) + await expect( + submitWithdraw(100, dummyAddress, dummySign, undefined, invalidMemo), + ).rejects.toThrow('Memo text cannot exceed 28 bytes') + }) + + it('allows deposit with valid memo <= 28 characters in demo mode', async () => { + const validMemo = 'Green bond deposit' + const hash = await submitDeposit(100, dummyAddress, dummySign, undefined, validMemo) + expect(hash).toMatch(/^demo/) + }) + + it('allows withdraw with valid memo <= 28 characters in demo mode', async () => { + const validMemo = 'Withdraw shares' + const hash = await submitWithdraw(100, dummyAddress, dummySign, undefined, validMemo) + expect(hash).toMatch(/^demo/) + }) + }) }) diff --git a/src/wallet/vault.ts b/src/wallet/vault.ts index bd5d1d7..2af7967 100644 --- a/src/wallet/vault.ts +++ b/src/wallet/vault.ts @@ -12,6 +12,7 @@ // back gracefully — no errors surface to the user. import { HB_DATA } from '../data' +import { validateStellarMemo } from './memo' export interface WithdrawPreview { assets: number @@ -135,6 +136,8 @@ async function waitForTransaction(hash: string): Promise { * @param amount USDC amount (integer stroops internally) * @param address Stellar address of the depositor (source account) * @param sign Signing function from WalletProvider + * @param signal Optional AbortSignal + * @param memo Optional Stellar memo text (max 28 bytes) * @returns Transaction hash (real or placeholder) */ export async function submitDeposit( @@ -142,7 +145,13 @@ export async function submitDeposit( address: string, sign: (xdr: string) => Promise, signal?: AbortSignal, + memo?: string, ): Promise { + if (memo) { + const { valid, error } = validateStellarMemo(memo) + if (!valid) throw new Error(error) + } + if (!CONTRACT_ID) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -163,7 +172,7 @@ export async function submitDeposit( }) } - const { rpc, Contract, TransactionBuilder, Networks, Horizon, nativeToScVal, Transaction } = + const { rpc, Contract, TransactionBuilder, Networks, Horizon, nativeToScVal, Transaction, Memo } = await import('@stellar/stellar-sdk') const server = new rpc.Server(RPC_URL, { allowHttp: false }) @@ -176,10 +185,16 @@ export async function submitDeposit( const amountScVal = nativeToScVal(BigInt(Math.round(amount * 1e7)), { type: 'i128' }) const minSharesScVal = nativeToScVal(BigInt(0), { type: 'i128' }) - const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET }) - .addOperation(contract.call('deposit', amountScVal, minSharesScVal)) - .setTimeout(180) - .build() + const builder = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }).addOperation(contract.call('deposit', amountScVal, minSharesScVal)) + + if (memo) { + builder.addMemo(Memo.text(memo)) + } + + const tx = builder.setTimeout(180).build() const simResult = await server.simulateTransaction(tx) if ('error' in simResult) throw new Error(`Simulation failed: ${simResult.error}`) @@ -203,6 +218,8 @@ export async function submitDeposit( * @param amount USDC amount to withdraw * @param address Stellar address of the withdrawer * @param sign Signing function from WalletProvider + * @param signal Optional AbortSignal + * @param memo Optional Stellar memo text (max 28 bytes) * @returns Transaction hash (real or placeholder) */ export async function submitWithdraw( @@ -210,7 +227,13 @@ export async function submitWithdraw( address: string, sign: (xdr: string) => Promise, signal?: AbortSignal, + memo?: string, ): Promise { + if (memo) { + const { valid, error } = validateStellarMemo(memo) + if (!valid) throw new Error(error) + } + if (!CONTRACT_ID) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -231,7 +254,7 @@ export async function submitWithdraw( }) } - const { rpc, Contract, TransactionBuilder, Networks, Horizon, nativeToScVal, Transaction } = + const { rpc, Contract, TransactionBuilder, Networks, Horizon, nativeToScVal, Transaction, Memo } = await import('@stellar/stellar-sdk') const server = new rpc.Server(RPC_URL, { allowHttp: false }) @@ -242,10 +265,16 @@ export async function submitWithdraw( const sharesScVal = nativeToScVal(BigInt(Math.round(amount * 1e7)), { type: 'i128' }) const minAssetsScVal = nativeToScVal(BigInt(0), { type: 'i128' }) - const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET }) - .addOperation(contract.call('withdraw', sharesScVal, minAssetsScVal)) - .setTimeout(180) - .build() + const builder = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }).addOperation(contract.call('withdraw', sharesScVal, minAssetsScVal)) + + if (memo) { + builder.addMemo(Memo.text(memo)) + } + + const tx = builder.setTimeout(180).build() const simResult = await server.simulateTransaction(tx) if ('error' in simResult) throw new Error(`Simulation failed: ${simResult.error}`)