From 65de044fd0794fd71269c27135bbb2b4daff461c Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 14:58:14 +0200 Subject: [PATCH 1/9] feat(stellar-wallet-snap): attach optional memo on confirmSend build path --- .../use-cases/client-request/confirmSend.md | 4 +- packages/stellar-wallet-snap/src/api/index.ts | 1 + .../stellar-wallet-snap/src/api/string.ts | 57 +++++++- packages/stellar-wallet-snap/src/constants.ts | 7 + .../src/handlers/clientRequest/api.ts | 8 ++ .../src/handlers/clientRequest/confirmSend.ts | 8 ++ .../transactionRefresher.test.ts | 2 + .../transactionRefresher.ts | 2 + .../transaction/TransactionBuilder.test.ts | 63 +++++++++ .../transaction/TransactionBuilder.ts | 86 +++++++++++- .../transaction/TransactionService.ts | 27 ++++ .../src/services/transaction/index.ts | 1 + .../src/services/transaction/memo.test.ts | 82 ++++++++++++ .../src/services/transaction/memo.ts | 123 ++++++++++++++++++ 14 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 packages/stellar-wallet-snap/src/services/transaction/memo.test.ts create mode 100644 packages/stellar-wallet-snap/src/services/transaction/memo.ts diff --git a/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md index 38600d825..5fe842f8e 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md +++ b/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md @@ -17,6 +17,8 @@ Confirms and submits a send for Unified Non-EVM Send (live on-chain data at buil - `toAddress` — Stellar destination - `assetId` — CAIP-19 classic / SEP-41 / slip44 (`scope` derived from `assetId`) - `amount` — human-readable amount string +- `memo` — optional memo value (text ≤ 28 UTF-8 bytes, numeric id, or 64-char hex for hash/return) +- `memoType` — optional `text` | `id` | `hash` | `return` (SEP-2 / client hint). When omitted, all-digit uint64 values are treated as memo **id**; otherwise **text**. Federation/muxed destination resolution is a follow-up. **Response** @@ -71,7 +73,7 @@ sequenceDiagram participant Wallet participant Track as TrackTransactionHandler - Client->>Handler: confirmSend { fromAccountId, toAddress, assetId, amount } + Client->>Handler: confirmSend { fromAccountId, toAddress, assetId, amount, memo? } Handler->>Resolver: resolve activated account (live on-chain) Resolver-->>Handler: account, wallet, onChainAccount Handler->>Meta: resolve(assetId) diff --git a/packages/stellar-wallet-snap/src/api/index.ts b/packages/stellar-wallet-snap/src/api/index.ts index 9411b9980..b3c326ddf 100644 --- a/packages/stellar-wallet-snap/src/api/index.ts +++ b/packages/stellar-wallet-snap/src/api/index.ts @@ -6,3 +6,4 @@ export * from './json'; export * from './integer'; export * from './xdr'; export * from './transactionHash'; +export * from './string'; diff --git a/packages/stellar-wallet-snap/src/api/string.ts b/packages/stellar-wallet-snap/src/api/string.ts index 868ed4f25..7f8831a94 100644 --- a/packages/stellar-wallet-snap/src/api/string.ts +++ b/packages/stellar-wallet-snap/src/api/string.ts @@ -1,5 +1,7 @@ import type { Infer } from '@metamask/superstruct'; -import { refine, string } from '@metamask/superstruct'; +import { enums, refine, string } from '@metamask/superstruct'; + +import { STELLAR_TEXT_MEMO_MAX_BYTES } from '../constants'; /** * Validation struct for a UTF-8 string. @@ -16,3 +18,56 @@ export const Utf8StringStruct = refine(string(), 'utf8', (value) => { }); export type Utf8String = Infer; + +/** + * Optional explicit Stellar memo type (SEP-2 `memo_type` when known). + * When omitted, the builder infers `id` for all-digit uint64 values, else `text`. + */ +export const StellarMemoTypeStruct = enums(['text', 'id', 'hash', 'return']); + +export type StellarMemoTypeParam = Infer; + +/** + * Validation struct for an optional Stellar text memo (≤ 28 UTF-8 bytes). + * Empty / whitespace-only values are allowed at the wire layer and treated as + * absent when building the transaction. + */ +export const StellarTextMemoStruct = refine( + string(), + 'stellar-text-memo', + (value) => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return true; + } + if ( + new TextEncoder().encode(trimmed).length > STELLAR_TEXT_MEMO_MAX_BYTES + ) { + return `Memo must be ${STELLAR_TEXT_MEMO_MAX_BYTES} bytes or fewer`; + } + return true; + }, +); + +export type StellarTextMemo = Infer; + +/** + * Wire-layer memo value for confirmSend: allows text (≤ 28 bytes), memo id + * digits, or 64-char hex for hash/return. Strict type checks run at build time. + */ +export const StellarMemoValueStruct = refine( + string(), + 'stellar-memo-value', + (value) => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return true; + } + if (trimmed.length > 64) { + return 'Memo is too long'; + } + return true; + }, +); + +export type StellarMemoValue = Infer; diff --git a/packages/stellar-wallet-snap/src/constants.ts b/packages/stellar-wallet-snap/src/constants.ts index d655873d5..cb0f979ac 100644 --- a/packages/stellar-wallet-snap/src/constants.ts +++ b/packages/stellar-wallet-snap/src/constants.ts @@ -125,6 +125,13 @@ export const MEMO_REQUIRED_KEY = 'config.memo_required'; */ export const ACCOUNT_REQUIRES_MEMO = 'MQ=='; +/** + * Stellar text memos are limited to 28 bytes on-chain. + * + * @see https://developers.stellar.org/docs/learn/fundamentals/transactions/operations-and-transactions#memo + */ +export const STELLAR_TEXT_MEMO_MAX_BYTES = 28; + /** * Maximum native XLM threshold for an incoming * payment to be treated as dust spam. diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index 637334541..2147d44c8 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -35,6 +35,8 @@ import { ValidAmountStruct, ValidStellarAmountStruct, SwapTransactionXdrStruct, + StellarMemoTypeStruct, + StellarMemoValueStruct, } from '../../api'; import { isSep41Id } from '../../utils'; import { parseProofOfOwnershipMessage } from './utils'; @@ -294,6 +296,12 @@ const ConfirmSendParamsStruct = object({ KnownCaip19Slip44IdStruct, ]), amount: nonempty(string()), + memo: optional(StellarMemoValueStruct), + /** + * Optional SEP-2 / client memo type hint. When omitted, numeric values are + * treated as memo id; otherwise text (see resolveStellarMemo). + */ + memoType: optional(StellarMemoTypeStruct), }); /** diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 4d9d0dbfb..37e4797b7 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -117,6 +117,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< try { const { onChainAccount, account: stellarKeyringAccount } = resolved; const { amount, toAddress, assetId, scope } = request.params; + const memo = request.params.memo?.trim() ?? undefined; + const { memoType } = request.params; const assetMetadata = await this.#assetMetadataService.resolve(assetId); const { decimals, symbol } = assetMetadata.units[0]; @@ -141,6 +143,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< assetId, amount: amountInSmallestUnit, destination: toAddress, + memo, + memoType, }); } catch (error: unknown) { if (error instanceof TransactionValidationException) { @@ -286,6 +290,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< }> { const { request, confirmedTransaction, amount } = params; const { assetId, toAddress, scope } = request.params; + const memo = request.params.memo?.trim() ?? undefined; + const { memoType } = request.params; // Resolve again after the user confirms so sequence, balances, and fees are fresh before signing. // sendTransaction still handles txBadSeq races that happen after this refresh. const { wallet, onChainAccount } = await this.resolveAccount(request); @@ -297,6 +303,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< assetId, amount, destination: toAddress, + memo, + memoType, }); // Reject if the refreshed fee is higher than what the user approved, so we diff --git a/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts b/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts index 831dec05a..0b0dc5cbf 100644 --- a/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts @@ -165,6 +165,8 @@ describe('ConfirmationTransactionRefresher', () => { assetId: sendRequest.params.assetId, destination: toAddress, amount: expect.anything(), + memo: undefined, + memoType: undefined, }); expect(result).toStrictEqual({ result: { diff --git a/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts b/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts index dc6013726..403719995 100644 --- a/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts +++ b/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts @@ -139,6 +139,8 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef assetId: request.params.assetId, destination: request.params.toAddress, amount, + memo: request.params.memo?.trim() ?? undefined, + memoType: request.params.memoType, }); break; } diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts index 9b08c083d..cab455ad5 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts @@ -212,6 +212,69 @@ describe('TransactionBuilder', () => { expect(transaction.network).toStrictEqual(Networks.PUBLIC); expect(transaction.getRaw()).toBeInstanceOf(StellarTransaction); expect(transaction.hasCreateAccount).toBe(false); + expect(transaction.getMemo()).toBeNull(); + }); + + it('attaches a text memo when provided', () => { + const testDestination = getTestWallet(); + const transaction = transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + amount: new BigNumber(100), + destination: { + address: testDestination.address, + isActivated: true, + }, + baseFee: new BigNumber(100), + memo: 'deposit-ref', + }); + + expect(transaction.getMemo()).toBe('deposit-ref'); + expect((transaction.getRaw() as StellarTransaction).memo.type).toBe( + 'text', + ); + }); + + it('infers memo id for numeric exchange-style memos', () => { + const testDestination = getTestWallet(); + const transaction = transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + amount: new BigNumber(100), + destination: { + address: testDestination.address, + isActivated: true, + }, + baseFee: new BigNumber(100), + memo: '123456789', + }); + + expect(transaction.getMemo()).toBe('123456789'); + expect((transaction.getRaw() as StellarTransaction).memo.type).toBe('id'); + }); + + it('honors an explicit memoType over inference', () => { + const testDestination = getTestWallet(); + const transaction = transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + amount: new BigNumber(100), + destination: { + address: testDestination.address, + isActivated: true, + }, + baseFee: new BigNumber(100), + memo: '123456789', + memoType: 'text', + }); + + expect(transaction.getMemo()).toBe('123456789'); + expect((transaction.getRaw() as StellarTransaction).memo.type).toBe( + 'text', + ); }); it('builds a create account transaction', () => { diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts index 2c25b51e2..240168257 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts @@ -32,6 +32,8 @@ import { InvalidAssetForCreateAccountException, TransactionBuilderException, } from './exceptions'; +import type { StellarMemoType } from './memo'; +import { resolveStellarMemo } from './memo'; import { Transaction } from './Transaction'; import { assertAssetScopeMatch, caip19ToStellarAsset } from './utils'; @@ -106,6 +108,8 @@ export class TransactionBuilder { * @param params.destination - Recipient Stellar account id (`G…`). * @param params.amount - Amount in the token's smallest units (i128). * @param params.baseFee - Per-operation inclusion fee in stroops. + * @param params.memo - Optional Stellar memo value to attach to the envelope. + * @param params.memoType - Optional explicit memo type (federation / client hint). * @returns Wrapped unsigned transaction with one `invokeHostFunction` op. */ sep41Transfer(params: { @@ -115,9 +119,19 @@ export class TransactionBuilder { destination: string; amount: BigNumber; baseFee: BigNumber; + memo?: string; + memoType?: StellarMemoType; }): Transaction { - const { scope, onChainAccount, assetId, destination, amount, baseFee } = - params; + const { + scope, + onChainAccount, + assetId, + destination, + amount, + baseFee, + memo, + memoType, + } = params; assertAssetScopeMatch(assetId, scope); @@ -144,6 +158,8 @@ export class TransactionBuilder { timeout: this.#getTimeout(), scope, fee: baseFee.toString(), + memo, + memoType, }); } catch (error: unknown) { throw new TransactionBuilderException( @@ -160,9 +176,19 @@ export class TransactionBuilder { onChainAccount: OnChainAccount; destination: string; amount: BigNumber; + memo?: string; + memoType?: StellarMemoType; }): Transaction { - const { amount, baseFee, scope, asset, onChainAccount, destination } = - params; + const { + amount, + baseFee, + scope, + asset, + onChainAccount, + destination, + memo, + memoType, + } = params; return this.#buildTransaction({ onChainAccount, operations: [ @@ -175,6 +201,8 @@ export class TransactionBuilder { timeout: this.#getTimeout(), scope, fee: baseFee, + memo, + memoType, }); } @@ -184,8 +212,18 @@ export class TransactionBuilder { onChainAccount: OnChainAccount; destination: string; amount: BigNumber; + memo?: string; + memoType?: StellarMemoType; }): Transaction { - const { amount, baseFee, scope, onChainAccount, destination } = params; + const { + amount, + baseFee, + scope, + onChainAccount, + destination, + memo, + memoType, + } = params; return this.#buildTransaction({ onChainAccount, @@ -198,6 +236,8 @@ export class TransactionBuilder { timeout: this.#getTimeout(), scope, fee: baseFee, + memo, + memoType, }); } @@ -215,6 +255,8 @@ export class TransactionBuilder { * @param params.destination.address - Recipient Stellar account id (`G…`). * @param params.destination.isActivated - Whether the destination account exists and is funded on-chain. * @param params.baseFee - Per-operation inclusion fee in stroops. + * @param params.memo - Optional Stellar memo value to attach to the envelope. + * @param params.memoType - Optional explicit memo type (federation / client hint). * @returns An unsigned transaction ready for signing. * @throws {InvalidAssetForCreateAccountException} When the destination is unfunded and the asset is not native. * @throws {TransactionBuilderException} If building fails. @@ -229,9 +271,19 @@ export class TransactionBuilder { isActivated: boolean; }; baseFee: BigNumber; + memo?: string; + memoType?: StellarMemoType; }): Transaction { - const { onChainAccount, scope, amount, assetId, destination, baseFee } = - params; + const { + onChainAccount, + scope, + amount, + assetId, + destination, + baseFee, + memo, + memoType, + } = params; const { address: toAddress, isActivated } = destination; assertAssetScopeMatch(assetId, scope); @@ -245,6 +297,8 @@ export class TransactionBuilder { destination: toAddress, amount, baseFee, + memo, + memoType, }); } @@ -260,6 +314,8 @@ export class TransactionBuilder { asset: assetId, destination: toAddress, amount: normalizedAmount, + memo, + memoType, }); } // Unfunded destination → createAccount only. @@ -273,6 +329,8 @@ export class TransactionBuilder { scope, amount: normalizedAmount, destination: toAddress, + memo, + memoType, }); } catch (error: unknown) { if (error instanceof InvalidAssetForCreateAccountException) { @@ -375,12 +433,16 @@ export class TransactionBuilder { timeout, scope, fee, + memo, + memoType, }: { onChainAccount: OnChainAccount; operations: xdr.Operation[]; timeout: number; scope: KnownCaip2ChainId; fee: string; + memo?: string; + memoType?: StellarMemoType; }): Transaction { const accountInstance = new Account( onChainAccount.accountId, @@ -388,9 +450,19 @@ export class TransactionBuilder { ); const networkPassphrase = caip2ChainIdToNetwork(scope); + let resolvedMemo; + try { + resolvedMemo = resolveStellarMemo({ value: memo, type: memoType }); + } catch (error: unknown) { + throw new TransactionBuilderException( + error instanceof Error ? error.message : 'Invalid memo', + { cause: error }, + ); + } const builder = new StellarSdkTransactionBuilder(accountInstance, { fee, networkPassphrase, + ...(resolvedMemo ? { memo: resolvedMemo } : {}), }); for (const operation of operations) { diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index bb9383ee1..d2d258467 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -25,6 +25,7 @@ import { } from './exceptions'; import type { KeyringTransactionRequest } from './KeyringTransactionBuilder'; import { KeyringTransactionBuilder } from './KeyringTransactionBuilder'; +import type { StellarMemoType } from './memo'; import { Transaction } from './Transaction'; import type { TransactionBuilder } from './TransactionBuilder'; import { TransactionMapper } from './TransactionMapper'; @@ -148,6 +149,8 @@ export class TransactionService { * @param params.scope - The CAIP-2 chain ID. * @param params.assetId - The CAIP-19 asset ID. * @param params.destination - The destination address. + * @param params.memo - Optional Stellar memo value to attach to the envelope. + * @param params.memoType - Optional explicit memo type (federation / client hint). * @param params.useCache - Whether to use the cache. * @returns A promise that resolves to the validated transaction. */ @@ -157,6 +160,8 @@ export class TransactionService { scope: KnownCaip2ChainId; assetId: KnownCaip19AssetIdOrSlip44Id; destination: string; + memo?: string; + memoType?: StellarMemoType; useCache?: boolean; }): Promise { const { @@ -165,6 +170,8 @@ export class TransactionService { assetId, amount, destination, + memo, + memoType, useCache = false, } = params; @@ -195,6 +202,8 @@ export class TransactionService { amount, destination, destinationAccount, + memo, + memoType, useCache, }); } @@ -207,6 +216,8 @@ export class TransactionService { amount, destination, destinationAccount, + memo, + memoType, }); } @@ -220,6 +231,8 @@ export class TransactionService { * @param params.amount - The amount to send. * @param params.destination - The destination address. * @param params.destinationAccount - The destination account. + * @param params.memo - Optional Stellar memo value to attach to the envelope. + * @param params.memoType - Optional explicit memo type (federation / client hint). * @param params.useCache - When `true`, reuses a cached SEP-41 simulation keyed by * asset, sender, recipient, and scope (not amount). Use only for preflight checks * such as amount-input validation, where the caller needs fee/balance feedback on @@ -235,6 +248,8 @@ export class TransactionService { amount: BigNumber; destination: string; destinationAccount: OnChainAccount; + memo?: string; + memoType?: StellarMemoType; useCache: boolean; }): Promise { const { @@ -244,6 +259,8 @@ export class TransactionService { amount, destination, destinationAccount, + memo, + memoType, useCache, } = params; @@ -256,6 +273,8 @@ export class TransactionService { amount, destination, baseFee, + memo, + memoType, }); // Use getRawAsset so we only fetch when the asset is absent from the State. @@ -324,6 +343,8 @@ export class TransactionService { * @param params.amount - The amount to send. * @param params.destination - The destination address. * @param params.destinationAccount - The destination account. + * @param params.memo - Optional Stellar memo value to attach to the envelope. + * @param params.memoType - Optional explicit memo type (federation / client hint). * @returns A promise that resolves to the validated transaction. */ async #createValidatedClassicAssetTransfer(params: { @@ -333,6 +354,8 @@ export class TransactionService { amount: BigNumber; destination: string; destinationAccount: OnChainAccount | null; + memo?: string; + memoType?: StellarMemoType; }): Promise { const { onChainAccount, @@ -341,6 +364,8 @@ export class TransactionService { amount, destinationAccount, destination, + memo, + memoType, } = params; const isDestinationActivated = destinationAccount !== null; @@ -362,6 +387,8 @@ export class TransactionService { isActivated: isDestinationActivated, }, baseFee, + memo, + memoType, }); this.validateTransaction(transaction, onChainAccount, { diff --git a/packages/stellar-wallet-snap/src/services/transaction/index.ts b/packages/stellar-wallet-snap/src/services/transaction/index.ts index f1d557c06..1071a6457 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/index.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/index.ts @@ -1,5 +1,6 @@ export * from './OperationMapper'; export * from './exceptions'; +export * from './memo'; export * from './Transaction'; export * from './TransactionBuilder'; export * from './TransactionRepository'; diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts new file mode 100644 index 000000000..5a7d04b2c --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts @@ -0,0 +1,82 @@ +import { Memo } from '@stellar/stellar-sdk'; + +import { + inferStellarMemoType, + resolveStellarMemo, + StellarMemoType, +} from './memo'; + +describe('inferStellarMemoType', () => { + it.each([ + { value: '12345', expected: StellarMemoType.Id }, + { value: '0', expected: StellarMemoType.Id }, + { value: '18446744073709551615', expected: StellarMemoType.Id }, + { value: 'deposit-ref', expected: StellarMemoType.Text }, + { value: '12abc', expected: StellarMemoType.Text }, + { value: '18446744073709551616', expected: StellarMemoType.Text }, + ])('infers $expected for $value', ({ value, expected }) => { + expect(inferStellarMemoType(value)).toBe(expected); + }); +}); + +describe('resolveStellarMemo', () => { + it('returns null for empty or whitespace-only values', () => { + expect(resolveStellarMemo({ value: undefined })).toBeNull(); + expect(resolveStellarMemo({ value: '' })).toBeNull(); + expect(resolveStellarMemo({ value: ' ' })).toBeNull(); + }); + + it('builds a text memo by default for non-numeric values', () => { + const memo = resolveStellarMemo({ value: ' deposit-ref ' }); + expect(memo).toStrictEqual(Memo.text('deposit-ref')); + }); + + it('infers memo id for all-digit values when type is omitted', () => { + const memo = resolveStellarMemo({ value: '9876543210' }); + expect(memo).toStrictEqual(Memo.id('9876543210')); + }); + + it('honors an explicit text type for numeric values', () => { + const memo = resolveStellarMemo({ + value: '12345', + type: StellarMemoType.Text, + }); + expect(memo).toStrictEqual(Memo.text('12345')); + }); + + it('honors an explicit id type', () => { + const memo = resolveStellarMemo({ + value: '42', + type: StellarMemoType.Id, + }); + expect(memo).toStrictEqual(Memo.id('42')); + }); + + it('builds hash and return memos from 64-char hex', () => { + const hex = 'a'.repeat(64); + expect(resolveStellarMemo({ value: hex, type: StellarMemoType.Hash })).toStrictEqual( + Memo.hash(hex), + ); + expect( + resolveStellarMemo({ value: hex, type: StellarMemoType.Return }), + ).toStrictEqual(Memo.return(hex)); + }); + + it('throws when text memo exceeds 28 UTF-8 bytes', () => { + expect(() => + resolveStellarMemo({ value: 'é'.repeat(15) }), + ).toThrow('Memo must be 28 bytes or fewer'); + }); + + it('throws when hash hex is invalid', () => { + expect(() => + resolveStellarMemo({ value: 'abc', type: StellarMemoType.Hash }), + ).toThrow('Memo hash must be a 64-character hex string'); + }); + + it('throws when explicit id is not decimal', () => { + expect(() => + resolveStellarMemo({ value: 'not-an-id', type: StellarMemoType.Id }), + ).toThrow('Memo id must be a non-negative decimal integer'); + }); +}); diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.ts new file mode 100644 index 000000000..5e7334bf3 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.ts @@ -0,0 +1,123 @@ +import { Memo } from '@stellar/stellar-sdk'; + +import { STELLAR_TEXT_MEMO_MAX_BYTES } from '../../constants'; + +/** + * Stellar memo kinds supported when attaching a memo to a send transaction. + * + * When federation (SEP-2) or muxed destinations are available, pass the + * destination's `memo_type` as {@link StellarMemoType}. Until then, numeric + * values are inferred as `id` (exchange-style); everything else falls back to + * `text`. + */ +export const StellarMemoType = { + Text: 'text', + Id: 'id', + Hash: 'hash', + Return: 'return', +} as const; + +export type StellarMemoType = + (typeof StellarMemoType)[keyof typeof StellarMemoType]; + +const STELLAR_MEMO_ID_MAX = 18446744073709551615n; +const STELLAR_MEMO_HASH_HEX_LENGTH = 64; +const STELLAR_MEMO_HASH_HEX_PATTERN = /^[0-9a-fA-F]+$/u; + +/** + * Infers a memo type when the destination / client did not specify one. + * All-digit uint64 values → `id` (common for exchanges); otherwise `text`. + * + * @param value - Trimmed memo string. + * @returns Inferred memo type. + */ +export function inferStellarMemoType(value: string): StellarMemoType { + if (/^\d+$/u.test(value)) { + try { + const asId = BigInt(value); + if (asId >= 0n && asId <= STELLAR_MEMO_ID_MAX) { + return StellarMemoType.Id; + } + } catch { + // Fall through to text. + } + } + return StellarMemoType.Text; +} + +/** + * Builds a Stellar SDK {@link Memo} from a string value and optional type hint. + * + * Resolution order: + * 1. Explicit `type` when provided (federation / client hint) + * 2. Otherwise {@link inferStellarMemoType} (numeric → id, else text) + * + * @param params - Memo value and optional type. + * @param params.value - Raw memo string from the client or confirmation UI. + * @param params.type - Optional explicit type (SEP-2 `memo_type` when known). + * @returns SDK memo, or `null` when the value is empty / whitespace-only. + * @throws {Error} When the value is invalid for the resolved type. + */ +export function resolveStellarMemo(params: { + value?: string | null; + type?: StellarMemoType | null; +}): Memo | null { + const trimmed = params.value?.trim() ?? ''; + if (trimmed.length === 0) { + return null; + } + + const type = params.type ?? inferStellarMemoType(trimmed); + + switch (type) { + case StellarMemoType.Id: + assertMemoId(trimmed); + return Memo.id(trimmed); + case StellarMemoType.Hash: + assertMemoHashOrReturn(trimmed, StellarMemoType.Hash); + return Memo.hash(trimmed); + case StellarMemoType.Return: + assertMemoHashOrReturn(trimmed, StellarMemoType.Return); + return Memo.return(trimmed); + case StellarMemoType.Text: + default: + assertMemoText(trimmed); + return Memo.text(trimmed); + } +} + +function assertMemoText(value: string): void { + if (new TextEncoder().encode(value).length > STELLAR_TEXT_MEMO_MAX_BYTES) { + throw new Error( + `Memo must be ${STELLAR_TEXT_MEMO_MAX_BYTES} bytes or fewer`, + ); + } +} + +function assertMemoId(value: string): void { + if (!/^\d+$/u.test(value)) { + throw new Error('Memo id must be a non-negative decimal integer'); + } + try { + const asId = BigInt(value); + if (asId < 0n || asId > STELLAR_MEMO_ID_MAX) { + throw new Error('Memo id is out of uint64 range'); + } + } catch (error: unknown) { + if (error instanceof Error && error.message.startsWith('Memo id')) { + throw error; + } + throw new Error('Memo id must be a non-negative decimal integer'); + } +} + +function assertMemoHashOrReturn(value: string, kind: 'hash' | 'return'): void { + if ( + value.length !== STELLAR_MEMO_HASH_HEX_LENGTH || + !STELLAR_MEMO_HASH_HEX_PATTERN.test(value) + ) { + throw new Error( + `Memo ${kind} must be a ${STELLAR_MEMO_HASH_HEX_LENGTH}-character hex string`, + ); + } +} From acea5e109a28cc807474a7f3bd7999b4e270de9a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 16:26:57 +0200 Subject: [PATCH 2/9] refactor(stellar-wallet-snap): drop unused StellarTextMemoStruct --- .../stellar-wallet-snap/src/api/string.ts | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/stellar-wallet-snap/src/api/string.ts b/packages/stellar-wallet-snap/src/api/string.ts index 7f8831a94..5919b08b6 100644 --- a/packages/stellar-wallet-snap/src/api/string.ts +++ b/packages/stellar-wallet-snap/src/api/string.ts @@ -1,8 +1,6 @@ import type { Infer } from '@metamask/superstruct'; import { enums, refine, string } from '@metamask/superstruct'; -import { STELLAR_TEXT_MEMO_MAX_BYTES } from '../constants'; - /** * Validation struct for a UTF-8 string. */ @@ -28,32 +26,10 @@ export const StellarMemoTypeStruct = enums(['text', 'id', 'hash', 'return']); export type StellarMemoTypeParam = Infer; /** - * Validation struct for an optional Stellar text memo (≤ 28 UTF-8 bytes). - * Empty / whitespace-only values are allowed at the wire layer and treated as - * absent when building the transaction. - */ -export const StellarTextMemoStruct = refine( - string(), - 'stellar-text-memo', - (value) => { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return true; - } - if ( - new TextEncoder().encode(trimmed).length > STELLAR_TEXT_MEMO_MAX_BYTES - ) { - return `Memo must be ${STELLAR_TEXT_MEMO_MAX_BYTES} bytes or fewer`; - } - return true; - }, -); - -export type StellarTextMemo = Infer; - -/** - * Wire-layer memo value for confirmSend: allows text (≤ 28 bytes), memo id - * digits, or 64-char hex for hash/return. Strict type checks run at build time. + * Wire-layer memo value for confirmSend: loose length gate only (≤ 64 chars) + * so text, memo id digits, and hash/return hex can all pass. Empty / + * whitespace-only values are allowed and treated as absent when building. + * Strict type and text-byte checks run in `resolveStellarMemo` at build time. */ export const StellarMemoValueStruct = refine( string(), From 1c14e23b1ae115be98c43fd001544e4d17b27757 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 16:27:50 +0200 Subject: [PATCH 3/9] chore: lint --- .../src/services/transaction/memo.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts index 5a7d04b2c..21b0076a8 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts @@ -54,18 +54,18 @@ describe('resolveStellarMemo', () => { it('builds hash and return memos from 64-char hex', () => { const hex = 'a'.repeat(64); - expect(resolveStellarMemo({ value: hex, type: StellarMemoType.Hash })).toStrictEqual( - Memo.hash(hex), - ); + expect( + resolveStellarMemo({ value: hex, type: StellarMemoType.Hash }), + ).toStrictEqual(Memo.hash(hex)); expect( resolveStellarMemo({ value: hex, type: StellarMemoType.Return }), ).toStrictEqual(Memo.return(hex)); }); it('throws when text memo exceeds 28 UTF-8 bytes', () => { - expect(() => - resolveStellarMemo({ value: 'é'.repeat(15) }), - ).toThrow('Memo must be 28 bytes or fewer'); + expect(() => resolveStellarMemo({ value: 'é'.repeat(15) })).toThrow( + 'Memo must be 28 bytes or fewer', + ); }); it('throws when hash hex is invalid', () => { From 24609447f8615ea221112119bdf01848da478722 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 16:33:50 +0200 Subject: [PATCH 4/9] refactor(stellar-wallet-snap): derive memo type from StellarMemoTypes const --- packages/stellar-wallet-snap/src/api/index.ts | 7 +++- .../stellar-wallet-snap/src/api/string.ts | 27 +++++++++++++--- .../src/services/transaction/memo.test.ts | 32 +++++++++---------- .../src/services/transaction/memo.ts | 32 ++++++++----------- 4 files changed, 59 insertions(+), 39 deletions(-) diff --git a/packages/stellar-wallet-snap/src/api/index.ts b/packages/stellar-wallet-snap/src/api/index.ts index b3c326ddf..7d0716bfb 100644 --- a/packages/stellar-wallet-snap/src/api/index.ts +++ b/packages/stellar-wallet-snap/src/api/index.ts @@ -6,4 +6,9 @@ export * from './json'; export * from './integer'; export * from './xdr'; export * from './transactionHash'; -export * from './string'; +export { + StellarMemoTypes, + StellarMemoTypeStruct, + StellarMemoValueStruct, +} from './string'; +export type { StellarMemoType, StellarMemoValue } from './string'; diff --git a/packages/stellar-wallet-snap/src/api/string.ts b/packages/stellar-wallet-snap/src/api/string.ts index 5919b08b6..8ad90e76b 100644 --- a/packages/stellar-wallet-snap/src/api/string.ts +++ b/packages/stellar-wallet-snap/src/api/string.ts @@ -18,12 +18,31 @@ export const Utf8StringStruct = refine(string(), 'utf8', (value) => { export type Utf8String = Infer; /** - * Optional explicit Stellar memo type (SEP-2 `memo_type` when known). - * When omitted, the builder infers `id` for all-digit uint64 values, else `text`. + * Stellar memo kinds (SEP-2 `memo_type` values). + * + * When omitted on confirmSend, the builder infers `id` for all-digit uint64 + * values, else `text`. */ -export const StellarMemoTypeStruct = enums(['text', 'id', 'hash', 'return']); +export const StellarMemoTypes = { + Text: 'text', + Id: 'id', + Hash: 'hash', + Return: 'return', +} as const; -export type StellarMemoTypeParam = Infer; +/** Union of {@link StellarMemoTypes} values — single source of truth with the const. */ +export type StellarMemoType = + (typeof StellarMemoTypes)[keyof typeof StellarMemoTypes]; + +/** + * Wire-layer validation for {@link StellarMemoType} — derived from {@link StellarMemoTypes}. + */ +export const StellarMemoTypeStruct = enums([ + StellarMemoTypes.Text, + StellarMemoTypes.Id, + StellarMemoTypes.Hash, + StellarMemoTypes.Return, +]); /** * Wire-layer memo value for confirmSend: loose length gate only (≤ 64 chars) diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts index 21b0076a8..33f32b7cf 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts @@ -3,17 +3,17 @@ import { Memo } from '@stellar/stellar-sdk'; import { inferStellarMemoType, resolveStellarMemo, - StellarMemoType, + StellarMemoTypes, } from './memo'; describe('inferStellarMemoType', () => { it.each([ - { value: '12345', expected: StellarMemoType.Id }, - { value: '0', expected: StellarMemoType.Id }, - { value: '18446744073709551615', expected: StellarMemoType.Id }, - { value: 'deposit-ref', expected: StellarMemoType.Text }, - { value: '12abc', expected: StellarMemoType.Text }, - { value: '18446744073709551616', expected: StellarMemoType.Text }, + { value: '12345', expected: StellarMemoTypes.Id }, + { value: '0', expected: StellarMemoTypes.Id }, + { value: '18446744073709551615', expected: StellarMemoTypes.Id }, + { value: 'deposit-ref', expected: StellarMemoTypes.Text }, + { value: '12abc', expected: StellarMemoTypes.Text }, + { value: '18446744073709551616', expected: StellarMemoTypes.Text }, ])('infers $expected for $value', ({ value, expected }) => { expect(inferStellarMemoType(value)).toBe(expected); }); @@ -39,7 +39,7 @@ describe('resolveStellarMemo', () => { it('honors an explicit text type for numeric values', () => { const memo = resolveStellarMemo({ value: '12345', - type: StellarMemoType.Text, + type: StellarMemoTypes.Text, }); expect(memo).toStrictEqual(Memo.text('12345')); }); @@ -47,19 +47,19 @@ describe('resolveStellarMemo', () => { it('honors an explicit id type', () => { const memo = resolveStellarMemo({ value: '42', - type: StellarMemoType.Id, + type: StellarMemoTypes.Id, }); expect(memo).toStrictEqual(Memo.id('42')); }); it('builds hash and return memos from 64-char hex', () => { - const hex = 'a'.repeat(64); + const hashHex = 'a'.repeat(64); expect( - resolveStellarMemo({ value: hex, type: StellarMemoType.Hash }), - ).toStrictEqual(Memo.hash(hex)); + resolveStellarMemo({ value: hashHex, type: StellarMemoTypes.Hash }), + ).toStrictEqual(Memo.hash(hashHex)); expect( - resolveStellarMemo({ value: hex, type: StellarMemoType.Return }), - ).toStrictEqual(Memo.return(hex)); + resolveStellarMemo({ value: hashHex, type: StellarMemoTypes.Return }), + ).toStrictEqual(Memo.return(hashHex)); }); it('throws when text memo exceeds 28 UTF-8 bytes', () => { @@ -70,13 +70,13 @@ describe('resolveStellarMemo', () => { it('throws when hash hex is invalid', () => { expect(() => - resolveStellarMemo({ value: 'abc', type: StellarMemoType.Hash }), + resolveStellarMemo({ value: 'abc', type: StellarMemoTypes.Hash }), ).toThrow('Memo hash must be a 64-character hex string'); }); it('throws when explicit id is not decimal', () => { expect(() => - resolveStellarMemo({ value: 'not-an-id', type: StellarMemoType.Id }), + resolveStellarMemo({ value: 'not-an-id', type: StellarMemoTypes.Id }), ).toThrow('Memo id must be a non-negative decimal integer'); }); }); diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.ts index 5e7334bf3..cb5026e8e 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/memo.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.ts @@ -1,24 +1,20 @@ import { Memo } from '@stellar/stellar-sdk'; +import type { StellarMemoType } from '../../api/string'; +import { StellarMemoTypes } from '../../api/string'; import { STELLAR_TEXT_MEMO_MAX_BYTES } from '../../constants'; +export type { StellarMemoType } from '../../api/string'; +export { StellarMemoTypes } from '../../api/string'; + /** - * Stellar memo kinds supported when attaching a memo to a send transaction. + * Builds / resolves Stellar memos for send transactions. * * When federation (SEP-2) or muxed destinations are available, pass the * destination's `memo_type` as {@link StellarMemoType}. Until then, numeric * values are inferred as `id` (exchange-style); everything else falls back to * `text`. */ -export const StellarMemoType = { - Text: 'text', - Id: 'id', - Hash: 'hash', - Return: 'return', -} as const; - -export type StellarMemoType = - (typeof StellarMemoType)[keyof typeof StellarMemoType]; const STELLAR_MEMO_ID_MAX = 18446744073709551615n; const STELLAR_MEMO_HASH_HEX_LENGTH = 64; @@ -36,13 +32,13 @@ export function inferStellarMemoType(value: string): StellarMemoType { try { const asId = BigInt(value); if (asId >= 0n && asId <= STELLAR_MEMO_ID_MAX) { - return StellarMemoType.Id; + return StellarMemoTypes.Id; } } catch { // Fall through to text. } } - return StellarMemoType.Text; + return StellarMemoTypes.Text; } /** @@ -70,16 +66,16 @@ export function resolveStellarMemo(params: { const type = params.type ?? inferStellarMemoType(trimmed); switch (type) { - case StellarMemoType.Id: + case StellarMemoTypes.Id: assertMemoId(trimmed); return Memo.id(trimmed); - case StellarMemoType.Hash: - assertMemoHashOrReturn(trimmed, StellarMemoType.Hash); + case StellarMemoTypes.Hash: + assertMemoHashOrReturn(trimmed, StellarMemoTypes.Hash); return Memo.hash(trimmed); - case StellarMemoType.Return: - assertMemoHashOrReturn(trimmed, StellarMemoType.Return); + case StellarMemoTypes.Return: + assertMemoHashOrReturn(trimmed, StellarMemoTypes.Return); return Memo.return(trimmed); - case StellarMemoType.Text: + case StellarMemoTypes.Text: default: assertMemoText(trimmed); return Memo.text(trimmed); From c0f3d0232256b90a19435252a98b8667e1478164 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 16:36:45 +0200 Subject: [PATCH 5/9] refactor(stellar-wallet-snap): use StellarMemoType const with Infer type --- packages/stellar-wallet-snap/src/api/index.ts | 4 +-- .../stellar-wallet-snap/src/api/string.ts | 19 +++++++------- .../src/services/transaction/memo.test.ts | 26 +++++++++---------- .../src/services/transaction/memo.ts | 22 +++++++--------- 4 files changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/stellar-wallet-snap/src/api/index.ts b/packages/stellar-wallet-snap/src/api/index.ts index 7d0716bfb..3d9a73041 100644 --- a/packages/stellar-wallet-snap/src/api/index.ts +++ b/packages/stellar-wallet-snap/src/api/index.ts @@ -7,8 +7,8 @@ export * from './integer'; export * from './xdr'; export * from './transactionHash'; export { - StellarMemoTypes, + StellarMemoType, StellarMemoTypeStruct, StellarMemoValueStruct, } from './string'; -export type { StellarMemoType, StellarMemoValue } from './string'; +export type { StellarMemoValue } from './string'; diff --git a/packages/stellar-wallet-snap/src/api/string.ts b/packages/stellar-wallet-snap/src/api/string.ts index 8ad90e76b..61ad6a3b9 100644 --- a/packages/stellar-wallet-snap/src/api/string.ts +++ b/packages/stellar-wallet-snap/src/api/string.ts @@ -23,27 +23,26 @@ export type Utf8String = Infer; * When omitted on confirmSend, the builder infers `id` for all-digit uint64 * values, else `text`. */ -export const StellarMemoTypes = { +export const StellarMemoType = { Text: 'text', Id: 'id', Hash: 'hash', Return: 'return', } as const; -/** Union of {@link StellarMemoTypes} values — single source of truth with the const. */ -export type StellarMemoType = - (typeof StellarMemoTypes)[keyof typeof StellarMemoTypes]; - /** - * Wire-layer validation for {@link StellarMemoType} — derived from {@link StellarMemoTypes}. + * Wire-layer validation — values taken from {@link StellarMemoType}. */ export const StellarMemoTypeStruct = enums([ - StellarMemoTypes.Text, - StellarMemoTypes.Id, - StellarMemoTypes.Hash, - StellarMemoTypes.Return, + StellarMemoType.Text, + StellarMemoType.Id, + StellarMemoType.Hash, + StellarMemoType.Return, ]); +/** Union of memo type strings — derived from {@link StellarMemoTypeStruct}. */ +export type StellarMemoType = Infer; + /** * Wire-layer memo value for confirmSend: loose length gate only (≤ 64 chars) * so text, memo id digits, and hash/return hex can all pass. Empty / diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts index 33f32b7cf..a06907e2d 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts @@ -3,17 +3,17 @@ import { Memo } from '@stellar/stellar-sdk'; import { inferStellarMemoType, resolveStellarMemo, - StellarMemoTypes, + StellarMemoType, } from './memo'; describe('inferStellarMemoType', () => { it.each([ - { value: '12345', expected: StellarMemoTypes.Id }, - { value: '0', expected: StellarMemoTypes.Id }, - { value: '18446744073709551615', expected: StellarMemoTypes.Id }, - { value: 'deposit-ref', expected: StellarMemoTypes.Text }, - { value: '12abc', expected: StellarMemoTypes.Text }, - { value: '18446744073709551616', expected: StellarMemoTypes.Text }, + { value: '12345', expected: StellarMemoType.Id }, + { value: '0', expected: StellarMemoType.Id }, + { value: '18446744073709551615', expected: StellarMemoType.Id }, + { value: 'deposit-ref', expected: StellarMemoType.Text }, + { value: '12abc', expected: StellarMemoType.Text }, + { value: '18446744073709551616', expected: StellarMemoType.Text }, ])('infers $expected for $value', ({ value, expected }) => { expect(inferStellarMemoType(value)).toBe(expected); }); @@ -39,7 +39,7 @@ describe('resolveStellarMemo', () => { it('honors an explicit text type for numeric values', () => { const memo = resolveStellarMemo({ value: '12345', - type: StellarMemoTypes.Text, + type: StellarMemoType.Text, }); expect(memo).toStrictEqual(Memo.text('12345')); }); @@ -47,7 +47,7 @@ describe('resolveStellarMemo', () => { it('honors an explicit id type', () => { const memo = resolveStellarMemo({ value: '42', - type: StellarMemoTypes.Id, + type: StellarMemoType.Id, }); expect(memo).toStrictEqual(Memo.id('42')); }); @@ -55,10 +55,10 @@ describe('resolveStellarMemo', () => { it('builds hash and return memos from 64-char hex', () => { const hashHex = 'a'.repeat(64); expect( - resolveStellarMemo({ value: hashHex, type: StellarMemoTypes.Hash }), + resolveStellarMemo({ value: hashHex, type: StellarMemoType.Hash }), ).toStrictEqual(Memo.hash(hashHex)); expect( - resolveStellarMemo({ value: hashHex, type: StellarMemoTypes.Return }), + resolveStellarMemo({ value: hashHex, type: StellarMemoType.Return }), ).toStrictEqual(Memo.return(hashHex)); }); @@ -70,13 +70,13 @@ describe('resolveStellarMemo', () => { it('throws when hash hex is invalid', () => { expect(() => - resolveStellarMemo({ value: 'abc', type: StellarMemoTypes.Hash }), + resolveStellarMemo({ value: 'abc', type: StellarMemoType.Hash }), ).toThrow('Memo hash must be a 64-character hex string'); }); it('throws when explicit id is not decimal', () => { expect(() => - resolveStellarMemo({ value: 'not-an-id', type: StellarMemoTypes.Id }), + resolveStellarMemo({ value: 'not-an-id', type: StellarMemoType.Id }), ).toThrow('Memo id must be a non-negative decimal integer'); }); }); diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.ts index cb5026e8e..8c02ebaf8 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/memo.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.ts @@ -1,11 +1,9 @@ import { Memo } from '@stellar/stellar-sdk'; -import type { StellarMemoType } from '../../api/string'; -import { StellarMemoTypes } from '../../api/string'; +import { StellarMemoType } from '../../api/string'; import { STELLAR_TEXT_MEMO_MAX_BYTES } from '../../constants'; -export type { StellarMemoType } from '../../api/string'; -export { StellarMemoTypes } from '../../api/string'; +export { StellarMemoType } from '../../api/string'; /** * Builds / resolves Stellar memos for send transactions. @@ -32,13 +30,13 @@ export function inferStellarMemoType(value: string): StellarMemoType { try { const asId = BigInt(value); if (asId >= 0n && asId <= STELLAR_MEMO_ID_MAX) { - return StellarMemoTypes.Id; + return StellarMemoType.Id; } } catch { // Fall through to text. } } - return StellarMemoTypes.Text; + return StellarMemoType.Text; } /** @@ -66,16 +64,16 @@ export function resolveStellarMemo(params: { const type = params.type ?? inferStellarMemoType(trimmed); switch (type) { - case StellarMemoTypes.Id: + case StellarMemoType.Id: assertMemoId(trimmed); return Memo.id(trimmed); - case StellarMemoTypes.Hash: - assertMemoHashOrReturn(trimmed, StellarMemoTypes.Hash); + case StellarMemoType.Hash: + assertMemoHashOrReturn(trimmed, StellarMemoType.Hash); return Memo.hash(trimmed); - case StellarMemoTypes.Return: - assertMemoHashOrReturn(trimmed, StellarMemoTypes.Return); + case StellarMemoType.Return: + assertMemoHashOrReturn(trimmed, StellarMemoType.Return); return Memo.return(trimmed); - case StellarMemoTypes.Text: + case StellarMemoType.Text: default: assertMemoText(trimmed); return Memo.text(trimmed); From c8e4ff6025d36b30b63915f6ac1ba8e966b6a4d4 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 16:43:55 +0200 Subject: [PATCH 6/9] feat(stellar-wallet-snap): update memo handling to support wire format and refactoring --- .../docs/use-cases/client-request/confirmSend.md | 2 +- .../src/handlers/clientRequest/confirmSend.ts | 9 +++------ .../refreshConfirmationContext/transactionRefresher.ts | 2 +- .../src/services/transaction/index.ts | 6 +++++- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md index 5fe842f8e..748c7271f 100644 --- a/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md +++ b/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md @@ -17,7 +17,7 @@ Confirms and submits a send for Unified Non-EVM Send (live on-chain data at buil - `toAddress` — Stellar destination - `assetId` — CAIP-19 classic / SEP-41 / slip44 (`scope` derived from `assetId`) - `amount` — human-readable amount string -- `memo` — optional memo value (text ≤ 28 UTF-8 bytes, numeric id, or 64-char hex for hash/return) +- `memo` — optional memo string (wire: ≤ 64 chars so text, id digits, or hash/return hex fit). Empty/whitespace is treated as absent. Text memos are limited to **28 UTF-8 bytes at build** (`resolveStellarMemo`); id/hash/return are validated by type there too. - `memoType` — optional `text` | `id` | `hash` | `return` (SEP-2 / client hint). When omitted, all-digit uint64 values are treated as memo **id**; otherwise **text**. Federation/muxed destination resolution is a follow-up. **Response** diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 37e4797b7..b517cf651 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -116,9 +116,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< ): Promise { try { const { onChainAccount, account: stellarKeyringAccount } = resolved; - const { amount, toAddress, assetId, scope } = request.params; - const memo = request.params.memo?.trim() ?? undefined; - const { memoType } = request.params; + const { amount, toAddress, assetId, scope, memo, memoType } = + request.params; const assetMetadata = await this.#assetMetadataService.resolve(assetId); const { decimals, symbol } = assetMetadata.units[0]; @@ -289,9 +288,7 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< transaction: Transaction; }> { const { request, confirmedTransaction, amount } = params; - const { assetId, toAddress, scope } = request.params; - const memo = request.params.memo?.trim() ?? undefined; - const { memoType } = request.params; + const { assetId, toAddress, scope, memo, memoType } = request.params; // Resolve again after the user confirms so sequence, balances, and fees are fresh before signing. // sendTransaction still handles txBadSeq races that happen after this refresh. const { wallet, onChainAccount } = await this.resolveAccount(request); diff --git a/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts b/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts index 403719995..931be5da1 100644 --- a/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts +++ b/packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts @@ -139,7 +139,7 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef assetId: request.params.assetId, destination: request.params.toAddress, amount, - memo: request.params.memo?.trim() ?? undefined, + memo: request.params.memo, memoType: request.params.memoType, }); break; diff --git a/packages/stellar-wallet-snap/src/services/transaction/index.ts b/packages/stellar-wallet-snap/src/services/transaction/index.ts index 1071a6457..c78ffad95 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/index.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/index.ts @@ -1,6 +1,10 @@ export * from './OperationMapper'; export * from './exceptions'; -export * from './memo'; +export { + StellarMemoType, + inferStellarMemoType, + resolveStellarMemo, +} from './memo'; export * from './Transaction'; export * from './TransactionBuilder'; export * from './TransactionRepository'; From 7d7d9aa53665ec90d1119973429391d105a48c5c Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 17:06:13 +0200 Subject: [PATCH 7/9] test: add memo validation and integration tests for stellar-wallet-snap --- .../src/api/string.test.ts | 35 ++++++++++++++ .../src/handlers/clientRequest/api.test.ts | 48 +++++++++++++++++++ .../clientRequest/confirmSend.test.ts | 19 ++++++++ .../transaction/TransactionBuilder.test.ts | 19 ++++++++ .../transaction/TransactionService.test.ts | 46 ++++++++++++++++++ .../src/services/transaction/memo.test.ts | 18 +++++++ 6 files changed, 185 insertions(+) create mode 100644 packages/stellar-wallet-snap/src/api/string.test.ts diff --git a/packages/stellar-wallet-snap/src/api/string.test.ts b/packages/stellar-wallet-snap/src/api/string.test.ts new file mode 100644 index 000000000..46d9cb7c8 --- /dev/null +++ b/packages/stellar-wallet-snap/src/api/string.test.ts @@ -0,0 +1,35 @@ +import { assert, StructError } from '@metamask/superstruct'; + +import { + StellarMemoType, + StellarMemoTypeStruct, + StellarMemoValueStruct, +} from './string'; + +describe('StellarMemoTypeStruct', () => { + it.each(Object.values(StellarMemoType))( + 'accepts memo type %s', + (memoType) => { + expect(() => assert(memoType, StellarMemoTypeStruct)).not.toThrow(); + }, + ); + + it('rejects an unknown memo type', () => { + expect(() => assert('none', StellarMemoTypeStruct)).toThrow(StructError); + }); +}); + +describe('StellarMemoValueStruct', () => { + it.each(['', ' ', 'deposit-ref', '12345', `${'a'.repeat(64)}`])( + 'accepts memo value %j', + (value) => { + expect(() => assert(value, StellarMemoValueStruct)).not.toThrow(); + }, + ); + + it('rejects memo values longer than 64 characters', () => { + expect(() => assert('a'.repeat(65), StellarMemoValueStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index b04c0f69b..447e93b28 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -799,6 +799,54 @@ describe('ConfirmSendJsonRpcRequestStruct', () => { expect(result.params.scope).toBe('stellar:testnet'); }); + it('accepts optional memo and memoType on confirmSend', () => { + const result = create( + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + memo: 'deposit-ref', + memoType: 'text', + }, + }, + ConfirmSendJsonRpcRequestStruct, + ); + + expect(result.params.memo).toBe('deposit-ref'); + expect(result.params.memoType).toBe('text'); + }); + + it('rejects confirmSend when memo exceeds the wire length gate', () => { + expect(() => + assert( + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + memo: 'a'.repeat(65), + }, + }, + ConfirmSendJsonRpcRequestStruct, + ), + ).toThrow(StructError); + }); + + it('rejects confirmSend when memoType is invalid', () => { + expect(() => + assert( + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + memo: '1', + memoType: 'none', + }, + }, + ConfirmSendJsonRpcRequestStruct, + ), + ).toThrow(StructError); + }); + it.each([ { ...baseWireRequest, diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index c0201f58d..a88d57e35 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -370,6 +370,25 @@ describe('ConfirmSendHandler', () => { }); }); + it('forwards memo and memoType into createValidatedSendTransaction', async () => { + const { handler, onChainAccount, createValidatedSendTransaction } = + setup(); + + await handler.handle( + baseRequest({ memo: 'deposit-ref', memoType: 'text' }), + ); + + expect(createValidatedSendTransaction).toHaveBeenCalledWith({ + onChainAccount, + scope, + assetId, + amount: new BigNumber('10000000'), + destination: destinationAddress, + memo: 'deposit-ref', + memoType: 'text', + }); + }); + it('throws UserRejectedRequestError when confirmation is rejected', async () => { const { handler, diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts index cab455ad5..359f230da 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts @@ -277,6 +277,25 @@ describe('TransactionBuilder', () => { ); }); + it('throws TransactionBuilderException when the memo is invalid', () => { + const testDestination = getTestWallet(); + + expect(() => + transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + amount: new BigNumber(100), + destination: { + address: testDestination.address, + isActivated: true, + }, + baseFee: new BigNumber(100), + memo: 'é'.repeat(15), + }), + ).toThrow(TransactionBuilderException); + }); + it('builds a create account transaction', () => { const testDestination = getTestWallet(); const transaction = transactionBuilder.transfer({ diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index 45e3d2184..490be7163 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -564,6 +564,52 @@ describe('TransactionService', () => { expect(tx.transactionOperations[0]?.type).toBe('payment'); }); + it('attaches memo on a classic native send', async () => { + const { transactionService } = createMockTransactionService(); + const sourceWallet = getTestWallet(); + const destWallet = getTestWallet(); + + const sourceAcc = createMockAccountWithBalances( + sourceWallet.address, + '1', + { ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, nativeBalance: 500 }, + ); + const sourceOnChain = new OnChainAccount( + sourceAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(sourceAcc, KnownCaip2ChainId.Mainnet), + ); + + const destAcc = createMockAccountWithBalances(destWallet.address, '1', { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 50, + }); + const destOnChain = new OnChainAccount( + destAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(destAcc, KnownCaip2ChainId.Mainnet), + ); + + jest + .spyOn(NetworkService.prototype, 'loadOnChainAccount') + .mockResolvedValue(destOnChain); + jest + .spyOn(NetworkService.prototype, 'getBaseFee') + .mockResolvedValue(new BigNumber('100')); + + const tx = await transactionService.createValidatedSendTransaction({ + onChainAccount: sourceOnChain, + amount: new BigNumber('1000000'), + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + destination: destWallet.address, + memo: 'deposit-ref', + memoType: 'text', + }); + + expect(tx.getMemo()).toBe('deposit-ref'); + }); + it('returns a createAccount transaction for native XLM to an unfunded destination', async () => { const { transactionService } = createMockTransactionService(); const sourceWallet = getTestWallet(); diff --git a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts index a06907e2d..f51726924 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts @@ -79,4 +79,22 @@ describe('resolveStellarMemo', () => { resolveStellarMemo({ value: 'not-an-id', type: StellarMemoType.Id }), ).toThrow('Memo id must be a non-negative decimal integer'); }); + + it('throws when explicit id is out of uint64 range', () => { + expect(() => + resolveStellarMemo({ + value: '18446744073709551616', + type: StellarMemoType.Id, + }), + ).toThrow('Memo id is out of uint64 range'); + }); + + it('throws when return hex is invalid', () => { + expect(() => + resolveStellarMemo({ + value: 'g'.repeat(64), + type: StellarMemoType.Return, + }), + ).toThrow('Memo return must be a 64-character hex string'); + }); }); From f8396355d8222c2a1719e44ea1737fe5c58d3857 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 17:15:42 +0200 Subject: [PATCH 8/9] feat(stellar-wallet-snap): add optional memo/memoType to confirmSend and attach resolved Stellar memos on send build path --- packages/stellar-wallet-snap/CHANGELOG.md | 1 + .../src/handlers/clientRequest/confirmSend.test.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 47bd08881..80b261c02 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add optional `memo` / `memoType` on `confirmSend` and attach resolved Stellar memos on the send build path (infer `id` for all-digit uint64 values, else `text`; explicit type wins) ([#289](https://github.com/MetaMask/internal-snaps/pull/289)) - Add `signProofOfOwnership` client request for silent proof-of-ownership signing (SEP-0053) ([#186](https://github.com/MetaMask/internal-snaps/pull/186)) - Add `exportAccount` keyring method for base32 Stellar secret-seed export ([#187](https://github.com/MetaMask/internal-snaps/pull/187)) - Add `TrustlineExceedLimitException` for send simulation when a payment would exceed the destination trustline limit (previously a generic `TransactionValidationException`) ([#185](https://github.com/MetaMask/internal-snaps/pull/185)) diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index a88d57e35..db70d5f20 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -371,8 +371,7 @@ describe('ConfirmSendHandler', () => { }); it('forwards memo and memoType into createValidatedSendTransaction', async () => { - const { handler, onChainAccount, createValidatedSendTransaction } = - setup(); + const { handler, onChainAccount, createValidatedSendTransaction } = setup(); await handler.handle( baseRequest({ memo: 'deposit-ref', memoType: 'text' }), From 5a4f08ecd85e1c7ae0935c8b2f98e1e69b09b7c0 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 8 Sep 2026 17:18:38 +0200 Subject: [PATCH 9/9] chore: lint --- .../src/handlers/clientRequest/confirmSend.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index db70d5f20..d7698119f 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -222,7 +222,12 @@ describe('ConfirmSendHandler', () => { overrides: Partial< Pick< ConfirmSendJsonRpcRequest['params'], - 'fromAccountId' | 'toAddress' | 'assetId' | 'amount' + | 'fromAccountId' + | 'toAddress' + | 'assetId' + | 'amount' + | 'memo' + | 'memoType' > > = {}, ) {