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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/stellar-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions packages/stellar-wallet-snap/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
35 changes: 35 additions & 0 deletions packages/stellar-wallet-snap/src/api/string.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
51 changes: 50 additions & 1 deletion packages/stellar-wallet-snap/src/api/string.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,3 +16,52 @@ export const Utf8StringStruct = refine(string(), 'utf8', (value) => {
});

export type Utf8String = Infer<typeof Utf8StringStruct>;

/**
* 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<typeof StellarMemoTypeStruct>;

/**
* 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<typeof StellarMemoValueStruct>;
7 changes: 7 additions & 0 deletions packages/stellar-wallet-snap/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
ValidAmountStruct,
ValidStellarAmountStruct,
SwapTransactionXdrStruct,
StellarMemoTypeStruct,
StellarMemoValueStruct,
} from '../../api';
import { isSep41Id } from '../../utils';
import { parseProofOfOwnershipMessage } from './utils';
Expand Down Expand Up @@ -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),
});

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,12 @@ describe('ConfirmSendHandler', () => {
overrides: Partial<
Pick<
ConfirmSendJsonRpcRequest['params'],
'fromAccountId' | 'toAddress' | 'assetId' | 'amount'
| 'fromAccountId'
| 'toAddress'
| 'assetId'
| 'amount'
| 'memo'
| 'memoType'
>
> = {},
) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler<
): Promise<ConfirmSendJsonRpcResponse> {
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];

Expand All @@ -141,6 +142,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler<
assetId,
amount: amountInSmallestUnit,
destination: toAddress,
memo,
memoType,
});
} catch (error: unknown) {
if (error instanceof TransactionValidationException) {
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ describe('ConfirmationTransactionRefresher', () => {
assetId: sendRequest.params.assetId,
destination: toAddress,
amount: expect.anything(),
memo: undefined,
memoType: undefined,
});
expect(result).toStrictEqual({
result: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading