Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/wallet/memo.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
30 changes: 30 additions & 0 deletions src/wallet/memo.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
33 changes: 32 additions & 1 deletion src/wallet/vault.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/)
})
})
})
49 changes: 39 additions & 10 deletions src/wallet/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -135,14 +136,22 @@ async function waitForTransaction(hash: string): Promise<void> {
* @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(
amount: number,
address: string,
sign: (xdr: string) => Promise<string>,
signal?: AbortSignal,
memo?: string,
): Promise<string> {
if (memo) {
const { valid, error } = validateStellarMemo(memo)
if (!valid) throw new Error(error)
}

if (!CONTRACT_ID) {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
Expand All @@ -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 })
Expand All @@ -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}`)
Expand All @@ -203,14 +218,22 @@ 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(
amount: number,
address: string,
sign: (xdr: string) => Promise<string>,
signal?: AbortSignal,
memo?: string,
): Promise<string> {
if (memo) {
const { valid, error } = validateStellarMemo(memo)
if (!valid) throw new Error(error)
}

if (!CONTRACT_ID) {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
Expand All @@ -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 })
Expand All @@ -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}`)
Expand Down
Loading