From c5a2db703284dd6945fa28825ad9faa462b5f45e Mon Sep 17 00:00:00 2001 From: XxHugheadxX Date: Mon, 27 Jul 2026 18:28:34 -0400 Subject: [PATCH] feat: add SDK transaction memo validation utility (#240) --- docs/error-handling.md | 8 +- docs/getting-started.md | 4 +- docs/issued-asset-payments.md | 4 +- docs/memo-validation.md | 123 +++++++++++ src/errors/codes.ts | 9 + src/index.ts | 11 + src/payments/index.ts | 16 +- src/payments/preview.ts | 8 +- src/payments/validation.ts | 4 +- src/transactions/offline-preparation.ts | 7 +- src/types/index.ts | 55 ++++- src/utils/index.ts | 11 + src/utils/memo.ts | 227 ++++++++++++++++++++ tests/exports.test.ts | 4 + tests/memo-validation.test.ts | 265 ++++++++++++++++++++++++ 15 files changed, 729 insertions(+), 27 deletions(-) create mode 100644 docs/memo-validation.md create mode 100644 src/utils/memo.ts create mode 100644 tests/memo-validation.test.ts diff --git a/docs/error-handling.md b/docs/error-handling.md index d62cf37..9ce3f6e 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -34,7 +34,11 @@ These errors are thrown locally before any network requests are made. * `INVALID_SECRET_KEY`: The provided secret key is not a valid Stellar private key (must start with `S` and be 56 characters). * `INVALID_AMOUNT`: The amount is not a positive number or is formatted incorrectly. * `INVALID_AMOUNT_PRECISION`: The amount exceeds the maximum Stellar precision of 7 decimal places (e.g., `1.12345678`). -* `INVALID_MEMO`: The transaction memo text exceeds the Stellar limit of 28 bytes. +* `TX_INVALID_MEMO`: The transaction memo is invalid — a text memo over 28 bytes, an + `id` memo that is not an unsigned 64-bit integer, or a `hash`/`return` memo that is + not 64 hex characters. `validation.reason` says which rule was broken. Replaces the + former unregistered `INVALID_MEMO` string on the throwing path — see + [Memo Validation](./memo-validation.md). * `SELF_PAYMENT`: The source account and destination account are identical. ### 2. Stellar Network & Horizon Errors @@ -162,7 +166,7 @@ When building customer-facing interfaces, translate machine-readable SDK error c | `INVALID_SECRET_KEY` | "The secret key is invalid. Please verify and try again." | | `INVALID_AMOUNT` | "Please enter a positive numeric amount." | | `INVALID_AMOUNT_PRECISION`| "Amounts cannot have more than 7 decimal places." | -| `INVALID_MEMO` | "Memo is too long. Please shorten it to 28 characters or fewer." | +| `TX_INVALID_MEMO` | "The transaction memo is invalid." | | `SELF_PAYMENT` | "You cannot send payments to your own account." | | `ACCOUNT_NOT_FOUND` | "This account is inactive. Fund it with XLM first to activate it." | | `PAYMENT_FAILED` | "Transaction failed. Please ensure you have sufficient balance and network fees." | diff --git a/docs/getting-started.md b/docs/getting-started.md index 8e67d07..72b54d7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -178,7 +178,9 @@ To send XLM from one account to another, use the `sendXLM` function. You will ne 1. The **Secret Key** of the sending account to sign the transaction. 2. The **Public Key** of the receiving account. 3. The **Amount** of XLM to send (as a string to avoid floating-point inaccuracies). -4. An optional **Memo** (maximum 28 bytes) to attach a short message or ID to the transaction. +4. An optional **Memo** to attach a short message or ID to the transaction. A plain + string is a `text` memo (maximum 28 bytes); `id`, `hash` and `return` memos are + also supported — see [Memo Validation](./memo-validation.md). > [!NOTE] > The destination account must already exist on-chain (be funded) before a standard payment transaction can succeed. diff --git a/docs/issued-asset-payments.md b/docs/issued-asset-payments.md index 0792bdc..9eeab14 100644 --- a/docs/issued-asset-payments.md +++ b/docs/issued-asset-payments.md @@ -207,7 +207,7 @@ All validation runs synchronously before any network call: | `sourceSecret` | Valid Stellar secret key (`S...`) | `INVALID_SECRET_KEY` | | `destination` | Valid Stellar public key (`G...`) | `INVALID_PUBLIC_KEY` | | `amount` | Positive decimal string (`> 0`) | `INVALID_AMOUNT` | -| `memo` | ≤ 28 bytes (if provided) | `INVALID_MEMO` | +| `memo` | Text ≤ 28 bytes, or a typed memo (`id`/`hash`/`return`) | `TX_INVALID_MEMO` | | `asset.code` | `XLM` / `native` for native; 1–12 alphanum for issued | `INVALID_ASSET_CODE` | | `asset.issuer` | Required & valid `G...` for issued assets | `MISSING_ASSET_ISSUER` / `INVALID_PUBLIC_KEY` | | Source ≠ Destination | Cannot send to yourself | `SELF_PAYMENT` | @@ -224,7 +224,7 @@ All errors thrown by `sendAsset` (and surfaced by `safeSendAsset`) are | `INVALID_SECRET_KEY` | Malformed source secret key | Fix the key format | | `INVALID_PUBLIC_KEY` | Malformed destination or issuer key | Fix the key format | | `INVALID_AMOUNT` | Amount ≤ 0 or non-numeric | Use a positive decimal string | -| `INVALID_MEMO` | Memo exceeds 28 bytes | Shorten the memo | +| `TX_INVALID_MEMO` | Memo breaks its type's format rule | See [Memo Validation](./memo-validation.md) | | `INVALID_ASSET_CODE` | Asset code too long or contains symbols | 1–12 alphanumeric chars | | `MISSING_ASSET_ISSUER` | Issued asset without issuer public key | Provide the issuer key | | `INVALID_ASSET` | Native asset has a spurious issuer | Remove the `issuer` field | diff --git a/docs/memo-validation.md b/docs/memo-validation.md new file mode 100644 index 0000000..2c9e517 --- /dev/null +++ b/docs/memo-validation.md @@ -0,0 +1,123 @@ +# Transaction Memo Validation + +The SDK validates memos consistently across every payment and transaction +helper. This page documents the rules for each Stellar memo type, the error you +get when a memo is rejected, and how to migrate from plain-string memos. + +## Memo types + +Stellar defines five memo types. All five are validated; four carry a payload. + +| Type | Payload | Rule | +| --- | --- | --- | +| `none` | — | No memo is attached. | +| `text` | UTF-8 string | Up to **28 bytes**. Multi-byte characters count for more than one byte each. | +| `id` | unsigned 64-bit integer | Decimal digits only, `0` to `18446744073709551615` (2⁶⁴−1). Accepts a string, number, or bigint. | +| `hash` | 32 bytes | Exactly **64 hexadecimal characters**. Case-insensitive. | +| `return` | 32 bytes | Exactly **64 hexadecimal characters**. Case-insensitive. | + +`undefined` and the empty string both mean "no memo" and are always valid — +memos are optional on every SDK operation that accepts one. + +## Passing a memo + +Anywhere a memo is accepted you may pass either a plain string or a typed +`MemoInput`. A plain string is treated as a `text` memo, which is what it has +always meant, so existing code needs no changes. + +```ts +import { sendXLM } from 'stellar-pocketpay-sdk'; + +// text — the plain-string form, unchanged +await sendXLM({ sourceSecret, destination, amount: '10', memo: 'invoice #42' }); + +// text — the explicit form, identical result +await sendXLM({ ...params, memo: { type: 'text', value: 'invoice #42' } }); + +// id — an unsigned 64-bit integer, commonly used by exchanges +await sendXLM({ ...params, memo: { type: 'id', value: '1234567890' } }); + +// hash / return — 32 bytes as 64 hex characters +await sendXLM({ ...params, memo: { type: 'hash', value: 'a1b2...' } }); + +// none — explicitly no memo +await sendXLM({ ...params, memo: { type: 'none' } }); +``` + +Memos are validated by `sendXLM`, `sendAsset`, `previewPayment`, +`validateSendXLMParams`, and the offline transaction preparation helpers. + +## Validation helpers + +| Helper | Behaviour | +| --- | --- | +| `validateMemoInput(memo)` | Returns `true`, or throws `PocketPayError` with code `TX_INVALID_MEMO`. | +| `safeValidateMemo(memo)` | Non-throwing: returns `{ valid: true }` or `{ valid: false, error }`. | +| `normalizeMemo(memo)` | Converts a string or `MemoInput` into a `MemoInput`, or `undefined` for no memo. | +| `buildMemo(memo)` | Validates, then returns the Stellar `Memo` to attach — or `undefined` for no memo. | +| `validateMemo(text)` | **Legacy.** Text-only 28-byte check. Kept for backwards compatibility. | + +```ts +import { safeValidateMemo } from 'stellar-pocketpay-sdk'; + +const result = safeValidateMemo({ type: 'id', value: 'not-a-number' }); +if (!result.valid) { + console.error(result.error.code); // 'TX_INVALID_MEMO' + console.error(result.error.validation?.reason); // 'not_unsigned_integer' +} +``` + +## Errors + +Every rejection is a `PocketPayError` with code `TX_INVALID_MEMO`, part of the +[published error standard](./error-standard.md), so `isKnownErrorCode()` +recognises it and `describeError()` returns real guidance rather than the +generic unknown-code fallback. + +The `validation.reason` field says which rule was broken: + +| `reason` | Meaning | +| --- | --- | +| `unsupported_type` | The `type` is not one of the five Stellar memo types. | +| `too_long` | A `text` memo exceeds 28 bytes. | +| `not_unsigned_integer` | An `id` memo is negative, fractional, or not numeric. | +| `out_of_range` | An `id` memo exceeds 2⁶⁴−1. | +| `invalid_length` | A `hash` or `return` memo is not 64 hex characters. | +| `not_hexadecimal` | A `hash` or `return` memo contains non-hex characters. | +| `invalid_type` | The payload type does not match the memo type. | +| `invalid_shape` | The memo is neither a string nor a `{ type, value }` object. | + +Reasons are distinct so callers can tell an unsupported *format* from a payload +that is merely too long — previously both surfaced as "Memo text exceeds +28-byte limit". + +## Previews + +`previewPayment` reports the memo alongside its type, mirroring how +`TransactionSummary` exposes `memo` and `memoType` for transactions read back +from Horizon: + +```ts +const preview = await previewPayment({ ...params, memo: { type: 'id', value: '12345' } }); +preview.memo; // '12345' +preview.memoType; // 'id' +``` + +## Migration + +Nothing is required. Plain-string memos keep working and keep meaning `text`. + +- `memo?: string` widened to `memo?: string | MemoInput` on `SendXLMParams`, + `SendAssetParams`, `PaymentPreviewParams`, and the offline preparation params. + This is additive. +- `PaymentPreview` gained an optional `memoType`. Its `memo` field is still a + string. +- Memo failures now report `TX_INVALID_MEMO` instead of the unregistered + `INVALID_MEMO` string. Consumers branching on the old value should switch to + the published code; the thrown value is still a `PocketPayError` and the + text-memo message is unchanged. + +## See also + +- [Error Standard](./error-standard.md) — the published error code registry. +- [Getting Started](./getting-started.md) — sending your first payment. diff --git a/src/errors/codes.ts b/src/errors/codes.ts index 2e59dca..bebf2d0 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -57,6 +57,7 @@ export const ErrorCode = { TX_UNSIGNED: 'TX_UNSIGNED', TX_SIGNING_DENIED: 'TX_SIGNING_DENIED', TX_BUILD_FAILED: 'TX_BUILD_FAILED', + TX_INVALID_MEMO: 'TX_INVALID_MEMO', // ─── Network ────────────────────────────────────────────────────────────── NET_RATE_LIMITED: 'NET_RATE_LIMITED', @@ -228,6 +229,14 @@ export const ERROR_CODES: Record = { safeMessage: 'Failed to build the transaction.', developerHint: 'Check operation params, sequence number, and asset specs.', }, + [ErrorCode.TX_INVALID_MEMO]: { + category: ErrorCategory.Transaction, + retryable: false, + safeMessage: 'The transaction memo is invalid.', + developerHint: + 'Text memos are limited to 28 bytes; id memos are unsigned 64-bit integers; ' + + 'hash and return memos are 64 hex characters.', + }, [ErrorCode.NET_RATE_LIMITED]: { category: ErrorCategory.Network, diff --git a/src/index.ts b/src/index.ts index f891808..f7e0ca0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,8 @@ export type { AssetBalance, AccountBalance, BalanceResult, + MemoType, + MemoInput, SendXLMParams, SendAssetParams, PaymentPreviewParams, @@ -261,6 +263,15 @@ export { validateSecretKey, validateAmount, validateMemo, + // Typed memo validation (issue #240) + validateMemoInput, + safeValidateMemo, + normalizeMemo, + buildMemo, + MEMO_TEXT_MAX_BYTES, + MEMO_HASH_HEX_LENGTH, + MEMO_ID_MAX, + SUPPORTED_MEMO_TYPES, validateTransactionHash, stroopsToXLM, xlmToStroops, diff --git a/src/payments/index.ts b/src/payments/index.ts index 4319dea..1c0cba9 100644 --- a/src/payments/index.ts +++ b/src/payments/index.ts @@ -6,7 +6,7 @@ import * as StellarSDK from '@stellar/stellar-sdk'; import { getHorizonServer, getNetworkPassphrase, resolveConfig } from '../config'; import { SendXLMParams, SendAssetParams, PaymentResult, PocketPayError, SDKConfig, PocketPayResult, EnhancedPocketPayResult } from '../types'; -import { validateSecretKey, validatePublicKey, validateAmount, validateMemo, wrapError, toResult, toEnhancedSuccessResult, toEnhancedFailureResult, toEnhancedResult } from '../utils'; +import { validateSecretKey, validatePublicKey, validateAmount, validateMemoInput, buildMemo, wrapError, toResult, toEnhancedSuccessResult, toEnhancedFailureResult, toEnhancedResult } from '../utils'; import type { ResultWarning, RecoveryHint } from '../errors'; import { withTimeout } from '../network'; import { validateAssetSpec, verifyPaymentTrustlineOrThrow } from './trustline'; @@ -33,7 +33,7 @@ export async function sendXLM( validateSecretKey(sourceSecret); validatePublicKey(destination); validateAmount(amount); - validateMemo(memo); + validateMemoInput(memo); const sourceKeypair = StellarSDK.Keypair.fromSecret(sourceSecret); const sourcePublic = sourceKeypair.publicKey(); if (sourcePublic === destination) { @@ -66,8 +66,9 @@ export async function sendXLM( amount, }) ); - if (memo) { - builder.addMemo(StellarSDK.Memo.text(memo)); + const builtMemo = buildMemo(memo); + if (builtMemo) { + builder.addMemo(builtMemo); } builder.setTimeout(30); const transaction = builder.build(); @@ -304,7 +305,7 @@ export async function sendAsset( validateSecretKey(sourceSecret); validatePublicKey(destination); validateAmount(amount); - validateMemo(memo); + validateMemoInput(memo); validateAssetSpec(asset); const sourceKeypair = StellarSDK.Keypair.fromSecret(sourceSecret); @@ -354,8 +355,9 @@ export async function sendAsset( }), ); - if (memo) { - builder.addMemo(StellarSDK.Memo.text(memo)); + const builtMemo = buildMemo(memo); + if (builtMemo) { + builder.addMemo(builtMemo); } builder.setTimeout(30); diff --git a/src/payments/preview.ts b/src/payments/preview.ts index 1637a0e..618cd1d 100644 --- a/src/payments/preview.ts +++ b/src/payments/preview.ts @@ -1,7 +1,7 @@ import * as StellarSDK from '@stellar/stellar-sdk'; import { SDKConfig, PaymentPreviewParams, PaymentPreview } from '../types'; import { resolveConfig } from '../config'; -import { validatePublicKey, validateAmount, validateMemo } from '../utils'; +import { validatePublicKey, validateAmount, validateMemoInput, normalizeMemo } from '../utils'; import { validateAssetSpec } from './trustline'; /** @@ -26,7 +26,8 @@ export async function previewPayment( validatePublicKey(sourceAccount); validatePublicKey(destination); validateAmount(amount); - validateMemo(memo); + validateMemoInput(memo); + const normalizedMemo = normalizeMemo(memo); const finalAsset = asset || { code: 'XLM' }; validateAssetSpec(finalAsset); @@ -38,7 +39,8 @@ export async function previewPayment( destination, amount, asset: finalAsset, - memo, + memo: normalizedMemo ? String(normalizedMemo.value ?? '') : undefined, + memoType: normalizedMemo?.type, network: cfg.network, estimatedFee: StellarSDK.BASE_FEE.toString(), // Hardcoded to Stellar base fee (100 stroops) }; diff --git a/src/payments/validation.ts b/src/payments/validation.ts index 64be653..1264c90 100644 --- a/src/payments/validation.ts +++ b/src/payments/validation.ts @@ -16,7 +16,7 @@ import * as StellarSDK from '@stellar/stellar-sdk'; import { PocketPayError, SendXLMParams } from '../types'; import { validateAmount, - validateMemo, + validateMemoInput, validatePublicKey, validateSecretKey, } from '../utils'; @@ -115,7 +115,7 @@ export function validateSendXLMParams( // 4. Memo length (optional field; missing memo is always valid). try { - validateMemo(params.memo); + validateMemoInput(params.memo); } catch (err) { errors.push(toValidationError(err, 'memo', 'INVALID_MEMO')); } diff --git a/src/transactions/offline-preparation.ts b/src/transactions/offline-preparation.ts index f9eaca3..020a9e9 100644 --- a/src/transactions/offline-preparation.ts +++ b/src/transactions/offline-preparation.ts @@ -46,7 +46,7 @@ import { SDKConfig, PocketPayResult, } from '../types'; -import { validatePublicKey, validateSecretKey, validateAmount, validateMemo, wrapError, toResult } from '../utils'; +import { validatePublicKey, validateSecretKey, validateAmount, validateMemoInput, buildMemo, wrapError, toResult } from '../utils'; import { withTimeout } from '../network'; // ─── Type Definitions ─────────────────────────────────────────────────────────── @@ -200,7 +200,7 @@ export function prepareTransactionOffline( validateAssetSpecOffline(op.asset); } - validateMemo(params.memo); + validateMemoInput(params.memo); const cfg = resolveConfig(config); const networkPassphrase = getNetworkPassphrase(cfg.network); @@ -416,7 +416,8 @@ export function buildUnsignedTransaction( // Add memo if provided if (prepared.memo) { - builder.addMemo(StellarSDK.Memo.text(prepared.memo)); + const preparedMemo = buildMemo(prepared.memo); + if (preparedMemo) builder.addMemo(preparedMemo); } const transaction = builder.build(); diff --git a/src/types/index.ts b/src/types/index.ts index fa0bb3e..d0892da 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -117,6 +117,41 @@ export type BalanceResult = publicKey: string; }; +// ─── Memo ─────────────────────────────────────────────────────────────────── + +/** + * The memo types defined by the Stellar protocol. + * + * - `none` — no memo + * - `text` — up to 28 **bytes** of UTF-8 + * - `id` — unsigned 64-bit integer + * - `hash` — 32 bytes, supplied as 64 hex characters + * - `return` — 32 bytes, supplied as 64 hex characters + */ +export type MemoType = 'none' | 'text' | 'id' | 'hash' | 'return'; + +/** + * A memo with an explicit type. + * + * Anywhere a memo is accepted you may pass a plain `string`, which is treated + * as a `text` memo — the behaviour every caller had before typed memos existed. + * + * @example + * ```ts + * await sendXLM({ ...params, memo: 'invoice #42' }); // text + * await sendXLM({ ...params, memo: { type: 'id', value: '12345' } }); // id + * ``` + */ +export interface MemoInput { + /** Which Stellar memo type to build. */ + type: MemoType; + /** + * The payload. Required for every type except `none`. `id` accepts a + * decimal string, number, or bigint; `hash` and `return` take 64 hex chars. + */ + value?: string | number | bigint; +} + // ─── Payments ─────────────────────────────────────────────────────────────── /** Parameters for sending an XLM payment */ @@ -127,8 +162,8 @@ export interface SendXLMParams { destination: string; /** Amount of XLM to send (as string for precision, e.g. "10.5") */ amount: string; - /** Optional memo text (max 28 bytes) */ - memo?: string; + /** Optional memo: text (max 28 bytes) or a typed {@link MemoInput} */ + memo?: string | MemoInput; } /** @@ -175,8 +210,8 @@ export interface SendAssetParams { * For issued assets supply both `code` and `issuer`. */ asset: StellarAssetSpec; - /** Optional memo text (max 28 bytes) */ - memo?: string; + /** Optional memo: text (max 28 bytes) or a typed {@link MemoInput} */ + memo?: string | MemoInput; /** * When `true`, a preflight trustline check is run against Horizon before * building the transaction. Defaults to `true` for issued assets; @@ -198,8 +233,8 @@ export interface PaymentPreviewParams { * For issued assets supply both `code` and `issuer`. */ asset?: StellarAssetSpec; - /** Optional memo text (max 28 bytes) */ - memo?: string; + /** Optional memo: text (max 28 bytes) or a typed {@link MemoInput} */ + memo?: string | MemoInput; } /** Typed preview of a payment */ @@ -212,8 +247,14 @@ export interface PaymentPreview { amount: string; /** Asset to be sent */ asset: StellarAssetSpec; - /** Memo text if provided */ + /** Memo payload if provided, rendered as a string */ memo?: string; + /** + * Type of the memo that will be attached. Mirrors `TransactionSummary.memoType` + * on the read side, so previews and fetched transactions describe memos the + * same way. Absent when there is no memo. + */ + memoType?: MemoType; /** Network the payment will be on */ network: string; /** Estimated base fee in stroops */ diff --git a/src/utils/index.ts b/src/utils/index.ts index 3a08dae..ac2e23c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -482,3 +482,14 @@ export { } from './explorer'; export * from './assetHelpers'; +// ─── Memo ──────────────────────────────────────────────────────────────── +export { + validateMemoInput, + safeValidateMemo, + normalizeMemo, + buildMemo, + MEMO_TEXT_MAX_BYTES, + MEMO_HASH_HEX_LENGTH, + MEMO_ID_MAX, + SUPPORTED_MEMO_TYPES, +} from './memo'; diff --git a/src/utils/memo.ts b/src/utils/memo.ts new file mode 100644 index 0000000..5a7a3f9 --- /dev/null +++ b/src/utils/memo.ts @@ -0,0 +1,227 @@ +/** + * memo.ts — transaction memo validation and construction. + * ────────────────────────────────────────────────────────────────────────────── + * Stellar defines five memo types, each with its own format rules: + * + * | Type | Payload | + * | -------- | -------------------------------------------- | + * | `none` | no payload | + * | `text` | up to 28 **bytes** of UTF-8 | + * | `id` | unsigned 64-bit integer | + * | `hash` | 32 bytes, supplied as 64 hex characters | + * | `return` | 32 bytes, supplied as 64 hex characters | + * + * Before this module the SDK could only build `text` memos, while it could + * already *read* `memo_type` back from Horizon (see `TransactionSummary`). + * Anything that was not text failed the text byte-length check and surfaced as + * "Memo text exceeds 28-byte limit", which described the wrong problem. + * + * Backwards compatibility: a bare `string` memo continues to mean `text` + * everywhere, so existing callers are unaffected. + */ + +import * as StellarSDK from '@stellar/stellar-sdk'; +import { PocketPayError } from '../types'; +import type { MemoInput, MemoType } from '../types'; +import { ErrorCode, ERROR_CODES } from '../errors/codes'; + +/** Maximum payload size of a `text` memo, in bytes (not characters). */ +export const MEMO_TEXT_MAX_BYTES = 28; + +/** Required length of the hex payload for `hash` and `return` memos. */ +export const MEMO_HASH_HEX_LENGTH = 64; + +/** Largest value a `id` memo can carry (2^64 - 1). */ +export const MEMO_ID_MAX = 18_446_744_073_709_551_615n; + +/** The memo types this SDK can validate and build. */ +export const SUPPORTED_MEMO_TYPES: readonly MemoType[] = [ + 'none', + 'text', + 'id', + 'hash', + 'return', +] as const; + +const HEX_PATTERN = /^[0-9a-fA-F]+$/; + +/** Builds the typed error used for every memo failure. */ +function memoError(reason: string, message: string, value?: string): PocketPayError { + const spec = ERROR_CODES[ErrorCode.TX_INVALID_MEMO]; + return new PocketPayError(message, ErrorCode.TX_INVALID_MEMO, { + category: spec.category, + safeMessage: spec.safeMessage, + validation: { field: 'memo', reason, value }, + }); +} + +/** + * Normalizes the two accepted memo shapes into a single {@link MemoInput}. + * + * A bare string is treated as a `text` memo, which is what every pre-existing + * caller means. `undefined` and the empty string mean "no memo". + * + * @param memo - A memo string, a structured memo, or undefined + * @returns The normalized memo, or `undefined` when there is no memo + */ +export function normalizeMemo(memo?: string | MemoInput): MemoInput | undefined { + if (memo === undefined || memo === null) return undefined; + + if (typeof memo === 'string') { + if (memo.length === 0) return undefined; + return { type: 'text', value: memo }; + } + + if (typeof memo !== 'object') { + throw memoError( + 'invalid_shape', + `Invalid memo: expected a string or { type, value } object, got ${typeof memo}.` + ); + } + + return memo; +} + +/** + * Validates a memo of any supported type. + * + * Unlike the legacy `validateMemo`, this reports *which* rule was broken — + * an unsupported type is reported as such rather than as a length problem. + * + * @param memo - A memo string, a structured memo, or undefined for no memo + * @returns true when the memo is valid (including "no memo") + * @throws PocketPayError with code `TX_INVALID_MEMO` when the memo is invalid + */ +export function validateMemoInput(memo?: string | MemoInput): boolean { + const normalized = normalizeMemo(memo); + if (!normalized) return true; + + const { type, value } = normalized; + + if (!SUPPORTED_MEMO_TYPES.includes(type)) { + throw memoError( + 'unsupported_type', + `Unsupported memo type: "${type}". Supported types: ${SUPPORTED_MEMO_TYPES.join(', ')}.`, + String(type) + ); + } + + switch (type) { + case 'none': + return true; + + case 'text': { + if (typeof value !== 'string') { + throw memoError('invalid_type', 'A text memo requires a string value.'); + } + const byteLength = Buffer.byteLength(value, 'utf-8'); + if (byteLength > MEMO_TEXT_MAX_BYTES) { + throw memoError( + 'too_long', + `Memo text exceeds ${MEMO_TEXT_MAX_BYTES}-byte limit (got ${byteLength} bytes): "${value}"`, + value + ); + } + return true; + } + + case 'id': { + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') { + throw memoError('invalid_type', 'An id memo requires a string, number, or bigint value.'); + } + const raw = String(value).trim(); + if (!/^\d+$/.test(raw)) { + throw memoError( + 'not_unsigned_integer', + `An id memo must be an unsigned 64-bit integer, got "${raw}".`, + raw + ); + } + if (BigInt(raw) > MEMO_ID_MAX) { + throw memoError( + 'out_of_range', + `An id memo must not exceed ${MEMO_ID_MAX} (2^64 - 1), got "${raw}".`, + raw + ); + } + return true; + } + + case 'hash': + case 'return': { + if (typeof value !== 'string') { + throw memoError('invalid_type', `A ${type} memo requires a hex string value.`); + } + if (value.length !== MEMO_HASH_HEX_LENGTH) { + throw memoError( + 'invalid_length', + `A ${type} memo must be ${MEMO_HASH_HEX_LENGTH} hex characters (32 bytes), ` + + `got ${value.length}.`, + value + ); + } + if (!HEX_PATTERN.test(value)) { + throw memoError( + 'not_hexadecimal', + `A ${type} memo must contain only hexadecimal characters.`, + value + ); + } + return true; + } + + /* c8 ignore next 2 -- unreachable: the guard above rejects unknown types */ + default: + return true; + } +} + +/** + * Validates a memo and returns the matching Stellar `Memo`. + * + * Returns `undefined` when there is no memo, so callers can skip `addMemo`. + * + * @param memo - A memo string, a structured memo, or undefined + * @returns The Stellar memo to attach, or undefined for no memo + * @throws PocketPayError with code `TX_INVALID_MEMO` when the memo is invalid + */ +export function buildMemo(memo?: string | MemoInput): StellarSDK.Memo | undefined { + const normalized = normalizeMemo(memo); + if (!normalized) return undefined; + + validateMemoInput(normalized); + + switch (normalized.type) { + case 'none': + return undefined; + case 'text': + return StellarSDK.Memo.text(String(normalized.value)); + case 'id': + return StellarSDK.Memo.id(String(normalized.value).trim()); + case 'hash': + return StellarSDK.Memo.hash(String(normalized.value)); + case 'return': + return StellarSDK.Memo.return(String(normalized.value)); + /* c8 ignore next 2 -- unreachable: validateMemoInput rejects unknown types */ + default: + return undefined; + } +} + +/** + * Non-throwing form of {@link validateMemoInput}. + * + * @param memo - A memo string, a structured memo, or undefined + * @returns `{ valid: true }`, or `{ valid: false, error }` with the typed error + */ +export function safeValidateMemo( + memo?: string | MemoInput +): { valid: true } | { valid: false; error: PocketPayError } { + try { + validateMemoInput(memo); + return { valid: true }; + } catch (error) { + if (error instanceof PocketPayError) return { valid: false, error }; + throw error; + } +} diff --git a/tests/exports.test.ts b/tests/exports.test.ts index faefab1..a7ed813 100644 --- a/tests/exports.test.ts +++ b/tests/exports.test.ts @@ -62,6 +62,10 @@ utilities: [ 'xlmToStroops', 'truncateAddress', 'validateMemo', +'validateMemoInput', +'safeValidateMemo', +'normalizeMemo', +'buildMemo', 'toSuccessResult', 'toFailureResult', 'toResult', diff --git a/tests/memo-validation.test.ts b/tests/memo-validation.test.ts new file mode 100644 index 0000000..2b2cbee --- /dev/null +++ b/tests/memo-validation.test.ts @@ -0,0 +1,265 @@ +/** + * Typed memo validation tests (issue #240). + * + * The SDK previously validated only `text` memos, while it could already read + * `memo_type` back from Horizon. Anything that was not text failed the text + * byte-length check and reported "Memo text exceeds 28-byte limit", which + * described the wrong problem. These tests cover all five Stellar memo types, + * the typed error, and backwards compatibility of the plain-string form. + */ + +import { describe, it, expect } from 'vitest'; +import * as StellarSDK from '@stellar/stellar-sdk'; +import { + validateMemoInput, + safeValidateMemo, + normalizeMemo, + buildMemo, + validateMemo, + MEMO_TEXT_MAX_BYTES, + MEMO_HASH_HEX_LENGTH, + MEMO_ID_MAX, + SUPPORTED_MEMO_TYPES, +} from '../src/utils'; +import { previewPayment } from '../src/payments'; +import { PocketPayError } from '../src/types'; +import type { MemoInput } from '../src/types'; +import { ErrorCode, isKnownErrorCode, describeError, ErrorCategory } from '../src/errors'; + +const HEX64 = 'a'.repeat(MEMO_HASH_HEX_LENGTH); +const PUBLIC_KEY = StellarSDK.Keypair.random().publicKey(); + +/** Extracts the thrown PocketPayError so assertions can inspect it. */ +const capture = (fn: () => unknown): PocketPayError => { + try { + fn(); + } catch (error) { + return error as PocketPayError; + } + throw new Error('expected the call to throw'); +}; + +describe('normalizeMemo', () => { + it('treats a plain string as a text memo', () => { + expect(normalizeMemo('invoice #42')).toEqual({ type: 'text', value: 'invoice #42' }); + }); + + it('treats undefined and the empty string as no memo', () => { + expect(normalizeMemo(undefined)).toBeUndefined(); + expect(normalizeMemo('')).toBeUndefined(); + }); + + it('passes a structured memo through unchanged', () => { + const memo: MemoInput = { type: 'id', value: '42' }; + expect(normalizeMemo(memo)).toBe(memo); + }); +}); + +describe('validateMemoInput — supported types', () => { + it('accepts no memo', () => { + expect(validateMemoInput(undefined)).toBe(true); + expect(validateMemoInput('')).toBe(true); + expect(validateMemoInput({ type: 'none' })).toBe(true); + }); + + it('accepts a text memo at exactly the byte limit', () => { + expect(validateMemoInput({ type: 'text', value: 'a'.repeat(MEMO_TEXT_MAX_BYTES) })).toBe(true); + }); + + it('accepts id memos as string, number and bigint', () => { + expect(validateMemoInput({ type: 'id', value: '12345' })).toBe(true); + expect(validateMemoInput({ type: 'id', value: 12345 })).toBe(true); + expect(validateMemoInput({ type: 'id', value: 12345n })).toBe(true); + expect(validateMemoInput({ type: 'id', value: '0' })).toBe(true); + expect(validateMemoInput({ type: 'id', value: String(MEMO_ID_MAX) })).toBe(true); + }); + + it('accepts hash and return memos of 64 hex characters', () => { + expect(validateMemoInput({ type: 'hash', value: HEX64 })).toBe(true); + expect(validateMemoInput({ type: 'return', value: HEX64.toUpperCase() })).toBe(true); + }); + + it('exposes every Stellar memo type as supported', () => { + expect([...SUPPORTED_MEMO_TYPES].sort()).toEqual( + ['hash', 'id', 'none', 'return', 'text'].sort() + ); + }); +}); + +describe('validateMemoInput — rejected input', () => { + it('rejects a text memo one byte over the limit', () => { + const err = capture(() => + validateMemoInput({ type: 'text', value: 'a'.repeat(MEMO_TEXT_MAX_BYTES + 1) }) + ); + expect(err.validation?.reason).toBe('too_long'); + }); + + it('measures text memos in bytes, not characters', () => { + // 15 multi-byte characters are well under 28 characters but over 28 bytes. + expect(() => validateMemoInput({ type: 'text', value: 'á'.repeat(15) })).toThrow(PocketPayError); + expect(validateMemoInput({ type: 'text', value: 'á'.repeat(14) })).toBe(true); + }); + + it('rejects negative, fractional and non-numeric id memos', () => { + expect(capture(() => validateMemoInput({ type: 'id', value: '-1' })).validation?.reason).toBe( + 'not_unsigned_integer' + ); + expect(capture(() => validateMemoInput({ type: 'id', value: '1.5' })).validation?.reason).toBe( + 'not_unsigned_integer' + ); + expect(capture(() => validateMemoInput({ type: 'id', value: 'abc' })).validation?.reason).toBe( + 'not_unsigned_integer' + ); + }); + + it('rejects an id memo above 2^64 - 1', () => { + const err = capture(() => validateMemoInput({ type: 'id', value: (MEMO_ID_MAX + 1n).toString() })); + expect(err.validation?.reason).toBe('out_of_range'); + }); + + it('rejects hash and return memos of the wrong length', () => { + expect(capture(() => validateMemoInput({ type: 'hash', value: 'ab' })).validation?.reason).toBe( + 'invalid_length' + ); + expect( + capture(() => validateMemoInput({ type: 'return', value: HEX64 + 'a' })).validation?.reason + ).toBe('invalid_length'); + }); + + it('rejects non-hexadecimal hash memos of the right length', () => { + const err = capture(() => validateMemoInput({ type: 'hash', value: 'z'.repeat(64) })); + expect(err.validation?.reason).toBe('not_hexadecimal'); + }); +}); + +describe('unsupported formats report the right problem', () => { + it('names the unsupported type instead of a length problem', () => { + const err = capture(() => + validateMemoInput({ type: 'quantum' as unknown as MemoInput['type'], value: 'x' }) + ); + expect(err.validation?.reason).toBe('unsupported_type'); + expect(err.message).toContain('Unsupported memo type'); + expect(err.message).not.toContain('28-byte limit'); + }); + + it('reports a hash-shaped memo as a length problem for its own type, not as text', () => { + // Regression: a 64-char hex intended as MEMO_HASH used to fail the text + // byte check with "Memo text exceeds 28-byte limit", describing the wrong + // rule. Declared as a hash it now validates cleanly. + expect(validateMemoInput({ type: 'hash', value: HEX64 })).toBe(true); + expect(() => validateMemo(HEX64)).toThrow('Memo text exceeds 28-byte limit'); + }); +}); + +describe('typed errors use the published standard', () => { + it('reports TX_INVALID_MEMO, which the registry recognises', () => { + const err = capture(() => validateMemoInput({ type: 'text', value: 'a'.repeat(29) })); + + expect(err.code).toBe(ErrorCode.TX_INVALID_MEMO); + expect(isKnownErrorCode(err.code)).toBe(true); + + const described = describeError(err.code); + expect(described.known).toBe(true); + expect(described.category).toBe(ErrorCategory.Transaction); + expect(described.safeMessage).not.toBe('An unexpected error occurred.'); + }); + + it('attaches validation metadata naming the memo field', () => { + const err = capture(() => validateMemoInput({ type: 'id', value: '-1' })); + expect(err.validation?.field).toBe('memo'); + expect(err).toBeInstanceOf(PocketPayError); + }); +}); + +describe('safeValidateMemo', () => { + it('returns valid:true for an acceptable memo', () => { + expect(safeValidateMemo({ type: 'id', value: '7' })).toEqual({ valid: true }); + }); + + it('returns the typed error instead of throwing', () => { + const result = safeValidateMemo({ type: 'hash', value: 'nope' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error.code).toBe(ErrorCode.TX_INVALID_MEMO); + } + }); +}); + +describe('buildMemo', () => { + it('returns undefined when there is no memo', () => { + expect(buildMemo(undefined)).toBeUndefined(); + expect(buildMemo('')).toBeUndefined(); + expect(buildMemo({ type: 'none' })).toBeUndefined(); + }); + + it('builds the matching Stellar memo for each type', () => { + expect(buildMemo('hello')?.type).toBe(StellarSDK.MemoText); + expect(buildMemo({ type: 'text', value: 'hello' })?.type).toBe(StellarSDK.MemoText); + expect(buildMemo({ type: 'id', value: '42' })?.type).toBe(StellarSDK.MemoID); + expect(buildMemo({ type: 'hash', value: HEX64 })?.type).toBe(StellarSDK.MemoHash); + expect(buildMemo({ type: 'return', value: HEX64 })?.type).toBe(StellarSDK.MemoReturn); + }); + + it('validates before building', () => { + expect(() => buildMemo({ type: 'id', value: 'not-a-number' })).toThrow(PocketPayError); + }); +}); + +describe('backwards compatibility', () => { + it('leaves the original validateMemo behaviour untouched', () => { + expect(validateMemo(undefined)).toBe(true); + expect(validateMemo('')).toBe(true); + expect(validateMemo('short')).toBe(true); + expect(() => validateMemo('a'.repeat(29))).toThrow('Memo text exceeds 28-byte limit'); + }); + + it('accepts a plain string memo wherever a memo is taken', () => { + expect(validateMemoInput('invoice #42')).toBe(true); + expect(buildMemo('invoice #42')?.type).toBe(StellarSDK.MemoText); + }); +}); + +describe('payment preview reports the memo type', () => { + it('describes a plain string memo as text', async () => { + const preview = await previewPayment({ + sourceAccount: PUBLIC_KEY, + destination: StellarSDK.Keypair.random().publicKey(), + amount: '10', + memo: 'invoice #42', + }); + expect(preview.memo).toBe('invoice #42'); + expect(preview.memoType).toBe('text'); + }); + + it('describes a typed memo with its own type', async () => { + const preview = await previewPayment({ + sourceAccount: PUBLIC_KEY, + destination: StellarSDK.Keypair.random().publicKey(), + amount: '10', + memo: { type: 'id', value: '12345' }, + }); + expect(preview.memo).toBe('12345'); + expect(preview.memoType).toBe('id'); + }); + + it('omits both fields when there is no memo', async () => { + const preview = await previewPayment({ + sourceAccount: PUBLIC_KEY, + destination: StellarSDK.Keypair.random().publicKey(), + amount: '10', + }); + expect(preview.memo).toBeUndefined(); + expect(preview.memoType).toBeUndefined(); + }); + + it('rejects an invalid memo before doing any other work', async () => { + await expect( + previewPayment({ + sourceAccount: PUBLIC_KEY, + destination: StellarSDK.Keypair.random().publicKey(), + amount: '10', + memo: { type: 'hash', value: 'too-short' }, + }) + ).rejects.toBeInstanceOf(PocketPayError); + }); +});