diff --git a/docs/stellar-memos.md b/docs/stellar-memos.md new file mode 100644 index 0000000..5928555 --- /dev/null +++ b/docs/stellar-memos.md @@ -0,0 +1,353 @@ +# Stellar Memos + +`encodeMemo`, `decodeMemo`, and `extractMemoFromTransaction` are typed helpers in `@wraith-protocol/sdk/chains/stellar` for working with Stellar transaction memos. They provide validation, type safety, and convenient extraction patterns. + +--- + +## Overview + +Stellar transactions can include memos to attach arbitrary data. The SDK supports all memo types: + +- **None**: No memo (default) +- **ID**: A 64-bit unsigned integer +- **Text**: A UTF-8 string (max 28 bytes) +- **Hash**: A 32-byte hash (hex-encoded or Uint8Array) +- **Return**: A 32-byte hash for return memos (hex-encoded or Uint8Array) + +These helpers validate memo values and provide a consistent typed interface. + +--- + +## Installation + +```ts +npm install @wraith-protocol/sdk @stellar/stellar-sdk +``` + +--- + +## API + +### encodeMemo + +Encodes a typed memo into a Stellar SDK Memo object with validation. + +```ts +import { encodeMemo } from '@wraith-protocol/sdk/chains/stellar'; + +const memo = encodeMemo({ type: 'text', value: 'Payment #123' }); +``` + +#### Parameters + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | `'none' \| 'id' \| 'text' \| 'hash' \| 'return'` | ✅ | The memo type | +| `value` | `string \| Uint8Array \| null` | ✅ | The memo value (null for 'none') | + +#### Return value + +Returns a Stellar SDK `Memo` object. + +#### Validation + +- **Text memos**: Must be ≤ 28 bytes (UTF-8 encoded) +- **ID memos**: Must be a valid uint64 string (0 to 2^64-1) +- **Hash/Return memos**: Must be exactly 32 bytes + +#### Throws + +Throws `MemoValidationError` if validation fails. + +### decodeMemo + +Decodes a Stellar SDK Memo object into a typed structure. + +```ts +import { decodeMemo } from '@wraith-protocol/sdk/chains/stellar'; + +const typed = decodeMemo(memo); +// => { type: 'text', value: 'Payment #123' } +``` + +#### Parameters + +| Field | Type | Required | Description | +|---|---|---|---| +| `memo` | `Memo \| xdr.Memo` | ✅ | The Stellar SDK Memo object | + +#### Return value + +```ts +interface TypedMemo { + type: MemoType; + value: MemoValue; +} +``` + +### extractMemoFromTransaction + +Extracts the memo from a Stellar transaction and returns a typed structure. + +```ts +import { extractMemoFromTransaction } from '@wraith-protocol/sdk/chains/stellar'; + +const memo = extractMemoFromTransaction(transaction); +``` + +#### Parameters + +| Field | Type | Required | Description | +|---|---|---|---| +| `tx` | `{ memo: Memo \| xdr.Memo }` | ✅ | The Stellar transaction object | + +#### Return value + +Returns a `TypedMemo` structure. + +--- + +## Worked Examples + +### 1 — Text memo for payment reference + +```ts +import { encodeMemo } from '@wraith-protocol/sdk/chains/stellar'; +import { TransactionBuilder, Networks, Operation } from '@stellar/stellar-sdk'; + +const tx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: Networks.TESTNET, +}) + .addOperation(Operation.payment({ + destination: 'GHIJKLMNOPQRSTUVWXYZ1234567890', + asset: Operation.paymentAssetToXDR('native'), + amount: '100', + })) + .addMemo(encodeMemo({ type: 'text', value: 'Invoice #12345' })) + .setTimeout(30) + .build(); +``` + +### 2 — ID memo for numeric reference + +```ts +const tx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: Networks.TESTNET, +}) + .addOperation(Operation.payment({ + destination: 'GHIJKLMNOPQRSTUVWXYZ1234567890', + asset: Operation.paymentAssetToXDR('native'), + amount: '100', + })) + .addMemo(encodeMemo({ type: 'id', value: '99999' })) + .setTimeout(30) + .build(); +``` + +### 3 — Hash memo for external reference + +```ts +import { encodeMemo } from '@wraith-protocol/sdk/chains/stellar'; + +// From hex string +const hashValue = 'a'.repeat(64); // 32 bytes in hex +const memo = encodeMemo({ type: 'hash', value: hashValue }); + +// From Uint8Array +const hashBytes = new Uint8Array(32).fill(0xaa); +const memo2 = encodeMemo({ type: 'hash', value: hashBytes }); +``` + +### 4 — Decoding memos from transactions + +```ts +import { decodeMemo, extractMemoFromTransaction } from '@wraith-protocol/sdk/chains/stellar'; + +// Decode a Memo object directly +const memo = Memo.text('Payment #123'); +const typed = decodeMemo(memo); +console.log(typed.type); // 'text' +console.log(typed.value); // 'Payment #123' + +// Extract from a transaction +const txMemo = extractMemoFromTransaction(transaction); +console.log(txMemo.type); // 'text' +console.log(txMemo.value); // 'Payment #123' +``` + +### 5 — Displaying memos in UI + +```ts +import { extractMemoFromTransaction } from '@wraith-protocol/sdk/chains/stellar'; + +function formatMemoForDisplay(tx: any): string { + const memo = extractMemoFromTransaction(tx); + + switch (memo.type) { + case 'none': + return 'No memo'; + case 'id': + return `ID: ${memo.value}`; + case 'text': + return memo.value as string; + case 'hash': + case 'return': + return Buffer.from(memo.value as Uint8Array).toString('hex'); + default: + return 'Unknown memo type'; + } +} +``` + +### 6 — Validation error handling + +```ts +import { encodeMemo, MemoValidationError } from '@wraith-protocol/sdk/chains/stellar'; + +try { + const memo = encodeMemo({ type: 'text', value: 'a'.repeat(29) }); +} catch (error) { + if (error instanceof MemoValidationError) { + console.error('Memo validation failed:', error.message); + // "Text memo must be at most 28 bytes, got 29 bytes" + } +} +``` + +--- + +## Validation Rules + +### Text Memos + +- Maximum 28 bytes (UTF-8 encoded) +- Multi-byte characters count toward the limit +- Example: "😀" is 4 bytes, so 7 emojis = 28 bytes (valid), 8 emojis = 32 bytes (invalid) + +### ID Memos + +- Must be a valid uint64 string +- Range: 0 to 18,446,744,073,709,551,615 (2^64 - 1) +- Negative values are invalid +- Non-numeric strings are invalid + +### Hash/Return Memos + +- Must be exactly 32 bytes +- Can be provided as hex string (64 hex characters) or Uint8Array +- Invalid hex strings will throw an error + +--- + +## Constants + +The SDK exports memo validation constants: + +```ts +import { + TEXT_MEMO_MAX_BYTES, + HASH_MEMO_BYTES, + ID_MEMO_MAX +} from '@wraith-protocol/sdk/chains/stellar'; + +console.log(TEXT_MEMO_MAX_BYTES); // 28 +console.log(HASH_MEMO_BYTES); // 32 +console.log(ID_MEMO_MAX); // 18446744073709551615n +``` + +--- + +## Error Types + +### MemoValidationError + +Thrown when memo validation fails. + +```ts +class MemoValidationError extends Error { + constructor(message: string); +} +``` + +Common error messages: +- `"Text memo must be at most 28 bytes, got X bytes"` +- `"ID memo value must be a valid uint64 string, got X"` +- `"Hash memo must be exactly 32 bytes, got X bytes"` +- `"X memo requires a value"` + +--- + +## Running Tests + +```bash +# Unit tests (no network needed) +pnpm test test/chains/stellar/memo.test.ts +``` + +--- + +## Best Practices + +### 1 — Use text memos for human-readable references + +Text memos are ideal for invoice numbers, payment references, or short identifiers that users might need to read. + +```ts +encodeMemo({ type: 'text', value: 'INV-2024-001' }); +``` + +### 2 — Use ID memos for numeric database references + +If you have a numeric ID from your database, use the ID memo type for efficient storage. + +```ts +encodeMemo({ type: 'id', value: orderId.toString() }); +``` + +### 3 — Use hash memos for external references + +When referencing external systems (e.g., transaction hashes from other blockchains), use hash memos. + +```ts +encodeMemo({ type: 'hash', value: externalTxHash }); +``` + +### 4 — Always validate user input + +When accepting memo values from users, validate them before encoding: + +```ts +function safeEncodeTextMemo(value: string): Memo { + if (new TextEncoder().encode(value).length > 28) { + throw new Error('Memo too long'); + } + return encodeMemo({ type: 'text', value }); +} +``` + +### 5 — Handle none memos gracefully + +Always check for none memos when displaying transaction details: + +```ts +const memo = extractMemoFromTransaction(tx); +if (memo.type !== 'none') { + // Display memo +} +``` + +--- + +## Comparison with Stellar SDK Primitives + +| Feature | Stellar SDK | Wraith SDK | +|---|---|---|---| +| **Type safety** | Untyped | Typed `TypedMemo` interface | +| **Validation** | Manual | Built-in with clear errors | +| **Extraction** | Manual access to `tx.memo` | `extractMemoFromTransaction()` helper | +| **Decoding** | Manual switch on `memo.switch()` | `decodeMemo()` helper | +| **Error handling** | Runtime errors | `MemoValidationError` with messages | + +The Wraith SDK helpers provide a more ergonomic and safer interface for working with memos. diff --git a/docs/stellar-path-payment.md b/docs/stellar-path-payment.md new file mode 100644 index 0000000..7325630 --- /dev/null +++ b/docs/stellar-path-payment.md @@ -0,0 +1,328 @@ +# Stellar Path Payments with Stealth + +`buildPathStealthPayment` and `findStrictReceivePath` are helpers in `@wraith-protocol/sdk/chains/stellar` that enable senders to pay in one asset while receivers receive in another via on-the-fly conversion through Stellar's orderbook/AMM, integrated with the stealth send flow. + +--- + +## Overview + +Stellar path payments allow atomic asset conversion during payment. This implementation wraps Stellar's `pathPaymentStrictReceive` operation and integrates it with the stealth address generation and announcement flow, enabling: + +- **Cross-asset stealth payments**: Send USDC, receive XLM (or any supported asset pair) +- **Path-finding via Horizon**: Automatically discovers optimal swap routes through orderbooks and AMMs +- **Slippage protection**: Set maximum send amount to avoid unfavorable execution +- **Atomic stealth announcement**: The recipient can detect the payment regardless of asset conversion + +--- + +## Installation + +```ts +npm install @wraith-protocol/sdk @stellar/stellar-sdk +``` + +--- + +## API + +### buildPathStealthPayment + +Builds a single atomic Stellar transaction that swaps assets, delivers to a stealth address, and announces the payment. + +```ts +import { buildPathStealthPayment } from '@wraith-protocol/sdk/chains/stellar'; + +const { transaction, stealthResult } = buildPathStealthPayment(options); +``` + +#### Parameters + +| Field | Type | Required | Description | +|---|---|---|---| +| `sender` | `string` | ✅ | Public key (G...) of the sender | +| `sequence` | `string` | ✅ | Current sequence number of the sender account | +| `sendAsset` | `Asset` | ✅ | Asset the sender is spending | +| `receiveAsset` | `Asset` | ✅ | Asset the stealth address should receive | +| `recipientMeta` | `string` | ✅ | Encoded stealth meta-address (`st:xlm:...`) | +| `sendMax` | `string` | ✅ | Maximum amount of `sendAsset` to spend (slippage protection) | +| `destAmount` | `string` | ✅ | Exact amount of `receiveAsset` the stealth address receives | +| `announcerContract` | `string` | ✅ | Address of the Wraith announcer contract | +| `networkPassphrase` | `string` | ✅ | Stellar network passphrase | +| `path` | `Asset[]` | ❌ | Intermediate assets for the swap route | +| `fee` | `string` | ❌ | Base fee in stroops (default: `"100"`) | +| `_ephemeralSeed` | `Uint8Array` | ❌ | Deterministic seed for testing only | + +#### Return value + +```ts +interface PathStealthPaymentResult { + transaction: Transaction; // Unsigned transaction to sign and submit + stealthResult: GeneratedStealthAddress; // Generated stealth account details +} +``` + +### findStrictReceivePath + +Queries the Horizon `/paths/strict-receive` endpoint to find the optimal payment path and quoted cost. + +```ts +import { findStrictReceivePath } from '@wraith-protocol/sdk/chains/stellar'; + +const { sourceAmount, path } = await findStrictReceivePath(options); +``` + +#### Parameters + +| Field | Type | Required | Description | +|---|---|---|---| +| `sendAsset` | `Asset` | ✅ | Asset the sender is spending | +| `receiveAsset` | `Asset` | ✅ | Asset the stealth address should receive | +| `destAmount` | `string` | ✅ | Exact amount of `receiveAsset` desired | +| `horizonUrl` | `string` | ❌ | Custom Horizon URL (default: deployment's Horizon) | +| `chain` | `string` | ❌ | Chain deployment key (default: `"stellar"`) | + +#### Return value + +```ts +interface StrictReceivePathResult { + sourceAmount: string; // Quoted cost in sendAsset + path: Asset[]; // Optimal intermediate assets for the swap +} +``` + +--- + +## Worked Examples + +### 1 — Send USDC, receive XLM with path-finding + +```ts +import { Asset, Keypair, Networks, Server } from '@stellar/stellar-sdk'; +import { + buildPathStealthPayment, + findStrictReceivePath +} from '@wraith-protocol/sdk/chains/stellar'; + +const USDC_TESTNET = new Asset( + 'USDC', + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +); + +// 1. Find the best path and quoted cost +const { sourceAmount, path } = await findStrictReceivePath({ + sendAsset: USDC_TESTNET, + receiveAsset: Asset.native(), + destAmount: '100', // Want to receive 100 XLM +}); + +console.log(`Quoted cost: ${sourceAmount} USDC`); +console.log(`Path: ${path.map(a => a.code || 'XLM').join(' → ')}`); + +// 2. Add slippage protection (0.5% tolerance) +const slippageBps = 50; // 0.5% +const sendMax = (parseFloat(sourceAmount) * (1 + slippageBps / 10_000)).toFixed(7); + +// 3. Build the stealth payment transaction +const senderKeypair = Keypair.fromSecret('S...'); +const server = new Server('https://horizon-testnet.stellar.org'); +const senderAccount = await server.loadAccount(senderKeypair.publicKey()); + +const { transaction, stealthResult } = buildPathStealthPayment({ + sender: senderKeypair.publicKey(), + sequence: senderAccount.sequence(), + sendAsset: USDC_TESTNET, + receiveAsset: Asset.native(), + destAmount: '100', + sendMax, + recipientMeta: 'st:xlm:...', // Recipient's meta-address + announcerContract: 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL', + networkPassphrase: Networks.TESTNET, + path, // Use the path found by Horizon +}); + +// 4. Sign and submit +transaction.sign(senderKeypair); +const result = await server.submitTransaction(transaction); +console.log(`Payment sent: ${result.hash}`); +``` + +### 2 — Send XLM, receive custom asset (USDC) + +```ts +const { sourceAmount, path } = await findStrictReceivePath({ + sendAsset: Asset.native(), + receiveAsset: USDC_TESTNET, + destAmount: '50', // Receive 50 USDC +}); + +const sendMax = (parseFloat(sourceAmount) * 1.005).toFixed(7); // 0.5% slippage + +const { transaction } = buildPathStealthPayment({ + sender: senderKeypair.publicKey(), + sequence: senderAccount.sequence(), + sendAsset: Asset.native(), + receiveAsset: USDC_TESTNET, + destAmount: '50', + sendMax, + recipientMeta: 'st:xlm:...', + announcerContract: 'CCJLJ...', + networkPassphrase: Networks.TESTNET, + path, +}); + +// For non-native receiveAsset, the transaction includes: +// 1. pathPaymentStrictReceive (swap to sender) +// 2. createClaimableBalance (wrap for stealth address) +// 3. invokeHostFunction (announce payment) +``` + +### 3 — Explicit path (no Horizon path-finding) + +If you want to pin a specific swap route instead of letting Horizon find the best path: + +```ts +const { transaction } = buildPathStealthPayment({ + sender: senderKeypair.publicKey(), + sequence: senderAccount.sequence(), + sendAsset: USDC_TESTNET, + receiveAsset: Asset.native(), + destAmount: '100', + sendMax: '50.025', // Manually calculated or quoted + recipientMeta: 'st:xlm:...', + announcerContract: 'CCJLJ...', + networkPassphrase: Networks.TESTNET, + path: [Asset.native()], // Explicit path: USDC → XLM +}); +``` + +### 4 — Same-asset payment (no conversion) + +When `sendAsset === receiveAsset`, the path payment behaves like a regular payment: + +```ts +const { transaction } = buildPathStealthPayment({ + sender: senderKeypair.publicKey(), + sequence: senderAccount.sequence(), + sendAsset: Asset.native(), + receiveAsset: Asset.native(), + destAmount: '100', + sendMax: '100', // No slippage needed for same asset + recipientMeta: 'st:xlm:...', + announcerContract: 'CCJLJ...', + networkPassphrase: Networks.TESTNET, +}); +``` + +--- + +## Slippage Protection + +The `sendMax` parameter caps how much `sendAsset` the sender will spend. If the swap would cost more, Stellar rejects the transaction with `PATH_PAYMENT_TOO_FEW_OFFERS` before any funds move. + +### Calculating sendMax with slippage tolerance + +```ts +// Get quoted cost from Horizon +const { sourceAmount } = await findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', +}); + +// Apply slippage tolerance (in basis points) +const slippageBps = 50; // 0.5% +const sendMax = (parseFloat(sourceAmount) * (1 + slippageBps / 10_000)).toFixed(7); + +// Example: if sourceAmount = 50.0 USDC +// sendMax = 50.0 * 1.005 = 50.25 USDC +``` + +Common slippage tolerances: +- **10 bps (0.1%)**: Very tight, may fail during volatility +- **50 bps (0.5%)**: Balanced for most use cases +- **100 bps (1.0%)**: Loose, maximizes success probability + +--- + +## Asset Delivery Behavior + +### Native XLM as receiveAsset + +When `receiveAsset` is native XLM: +- `pathPaymentStrictReceive` delivers XLM directly to the stealth address +- If the stealth account doesn't exist, it's created atomically +- Transaction has 2 operations: swap + announcement + +### Non-native receiveAsset (e.g., USDC) + +When `receiveAsset` is a custom asset: +- `pathPaymentStrictReceive` delivers to the sender first +- A `createClaimableBalance` operation wraps the amount for the stealth address +- This bypasses the trustline requirement on a brand-new account +- Transaction has 3 operations: swap + claimable balance + announcement + +The recipient can claim the balance once they detect the announcement and derive the stealth private key. + +--- + +## Error Handling + +### No payment path found + +If Horizon cannot find a path for the asset pair and amount: + +```ts +try { + const { sourceAmount, path } = await findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '1000000', // Unrealistic amount + }); +} catch (error) { + console.error('No payment path found:', error.message); + // Fallback: ask user to adjust amount or try different asset pair +} +``` + +### Slippage exceeded + +If the transaction fails due to slippage: + +```ts +try { + const result = await server.submitTransaction(transaction); +} catch (error) { + if (error.response?.data?.extras?.result_codes?.operations?.[0] === 'op_no_trust') { + console.error('No trustline for receiveAsset'); + } else if (error.response?.data?.extras?.result_codes?.operations?.[0] === 'op_underfunded') { + console.error('Slippage exceeded - try increasing sendMax'); + } +} +``` + +--- + +## Running Tests + +```bash +# Unit tests (no network needed) +pnpm test test/chains/stellar/path-payment.test.ts + +# Integration tests against testnet (requires INTEGRATION=1) +INTEGRATION=1 pnpm exec vitest run test/chains/stellar/path-payment.integration.test.ts +``` + +--- + +## Comparison with buildStellarSwapAndStealth + +The SDK provides two similar helpers: + +| Feature | `buildPathStealthPayment` | `buildStellarSwapAndStealth` | +|---|---|---| +| **Primary use case** | General path payments with Horizon integration | Simplified swap + stealth (legacy) | +| **Path-finding** | Includes `findStrictReceivePath` helper | Manual path specification only | +| **Parameter names** | `sendAsset`, `receiveAsset` | `fromAsset`, `toAsset` | +| **Recommendation** | Use for new implementations | Existing code can continue using | + +Both functions produce equivalent transactions; `buildPathStealthPayment` is the newer, more feature-rich API. diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index cb4ac2d..5bd960d 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -23,17 +23,10 @@ export { L, } from './scalar'; export { bytesToHex, hexToBytes } from './utils'; -export { fetchAnnouncements } from './announcements'; -export type { FetchAnnouncementsOptions } from './announcements'; +export { fetchAnnouncements, RetentionExceededError } from './announcements'; +export type { FetchAnnouncementsOptions, FetchAnnouncementsResult } from './announcements'; export { MemoryCache, IndexedDBCache, autoSelectCache } from './cache'; export type { AnnouncementCache } from './cache'; -export { - fetchAnnouncements, - fetchAnnouncementsStream, - RetentionExceededError, - parseAnnouncementEvent, -} from './announcements'; -export type { FetchAnnouncementsOptions, FetchAnnouncementsResult } from './announcements'; export { MAX_RPC_EVENT_FILTERS, encodeSymbolTopic, @@ -66,3 +59,8 @@ export type { } from './fee-estimation'; export { buildStellarSwapAndStealth } from './swap'; export type { BuildStellarSwapAndStealthOptions, SwapAndStealthResult } from './swap'; +export { buildPathStealthPayment, findStrictReceivePath } from './path-payment'; +export type { BuildPathStealthPaymentOptions, PathStealthPaymentResult, FindStrictReceivePathOptions, StrictReceivePathResult } from './path-payment'; +export { encodeMemo, decodeMemo, extractMemoFromTransaction } from './memo'; +export type { MemoType, MemoValue, TypedMemo } from './memo'; +export { MemoValidationError, TEXT_MEMO_MAX_BYTES, HASH_MEMO_BYTES, ID_MEMO_MAX } from './memo'; diff --git a/src/chains/stellar/memo.ts b/src/chains/stellar/memo.ts new file mode 100644 index 0000000..cd5979a --- /dev/null +++ b/src/chains/stellar/memo.ts @@ -0,0 +1,238 @@ +import { Memo, xdr } from '@stellar/stellar-sdk'; + +/** + * Supported memo types for Stellar transactions. + */ +export type MemoType = 'none' | 'id' | 'text' | 'hash' | 'return'; + +/** + * Typed memo value based on the memo type. + */ +export type MemoValue = string | Uint8Array | null; + +/** + * Typed memo structure. + */ +export interface TypedMemo { + /** The memo type. */ + type: MemoType; + /** The memo value (null for 'none' type). */ + value: MemoValue; +} + +/** + * Error thrown when memo validation fails. + */ +export class MemoValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'MemoValidationError'; + } +} + +/** + * Maximum byte length for text memos. + */ +export const TEXT_MEMO_MAX_BYTES = 28; + +/** + * Required byte length for hash and return memos. + */ +export const HASH_MEMO_BYTES = 32; + +/** + * Maximum value for ID memos (uint64). + */ +export const ID_MEMO_MAX = BigInt('18446744073709551615'); // 2^64 - 1 + +/** + * Validates and normalizes a string value from a memo value. + */ +function normalizeStringValue(value: MemoValue, typeName: string): string { + if (value === null || value === undefined) { + throw new MemoValidationError(`${typeName} memo requires a value`); + } + return typeof value === 'string' ? value : new TextDecoder().decode(value); +} + +/** + * Validates and normalizes a buffer value from a memo value. + */ +function normalizeBufferValue(value: MemoValue, typeName: string): Uint8Array { + if (value === null || value === undefined) { + throw new MemoValidationError(`${typeName} memo requires a value`); + } + return typeof value === 'string' ? Buffer.from(value, 'hex') : Buffer.from(value); +} + +/** + * Validates an ID memo value as a uint64. + */ +function validateIdMemoValue(value: string): void { + try { + const num = BigInt(value); + if (num < 0 || num > ID_MEMO_MAX) { + throw new MemoValidationError( + `ID memo value must be a uint64 (0 to ${ID_MEMO_MAX.toString()}), got ${value}`, + ); + } + } catch (e) { + if (e instanceof MemoValidationError) throw e; + throw new MemoValidationError(`ID memo value must be a valid uint64 string, got ${value}`); + } +} + +/** + * Validates a text memo byte length. + */ +function validateTextMemoLength(value: string): void { + const byteLength = new TextEncoder().encode(value).length; + if (byteLength > TEXT_MEMO_MAX_BYTES) { + throw new MemoValidationError( + `Text memo must be at most ${TEXT_MEMO_MAX_BYTES} bytes, got ${byteLength} bytes`, + ); + } +} + +/** + * Validates a hash/return memo byte length. + */ +function validateHashMemoLength(value: Uint8Array, typeName: string): void { + if (value.length !== HASH_MEMO_BYTES) { + throw new MemoValidationError( + `${typeName} memo must be exactly ${HASH_MEMO_BYTES} bytes, got ${value.length} bytes`, + ); + } +} + +/** + * Encodes a typed memo into a Stellar SDK Memo object. + * + * @param memo The typed memo structure. + * @returns A Stellar SDK Memo object. + * @throws {MemoValidationError} If the memo value is invalid for the given type. + * + * @example + * ```ts + * import { encodeMemo } from '@wraith-protocol/sdk/chains/stellar'; + * + * const memo = encodeMemo({ type: 'text', value: 'Payment #123' }); + * // => Memo.text('Payment #123') + * + * const idMemo = encodeMemo({ type: 'id', value: '12345' }); + * // => Memo.id('12345') + * + * const hashMemo = encodeMemo({ type: 'hash', value: Buffer.from('...') }); + * // => Memo.hash(Buffer.from('...')) + * ``` + */ +export function encodeMemo(memo: TypedMemo): Memo { + const { type, value } = memo; + + switch (type) { + case 'none': + return Memo.none(); + + case 'id': { + const idValue = normalizeStringValue(value, 'ID'); + validateIdMemoValue(idValue); + return Memo.id(idValue); + } + + case 'text': { + const textValue = normalizeStringValue(value, 'Text'); + validateTextMemoLength(textValue); + return Memo.text(textValue); + } + + case 'hash': { + const hashValue = normalizeBufferValue(value, 'Hash'); + validateHashMemoLength(hashValue, 'Hash'); + return Memo.hash(hashValue); + } + + case 'return': { + const returnValue = normalizeBufferValue(value, 'Return'); + validateHashMemoLength(returnValue, 'Return'); + return Memo.return(returnValue); + } + + default: + throw new MemoValidationError(`Unknown memo type: ${(type as string)}`); + } +} + +/** + * Decodes a Stellar SDK Memo object into a typed structure. + * + * @param memo The Stellar SDK Memo object. + * @returns A typed memo structure. + * + * @example + * ```ts + * import { decodeMemo } from '@wraith-protocol/sdk/chains/stellar'; + * + * const memo = Memo.text('Payment #123'); + * const typed = decodeMemo(memo); + * // => { type: 'text', value: 'Payment #123' } + * ``` + */ +export function decodeMemo(memo: Memo | xdr.Memo): TypedMemo { + if (memo instanceof Memo) { + switch (memo.switch().name) { + case 'memoNone': + return { type: 'none', value: null }; + case 'memoId': + return { type: 'id', value: memo.value().toString() }; + case 'memoText': + return { type: 'text', value: memo.value().toString() }; + case 'memoHash': + return { type: 'hash', value: Buffer.from(memo.value()) }; + case 'memoReturn': + return { type: 'return', value: Buffer.from(memo.value()) }; + default: + throw new MemoValidationError(`Unknown memo type: ${memo.switch().name}`); + } + } + + // Handle xdr.Memo directly + const xdrMemo = memo as xdr.Memo; + switch (xdrMemo.switch().name) { + case 'memoNone': + return { type: 'none', value: null }; + case 'memoId': + return { type: 'id', value: xdrMemo.value().toString() }; + case 'memoText': + return { type: 'text', value: xdrMemo.value().toString() }; + case 'memoHash': + return { type: 'hash', value: Buffer.from(xdrMemo.value()) }; + case 'memoReturn': + return { type: 'return', value: Buffer.from(xdrMemo.value()) }; + default: + throw new MemoValidationError(`Unknown memo type: ${xdrMemo.switch().name}`); + } +} + +/** + * Extracts the memo from a Stellar transaction and returns a typed structure. + * + * @param tx The Stellar transaction object. + * @returns A typed memo structure. + * + * @example + * ```ts + * import { extractMemoFromTransaction } from '@wraith-protocol/sdk/chains/stellar'; + * + * const tx = new TransactionBuilder(sourceAccount, { networkPassphrase: Networks.TESTNET }) + * .addOperation(Operation.payment({ ... })) + * .addMemo(Memo.text('Payment #123')) + * .setTimeout(30) + * .build(); + * + * const memo = extractMemoFromTransaction(tx); + * // => { type: 'text', value: 'Payment #123' } + * ``` + */ +export function extractMemoFromTransaction(tx: { memo: Memo | xdr.Memo }): TypedMemo { + return decodeMemo(tx.memo); +} diff --git a/src/chains/stellar/path-payment.ts b/src/chains/stellar/path-payment.ts new file mode 100644 index 0000000..b37f0b5 --- /dev/null +++ b/src/chains/stellar/path-payment.ts @@ -0,0 +1,339 @@ +import { Asset, Operation, TransactionBuilder, Account, Contract, Address, nativeToScVal, xdr } from '@stellar/stellar-sdk'; +import { SCHEME_ID } from './constants'; +import { generateStealthAddress } from './stealth'; +import { decodeStealthMetaAddress } from './meta-address'; +import { getDeployment } from './deployments'; +import type { GeneratedStealthAddress } from './types'; + +/** + * Options for {@link buildPathStealthPayment}. + */ +export interface BuildPathStealthPaymentOptions { + /** The public key (G...) of the sender. */ + sender: string; + /** The current sequence number of the sender account. */ + sequence: string; + /** The asset the sender is spending. */ + sendAsset: Asset; + /** The asset the stealth address should receive. */ + receiveAsset: Asset; + /** + * Encoded stealth meta-address of the recipient (`st:xlm:...`). + * The helper decodes it and generates a one-time stealth address internally. + */ + recipientMeta: string; + /** + * Maximum amount of `sendAsset` the sender is willing to spend. + * Acts as slippage protection: the transaction fails if the swap would cost + * more than this. + */ + sendMax: string; + /** + * Exact amount of `receiveAsset` the stealth address should receive (as a string, + * e.g., `"100.0"`). This is the `destAmount` of `pathPaymentStrictReceive`. + */ + destAmount: string; + /** Stellar network passphrase. */ + networkPassphrase: string; + /** Address of the Wraith announcer contract. */ + announcerContract: string; + /** + * Intermediate assets for the AMM path. Leave empty or undefined to let + * Stellar path-finding pick the best route (order book fallback applies). + * Pass an explicit path to pin the route, e.g., `[Asset.native()]` for + * USDC → XLM via the native asset. + */ + path?: Asset[]; + /** Base fee in stroops. Defaults to `"100"`. */ + fee?: string; + /** + * Optional fixed 32-byte ephemeral seed for deterministic tests. + * Never pass this in production. + */ + _ephemeralSeed?: Uint8Array; +} + +/** + * Result returned by {@link buildPathStealthPayment}. + */ +export interface PathStealthPaymentResult { + /** + * The combined Stellar transaction containing: + * 1. `pathPaymentStrictReceive` — AMM swap sending `receiveAsset` to the stealth address. + * 2. `createClaimableBalance` — wraps the received amount for non-native receiveAsset + * so the stealth account can claim without a pre-existing trustline, **or** + * the swap operation itself already delivers native XLM directly. + * 3. `invokeHostFunction` — announces the stealth payment on the Wraith contract. + * + * Sign and submit this transaction. Both legs succeed or fail atomically. + */ + transaction: ReturnType; + /** The generated one-time stealth account. */ + stealthResult: GeneratedStealthAddress; +} + +/** + * Builds a single atomic Stellar transaction that: + * + * 1. **Swaps** `sendAsset` for `receiveAsset` using `pathPaymentStrictReceive`, with + * `sendMax` as the slippage ceiling. + * 2. **Delivers** `receiveAsset` to a freshly generated stealth address derived from + * the recipient's meta-address. + * 3. **Announces** the stealth payment by calling the Wraith announcer contract. + * + * The three operations share a single envelope, so all succeed or all fail. + * + * ### Slippage protection + * + * `sendMax` caps how much `sendAsset` the sender will spend. If the AMM path + * costs more, Stellar rejects the transaction with `PATH_PAYMENT_TOO_FEW_OFFERS` + * or an under-funded error before any funds move. + * + * To express a percentage slippage tolerance given a quoted send cost: + * ```ts + * const quotedSend = "50.0"; // obtained from Horizon /paths + * const slippageBps = 50; // 0.5 % + * const sendMax = (parseFloat(quotedSend) * (1 + slippageBps / 10_000)).toFixed(7); + * ``` + * + * ### Asset delivery + * + * - **Native XLM as `receiveAsset`**: `pathPaymentStrictReceive` delivers XLM directly + * to `stealthAddress`. If the account does not exist it is created atomically. + * - **Non-native `receiveAsset`** (e.g., USDC, yXLM): the swap delivers to the sender, + * who then wraps the amount in a `createClaimableBalance` claimable by the + * stealth address. This bypasses the trustline requirement on a brand-new account. + * + * @param options See {@link BuildPathStealthPaymentOptions}. + * @returns The unsigned transaction and the generated stealth account details. + * + * @example + * ```ts + * import { Asset } from '@stellar/stellar-sdk'; + * import { buildPathStealthPayment } from '@wraith-protocol/sdk/chains/stellar'; + * + * const USDC_TESTNET = new Asset('USDC', 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); + * + * const { transaction, stealthResult } = buildPathStealthPayment({ + * sender: senderKeypair.publicKey(), + * sequence: account.sequence, + * sendAsset: USDC_TESTNET, + * receiveAsset: Asset.native(), + * destAmount: '100', // receive exactly 100 XLM + * sendMax: '50.025', // spend at most 50.025 USDC (0.05 % slippage) + * recipientMeta: 'st:xlm:...', // recipient's published meta-address + * announcerContract: 'CCJLJ...', + * networkPassphrase: Networks.TESTNET, + * }); + * + * transaction.sign(senderKeypair); + * await server.submitTransaction(transaction); + * ``` + */ +export function buildPathStealthPayment( + options: BuildPathStealthPaymentOptions, +): PathStealthPaymentResult { + const { + sender, + sequence, + sendAsset, + receiveAsset, + recipientMeta, + sendMax, + destAmount, + announcerContract, + networkPassphrase, + path = [], + fee = '100', + _ephemeralSeed, + } = options; + + const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(recipientMeta); + const stealthResult = generateStealthAddress(spendingPubKey, viewingPubKey, _ephemeralSeed); + + const source = new Account(sender, sequence); + const builder = new TransactionBuilder(source, { fee, networkPassphrase }).setTimeout(180); + + if (receiveAsset.isNative()) { + // Swap + direct delivery to stealth account in one operation. + // pathPaymentStrictReceive creates the destination account if it doesn't exist + // when the dest asset is native XLM. + builder.addOperation( + Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax, + destination: stealthResult.stealthAddress, + destAsset: receiveAsset, + destAmount, + path, + }), + ); + } else { + // Non-native receiveAsset: swap to sender first, then wrap in a claimable balance + // so the stealth account doesn't need a pre-existing trustline. + builder.addOperation( + Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax, + destination: sender, + destAsset: receiveAsset, + destAmount, + path, + }), + ); + builder.addOperation( + Operation.createClaimableBalance({ + asset: receiveAsset, + amount: destAmount, + claimants: [new Operation.CreateClaimableBalance.Claimant(stealthResult.stealthAddress, Operation.CreateClaimableBalance.Claimant.predicateUnconditional())], + }), + ); + } + + // Announce the stealth payment so the recipient can scan for it. + const contract = new Contract(announcerContract); + builder.addOperation( + contract.call( + 'announce', + nativeToScVal(SCHEME_ID, { type: 'u32' }), + new Address(stealthResult.stealthAddress).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)), + xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])), + ), + ); + + return { transaction: builder.build(), stealthResult }; +} + +/** + * Horizon strict-receive path response. + */ +interface HorizonStrictReceivePath { + source_amount: string; + source_asset: string; + destination_amount: string; + destination_asset: string; + path: Array<{ asset_type: string; asset_code?: string; asset_issuer?: string }>; +} + +/** + * Options for {@link findStrictReceivePath}. + */ +export interface FindStrictReceivePathOptions { + /** The asset the sender is spending. */ + sendAsset: Asset; + /** The asset the stealth address should receive. */ + receiveAsset: Asset; + /** Exact amount of `receiveAsset` the stealth address should receive. */ + destAmount: string; + /** Horizon API URL. Defaults to the configured deployment's Horizon URL. */ + horizonUrl?: string; + /** Chain deployment key (e.g., "stellar"). Defaults to "stellar". */ + chain?: string; +} + +/** + * Result returned by {@link findStrictReceivePath}. + */ +export interface StrictReceivePathResult { + /** The source amount needed to receive `destAmount`. */ + sourceAmount: string; + /** The path of intermediate assets for the swap. */ + path: Asset[]; +} + +/** + * Queries the Horizon `/paths/strict-receive` endpoint to find the best payment path. + * + * This helper is used before building a stealth payment to determine the optimal + * swap route and calculate the required `sendMax` with slippage protection. + * + * @param options See {@link FindStrictReceivePathOptions}. + * @returns The source amount and path for the optimal swap route. + * @throws {Error} If the Horizon request fails or returns an invalid response. + * + * @example + * ```ts + * import { Asset } from '@stellar/stellar-sdk'; + * import { findStrictReceivePath, buildPathStealthPayment } from '@wraith-protocol/sdk/chains/stellar'; + * + * const USDC = new Asset('USDC', 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); + * + * // Find the best path and quoted cost + * const { sourceAmount, path } = await findStrictReceivePath({ + * sendAsset: USDC, + * receiveAsset: Asset.native(), + * destAmount: '100', + * }); + * + * // Add slippage protection (0.5%) + * const slippageBps = 50; + * const sendMax = (parseFloat(sourceAmount) * (1 + slippageBps / 10_000)).toFixed(7); + * + * // Build the transaction with the found path + * const { transaction } = buildPathStealthPayment({ + * sender: 'G...', + * sequence: '123', + * sendAsset: USDC, + * receiveAsset: Asset.native(), + * destAmount: '100', + * sendMax, + * recipientMeta: 'st:xlm:...', + * announcerContract: 'CCJLJ...', + * networkPassphrase: Networks.TESTNET, + * path, + * }); + * ``` + */ +export async function findStrictReceivePath( + options: FindStrictReceivePathOptions, +): Promise { + const { sendAsset, receiveAsset, destAmount, horizonUrl, chain = 'stellar' } = options; + + const deployment = getDeployment(chain); + const url = horizonUrl || deployment.horizonUrl; + + // Build Horizon asset strings + const sendAssetStr = sendAsset.isNative() + ? 'native' + : `${sendAsset.code}:${sendAsset.issuer}`; + const receiveAssetStr = receiveAsset.isNative() + ? 'native' + : `${receiveAsset.code}:${receiveAsset.issuer}`; + + const params = new URLSearchParams({ + source_account: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWH4', // Dummy account for path finding + destination_asset: receiveAssetStr, + destination_amount: destAmount, + source_assets: sendAssetStr, + }); + + const response = await fetch(`${url}/paths/strict-receive?${params.toString()}`); + if (!response.ok) { + throw new Error(`Horizon path finding failed: ${response.status} ${response.statusText}`); + } + + const data: HorizonStrictReceivePath[] = await response.json(); + if (!Array.isArray(data) || data.length === 0) { + throw new Error('No payment path found for the given assets and amount'); + } + + // Use the first (best) path + const bestPath = data[0]; + + // Parse the path assets + const pathAssets: Asset[] = bestPath.path.map((p) => { + if (p.asset_type === 'native') { + return Asset.native(); + } + if (p.asset_code && p.asset_issuer) { + return new Asset(p.asset_code, p.asset_issuer); + } + throw new Error(`Invalid path asset: ${JSON.stringify(p)}`); + }); + + return { + sourceAmount: bestPath.source_amount, + path: pathAssets, + }; +} diff --git a/test/chains/stellar/memo.test.ts b/test/chains/stellar/memo.test.ts new file mode 100644 index 0000000..cb18f63 --- /dev/null +++ b/test/chains/stellar/memo.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect } from 'vitest'; +import { Memo, TransactionBuilder, Account, Networks, Operation } from '@stellar/stellar-sdk'; +import { + encodeMemo, + decodeMemo, + extractMemoFromTransaction, + MemoValidationError, + TEXT_MEMO_MAX_BYTES, + HASH_MEMO_BYTES, + ID_MEMO_MAX, +} from '../../../src/chains/stellar/memo'; + +describe('encodeMemo', () => { + describe('none memo', () => { + it('encodes none memo', () => { + const memo = encodeMemo({ type: 'none', value: null }); + expect(memo.switch().name).toBe('memoNone'); + }); + }); + + describe('id memo', () => { + it('encodes valid id memo from string', () => { + const memo = encodeMemo({ type: 'id', value: '12345' }); + expect(memo.switch().name).toBe('memoId'); + expect(memo.value().toString()).toBe('12345'); + }); + + it('encodes valid id memo from Uint8Array', () => { + const value = new TextEncoder().encode('12345'); + const memo = encodeMemo({ type: 'id', value }); + expect(memo.switch().name).toBe('memoId'); + expect(memo.value().toString()).toBe('12345'); + }); + + it('throws when value is null', () => { + expect(() => encodeMemo({ type: 'id', value: null })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'id', value: null })).toThrow('ID memo requires a value'); + }); + + it('throws when value is invalid uint64', () => { + expect(() => encodeMemo({ type: 'id', value: 'not a number' })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'id', value: 'not a number' })).toThrow('valid uint64 string'); + }); + + it('throws when value exceeds uint64 max', () => { + const maxValue = ID_MEMO_MAX.toString(); + const tooLarge = (ID_MEMO_MAX + BigInt(1)).toString(); + + // Max value should work + expect(() => encodeMemo({ type: 'id', value: maxValue })).not.toThrow(); + + // One over max should fail + expect(() => encodeMemo({ type: 'id', value: tooLarge })).toThrow(MemoValidationError); + }); + + it('throws when value is negative', () => { + expect(() => encodeMemo({ type: 'id', value: '-1' })).toThrow(MemoValidationError); + }); + }); + + describe('text memo', () => { + it('encodes valid text memo from string', () => { + const memo = encodeMemo({ type: 'text', value: 'Payment #123' }); + expect(memo.switch().name).toBe('memoText'); + expect(memo.value().toString()).toBe('Payment #123'); + }); + + it('encodes valid text memo from Uint8Array', () => { + const value = new TextEncoder().encode('Payment #123'); + const memo = encodeMemo({ type: 'text', value }); + expect(memo.switch().name).toBe('memoText'); + expect(memo.value().toString()).toBe('Payment #123'); + }); + + it('throws when value is null', () => { + expect(() => encodeMemo({ type: 'text', value: null })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'text', value: null })).toThrow('Text memo requires a value'); + }); + + it('throws when text exceeds 28 bytes', () => { + const longText = 'a'.repeat(29); + expect(() => encodeMemo({ type: 'text', value: longText })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'text', value: longText })).toContain('28 bytes'); + }); + + it('accepts text exactly at 28 bytes', () => { + const exactText = 'a'.repeat(28); + expect(() => encodeMemo({ type: 'text', value: exactText })).not.toThrow(); + }); + + it('handles multi-byte characters correctly', () => { + // Each emoji is 4 bytes, so 7 emojis = 28 bytes (valid) + const emojiText = '😀😀😀😀😀😀😀'; + expect(() => encodeMemo({ type: 'text', value: emojiText })).not.toThrow(); + + // 8 emojis = 32 bytes (too long) + const tooLongEmoji = '😀😀😀😀😀😀😀😀'; + expect(() => encodeMemo({ type: 'text', value: tooLongEmoji })).toThrow(MemoValidationError); + }); + }); + + describe('hash memo', () => { + it('encodes valid hash memo from hex string', () => { + const hexValue = 'a'.repeat(64); // 32 bytes in hex + const memo = encodeMemo({ type: 'hash', value: hexValue }); + expect(memo.switch().name).toBe('memoHash'); + expect(memo.value().length).toBe(HASH_MEMO_BYTES); + }); + + it('encodes valid hash memo from Uint8Array', () => { + const value = new Uint8Array(HASH_MEMO_BYTES).fill(0xaa); + const memo = encodeMemo({ type: 'hash', value }); + expect(memo.switch().name).toBe('memoHash'); + expect(memo.value().length).toBe(HASH_MEMO_BYTES); + }); + + it('throws when value is null', () => { + expect(() => encodeMemo({ type: 'hash', value: null })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'hash', value: null })).toThrow('Hash memo requires a value'); + }); + + it('throws when hash is not 32 bytes', () => { + const shortHash = new Uint8Array(16); + expect(() => encodeMemo({ type: 'hash', value: shortHash })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'hash', value: shortHash })).toContain('32 bytes'); + + const longHash = new Uint8Array(64); + expect(() => encodeMemo({ type: 'hash', value: longHash })).toThrow(MemoValidationError); + }); + + it('throws when hex string is invalid', () => { + expect(() => encodeMemo({ type: 'hash', value: 'not hex' })).toThrow(); + }); + }); + + describe('return memo', () => { + it('encodes valid return memo from hex string', () => { + const hexValue = 'b'.repeat(64); // 32 bytes in hex + const memo = encodeMemo({ type: 'return', value: hexValue }); + expect(memo.switch().name).toBe('memoReturn'); + expect(memo.value().length).toBe(HASH_MEMO_BYTES); + }); + + it('encodes valid return memo from Uint8Array', () => { + const value = new Uint8Array(HASH_MEMO_BYTES).fill(0xbb); + const memo = encodeMemo({ type: 'return', value }); + expect(memo.switch().name).toBe('memoReturn'); + expect(memo.value().length).toBe(HASH_MEMO_BYTES); + }); + + it('throws when value is null', () => { + expect(() => encodeMemo({ type: 'return', value: null })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'return', value: null })).toThrow('Return memo requires a value'); + }); + + it('throws when return is not 32 bytes', () => { + const shortReturn = new Uint8Array(16); + expect(() => encodeMemo({ type: 'return', value: shortReturn })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'return', value: shortReturn })).toContain('32 bytes'); + }); + }); + + describe('unknown memo type', () => { + it('throws for unknown type', () => { + // @ts-expect-error - testing invalid type + expect(() => encodeMemo({ type: 'unknown', value: 'test' })).toThrow(MemoValidationError); + expect(() => encodeMemo({ type: 'unknown' as any, value: 'test' })).toThrow('Unknown memo type'); + }); + }); +}); + +describe('decodeMemo', () => { + describe('Memo object', () => { + it('decodes none memo', () => { + const memo = Memo.none(); + const decoded = decodeMemo(memo); + expect(decoded.type).toBe('none'); + expect(decoded.value).toBe(null); + }); + + it('decodes id memo', () => { + const memo = Memo.id('12345'); + const decoded = decodeMemo(memo); + expect(decoded.type).toBe('id'); + expect(decoded.value).toBe('12345'); + }); + + it('decodes text memo', () => { + const memo = Memo.text('Payment #123'); + const decoded = decodeMemo(memo); + expect(decoded.type).toBe('text'); + expect(decoded.value).toBe('Payment #123'); + }); + + it('decodes hash memo', () => { + const hashValue = new Uint8Array(HASH_MEMO_BYTES).fill(0xaa); + const memo = Memo.hash(hashValue); + const decoded = decodeMemo(memo); + expect(decoded.type).toBe('hash'); + expect(decoded.value).toEqual(hashValue); + }); + + it('decodes return memo', () => { + const returnValue = new Uint8Array(HASH_MEMO_BYTES).fill(0xbb); + const memo = Memo.return(returnValue); + const decoded = decodeMemo(memo); + expect(decoded.type).toBe('return'); + expect(decoded.value).toEqual(returnValue); + }); + }); + + describe('xdr.Memo object', () => { + it('decodes xdr memo', () => { + const memo = Memo.text('Test'); + const xdrMemo = memo.toXDR(); + const parsed = Memo.fromXDR(xdrMemo); + const decoded = decodeMemo(parsed); + expect(decoded.type).toBe('text'); + expect(decoded.value).toBe('Test'); + }); + }); +}); + +describe('extractMemoFromTransaction', () => { + it('extracts memo from transaction', () => { + const source = new Account('GABCDEF1234567890', '1'); + const tx = new TransactionBuilder(source, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.payment({ + destination: 'GHIJKLMNOPQRSTUVWXYZ1234567890', + asset: Operation.paymentAssetToXDR('native'), + amount: '100', + })) + .addMemo(Memo.text('Payment #123')) + .setTimeout(30) + .build(); + + const memo = extractMemoFromTransaction(tx); + expect(memo.type).toBe('text'); + expect(memo.value).toBe('Payment #123'); + }); + + it('extracts none memo from transaction without memo', () => { + const source = new Account('GABCDEF1234567890', '1'); + const tx = new TransactionBuilder(source, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.payment({ + destination: 'GHIJKLMNOPQRSTUVWXYZ1234567890', + asset: Operation.paymentAssetToXDR('native'), + amount: '100', + })) + .setTimeout(30) + .build(); + + const memo = extractMemoFromTransaction(tx); + expect(memo.type).toBe('none'); + expect(memo.value).toBe(null); + }); + + it('extracts id memo from transaction', () => { + const source = new Account('GABCDEF1234567890', '1'); + const tx = new TransactionBuilder(source, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.payment({ + destination: 'GHIJKLMNOPQRSTUVWXYZ1234567890', + asset: Operation.paymentAssetToXDR('native'), + amount: '100', + })) + .addMemo(Memo.id('99999')) + .setTimeout(30) + .build(); + + const memo = extractMemoFromTransaction(tx); + expect(memo.type).toBe('id'); + expect(memo.value).toBe('99999'); + }); +}); + +describe('constants', () => { + it('exports TEXT_MEMO_MAX_BYTES', () => { + expect(TEXT_MEMO_MAX_BYTES).toBe(28); + }); + + it('exports HASH_MEMO_BYTES', () => { + expect(HASH_MEMO_BYTES).toBe(32); + }); + + it('exports ID_MEMO_MAX', () => { + expect(ID_MEMO_MAX).toBe(BigInt('18446744073709551615')); + }); +}); + +describe('round-trip encoding/decoding', () => { + it('round-trips none memo', () => { + const original = { type: 'none' as const, value: null }; + const encoded = encodeMemo(original); + const decoded = decodeMemo(encoded); + expect(decoded).toEqual(original); + }); + + it('round-trips id memo', () => { + const original = { type: 'id' as const, value: '12345' }; + const encoded = encodeMemo(original); + const decoded = decodeMemo(encoded); + expect(decoded).toEqual(original); + }); + + it('round-trips text memo', () => { + const original = { type: 'text' as const, value: 'Payment #123' }; + const encoded = encodeMemo(original); + const decoded = decodeMemo(encoded); + expect(decoded).toEqual(original); + }); + + it('round-trips hash memo', () => { + const hashValue = new Uint8Array(HASH_MEMO_BYTES).fill(0xaa); + const original = { type: 'hash' as const, value: hashValue }; + const encoded = encodeMemo(original); + const decoded = decodeMemo(encoded); + expect(decoded.type).toBe(original.type); + expect(decoded.value).toEqual(original.value); + }); + + it('round-trips return memo', () => { + const returnValue = new Uint8Array(HASH_MEMO_BYTES).fill(0xbb); + const original = { type: 'return' as const, value: returnValue }; + const encoded = encodeMemo(original); + const decoded = decodeMemo(encoded); + expect(decoded.type).toBe(original.type); + expect(decoded.value).toEqual(original.value); + }); +}); diff --git a/test/chains/stellar/path-payment.test.ts b/test/chains/stellar/path-payment.test.ts new file mode 100644 index 0000000..dd0195c --- /dev/null +++ b/test/chains/stellar/path-payment.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Asset, Keypair, Networks, Operation } from '@stellar/stellar-sdk'; +import { buildPathStealthPayment, findStrictReceivePath } from '../../../src/chains/stellar/path-payment'; +import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; +import { encodeStealthMetaAddress } from '../../../src/chains/stellar/meta-address'; + +const ANNOUNCER = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; +const NETWORK = Networks.TESTNET; +const USDC_ISSUER = Keypair.random().publicKey(); +const USDC = new Asset('USDC', USDC_ISSUER); +const EPHEMERAL = new Uint8Array(32).fill(0xee); + +// Deterministic recipient meta-address +const recipientKeys = deriveStealthKeys(new Uint8Array(64).fill(0xab)); +const recipientMeta = encodeStealthMetaAddress( + recipientKeys.spendingPubKey, + recipientKeys.viewingPubKey, +); + +const base = { + sender: Keypair.random().publicKey(), + sequence: '100', + announcerContract: ANNOUNCER, + networkPassphrase: NETWORK, + recipientMeta, + _ephemeralSeed: EPHEMERAL, +}; + +describe('buildPathStealthPayment', () => { + describe('native XLM as receiveAsset', () => { + it('produces 2 operations: pathPaymentStrictReceive + invokeHostFunction', () => { + const { transaction, stealthResult } = buildPathStealthPayment({ + ...base, + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + sendMax: '50.025', + }); + + expect(transaction.operations).toHaveLength(2); + + const swap = transaction.operations[0] as Operation.PathPaymentStrictReceive; + expect(swap.type).toBe('pathPaymentStrictReceive'); + expect(swap.destAsset.isNative()).toBe(true); + expect(swap.destAmount).toBe('100.0000000'); + expect(swap.sendMax).toBe('50.0250000'); + expect(swap.sendAsset.code).toBe('USDC'); + // Swap delivers directly to the stealth address + expect(swap.destination).toBe(stealthResult.stealthAddress); + + const announce = transaction.operations[1] as Operation.InvokeHostFunction; + expect(announce.type).toBe('invokeHostFunction'); + }); + + it('stealthAddress is a valid Stellar G... address', () => { + const { stealthResult } = buildPathStealthPayment({ + ...base, + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '50', + sendMax: '25', + }); + expect(stealthResult.stealthAddress).toMatch(/^G[A-Z2-7]{55}$/); + }); + + it('is deterministic with _ephemeralSeed', () => { + const opts = { + ...base, + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '10', + sendMax: '5', + }; + const a = buildPathStealthPayment(opts); + const b = buildPathStealthPayment(opts); + expect(a.stealthResult.stealthAddress).toBe(b.stealthResult.stealthAddress); + }); + }); + + describe('non-native receiveAsset (USDC)', () => { + it('produces 3 operations: pathPayment + claimableBalance + invokeHostFunction', () => { + const USDC2_ISSUER = Keypair.random().publicKey(); + const USDC2 = new Asset('USDC', USDC2_ISSUER); + + const { transaction, stealthResult } = buildPathStealthPayment({ + ...base, + sendAsset: Asset.native(), + receiveAsset: USDC2, + destAmount: '200', + sendMax: '100.5', + }); + + expect(transaction.operations).toHaveLength(3); + + const swap = transaction.operations[0] as Operation.PathPaymentStrictReceive; + expect(swap.type).toBe('pathPaymentStrictReceive'); + // Swap delivers to sender, not stealth address + expect(swap.destination).toBe(base.sender); + expect(swap.destAmount).toBe('200.0000000'); + expect(swap.sendMax).toBe('100.5000000'); + + const claimable = transaction.operations[1] as Operation.CreateClaimableBalance; + expect(claimable.type).toBe('createClaimableBalance'); + expect(claimable.amount).toBe('200.0000000'); + expect(claimable.asset.code).toBe('USDC'); + expect(claimable.claimants[0].destination).toBe(stealthResult.stealthAddress); + + const announce = transaction.operations[2] as Operation.InvokeHostFunction; + expect(announce.type).toBe('invokeHostFunction'); + }); + }); + + describe('slippage — sendMax enforcement', () => { + it('encodes sendMax exactly in the operation', () => { + const { transaction } = buildPathStealthPayment({ + ...base, + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + sendMax: '50.075', // 0.15% slippage tolerance + }); + const swap = transaction.operations[0] as Operation.PathPaymentStrictReceive; + expect(swap.sendMax).toBe('50.0750000'); + }); + + it('accepts explicit intermediate path', () => { + const { transaction } = buildPathStealthPayment({ + ...base, + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + sendMax: '50', + path: [Asset.native()], + }); + const swap = transaction.operations[0] as Operation.PathPaymentStrictReceive; + expect(swap.path).toHaveLength(1); + }); + }); + + describe('announcement', () => { + it('always includes an invokeHostFunction operation as the last operation', () => { + for (const receiveAsset of [Asset.native(), USDC]) { + const { transaction } = buildPathStealthPayment({ + ...base, + sendAsset: Asset.native(), + receiveAsset, + destAmount: '10', + sendMax: '10', + }); + const last = transaction.operations[transaction.operations.length - 1]; + expect(last.type).toBe('invokeHostFunction'); + } + }); + }); +}); + +describe('findStrictReceivePath', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('queries Horizon /paths/strict-receive endpoint', async () => { + const mockFetch = vi.fn(); + globalThis.fetch = mockFetch; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + source_amount: '50.0', + source_asset: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + destination_amount: '100.0', + destination_asset: 'native', + path: [], + }, + ], + }); + + const result = await findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + }); + + expect(result.sourceAmount).toBe('50.0'); + expect(result.path).toEqual([]); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/paths/strict-receive?'), + ); + }); + + it('parses path with intermediate assets', async () => { + const mockFetch = vi.fn(); + globalThis.fetch = mockFetch; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + source_amount: '50.0', + source_asset: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + destination_amount: '100.0', + destination_asset: 'native', + path: [ + { asset_type: 'credit_alphanum4', asset_code: 'USDT', asset_issuer: 'G...' }, + ], + }, + ], + }); + + const result = await findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + }); + + expect(result.path).toHaveLength(1); + expect(result.path[0].code).toBe('USDT'); + }); + + it('throws when Horizon returns error', async () => { + const mockFetch = vi.fn(); + global.fetch = mockFetch; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect( + findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + }), + ).rejects.toThrow('Horizon path finding failed'); + }); + + it('throws when no path found', async () => { + const mockFetch = vi.fn(); + global.fetch = mockFetch; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + await expect( + findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + }), + ).rejects.toThrow('No payment path found'); + }); + + it('uses custom horizonUrl when provided', async () => { + const mockFetch = vi.fn(); + globalThis.fetch = mockFetch; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + source_amount: '50.0', + source_asset: 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + destination_amount: '100.0', + destination_asset: 'native', + path: [], + }, + ], + }); + + await findStrictReceivePath({ + sendAsset: USDC, + receiveAsset: Asset.native(), + destAmount: '100', + horizonUrl: 'https://custom-horizon.example.com', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://custom-horizon.example.com'), + ); + }); +});