Skip to content
Merged
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
8 changes: 6 additions & 2 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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." |
Expand Down
4 changes: 3 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/issued-asset-payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 |
Expand Down
123 changes: 123 additions & 0 deletions docs/memo-validation.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions src/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -228,6 +229,14 @@ export const ERROR_CODES: Record<ErrorCodeValue, ErrorCodeSpec> = {
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,
Expand Down
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type {
AssetBalance,
AccountBalance,
BalanceResult,
MemoType,
MemoInput,
SendXLMParams,
SendAssetParams,
PaymentPreviewParams,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 9 additions & 7 deletions src/payments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions src/payments/preview.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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);
Expand All @@ -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)
};
Expand Down
4 changes: 2 additions & 2 deletions src/payments/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import * as StellarSDK from '@stellar/stellar-sdk';
import { PocketPayError, SendXLMParams } from '../types';
import {
validateAmount,
validateMemo,
validateMemoInput,
validatePublicKey,
validateSecretKey,
} from '../utils';
Expand Down Expand Up @@ -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'));
}
Expand Down
7 changes: 4 additions & 3 deletions src/transactions/offline-preparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading