diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 47bd0888..80b261c0 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/docs/use-cases/client-request/confirmSend.md b/packages/stellar-wallet-snap/docs/use-cases/client-request/confirmSend.md index 38600d82..748c7271 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 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** @@ -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 9411b998..3d9a7304 100644 --- a/packages/stellar-wallet-snap/src/api/index.ts +++ b/packages/stellar-wallet-snap/src/api/index.ts @@ -6,3 +6,9 @@ export * from './json'; export * from './integer'; export * from './xdr'; export * from './transactionHash'; +export { + StellarMemoType, + StellarMemoTypeStruct, + StellarMemoValueStruct, +} from './string'; +export type { StellarMemoValue } from './string'; 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 00000000..46d9cb7c --- /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/api/string.ts b/packages/stellar-wallet-snap/src/api/string.ts index 868ed4f2..61ad6a3b 100644 --- a/packages/stellar-wallet-snap/src/api/string.ts +++ b/packages/stellar-wallet-snap/src/api/string.ts @@ -1,5 +1,5 @@ import type { Infer } from '@metamask/superstruct'; -import { refine, string } from '@metamask/superstruct'; +import { enums, refine, string } from '@metamask/superstruct'; /** * Validation struct for a UTF-8 string. @@ -16,3 +16,52 @@ export const Utf8StringStruct = refine(string(), 'utf8', (value) => { }); export type Utf8String = Infer; + +/** + * 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 StellarMemoType = { + Text: 'text', + Id: 'id', + Hash: 'hash', + Return: 'return', +} as const; + +/** + * Wire-layer validation — values taken from {@link StellarMemoType}. + */ +export const StellarMemoTypeStruct = enums([ + 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 / + * 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(), + '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 d655873d..cb0f979a 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.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index b04c0f69..447e93b2 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/api.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index 63733454..2147d44c 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.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index c0201f58..d7698119 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' > > = {}, ) { @@ -370,6 +375,24 @@ 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/handlers/clientRequest/confirmSend.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 4d9d0dbf..b517cf65 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -116,7 +116,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< ): Promise { try { const { onChainAccount, account: stellarKeyringAccount } = resolved; - const { amount, toAddress, assetId, scope } = request.params; + const { amount, toAddress, assetId, scope, memo, memoType } = + request.params; const assetMetadata = await this.#assetMetadataService.resolve(assetId); const { decimals, symbol } = assetMetadata.units[0]; @@ -141,6 +142,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< assetId, amount: amountInSmallestUnit, destination: toAddress, + memo, + memoType, }); } catch (error: unknown) { if (error instanceof TransactionValidationException) { @@ -285,7 +288,7 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< transaction: Transaction; }> { const { request, confirmedTransaction, amount } = params; - const { assetId, toAddress, scope } = 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); @@ -297,6 +300,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 831dec05..0b0dc5cb 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 dc601372..931be5da 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, + 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 9b08c083..359f230d 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,88 @@ 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('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', () => { diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts index 2c25b51e..24016825 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.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index 45e3d218..490be716 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/TransactionService.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index bb9383ee..d2d25846 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 f1d557c0..c78ffad9 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/index.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/index.ts @@ -1,5 +1,10 @@ export * from './OperationMapper'; export * from './exceptions'; +export { + StellarMemoType, + inferStellarMemoType, + resolveStellarMemo, +} 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 00000000..f5172692 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.test.ts @@ -0,0 +1,100 @@ +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 hashHex = 'a'.repeat(64); + expect( + resolveStellarMemo({ value: hashHex, type: StellarMemoType.Hash }), + ).toStrictEqual(Memo.hash(hashHex)); + expect( + resolveStellarMemo({ value: hashHex, type: StellarMemoType.Return }), + ).toStrictEqual(Memo.return(hashHex)); + }); + + 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'); + }); + + 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'); + }); +}); 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 00000000..8c02ebaf --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/transaction/memo.ts @@ -0,0 +1,117 @@ +import { Memo } from '@stellar/stellar-sdk'; + +import { StellarMemoType } from '../../api/string'; +import { STELLAR_TEXT_MEMO_MAX_BYTES } from '../../constants'; + +export { StellarMemoType } from '../../api/string'; + +/** + * 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`. + */ + +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`, + ); + } +}