diff --git a/src/index.ts b/src/index.ts index a2007d7..dfb10da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,5 +30,6 @@ export type { SubmitResult, TransactionStatus } from './types/transaction'; // 6. Standalone functions export { createEscrowAccount, lockCustodyFunds, anchorTrustHash, verifyEventHash } from './escrow'; -export { buildMultisigTransaction } from './transactions'; +export { buildMultisigTransaction, buildPaymentOp, decodeMemo } from './transactions'; +export type { DecodedMemo } from './transactions'; export { getMinimumReserve } from './accounts'; diff --git a/src/transactions/builder.ts b/src/transactions/builder.ts new file mode 100644 index 0000000..5295343 --- /dev/null +++ b/src/transactions/builder.ts @@ -0,0 +1,71 @@ +import { Memo, MemoType } from '@stellar/stellar-sdk'; + +export type DecodedMemo = + | { type: 'MEMO_NONE'; value: null } + | { type: 'MEMO_TEXT'; value: string } + | { type: 'MEMO_ID'; value: string } + | { type: 'MEMO_HASH'; value: string } + | { type: 'MEMO_RETURN'; value: string }; + +/** + * decodeMemo + * + * Decodes a Stellar Memo object back to its original value where possible. + * + * - MEMO_NONE → null + * - MEMO_TEXT → original string + * - MEMO_ID → numeric value as string + * - MEMO_HASH → hex digest string (cannot be reversed — returned as-is) + * - MEMO_RETURN → hex digest string (cannot be reversed — returned as-is) + * + * @param memo - A Stellar SDK Memo object + * @returns DecodedMemo object with type and value + * + * @example + * decodeMemo(Memo.none()) + * // { type: 'MEMO_NONE', value: null } + * + * decodeMemo(Memo.text('hello')) + * // { type: 'MEMO_TEXT', value: 'hello' } + * + * decodeMemo(Memo.id('12345')) + * // { type: 'MEMO_ID', value: '12345' } + * + * decodeMemo(Memo.hash(Buffer.alloc(32))) + * // { type: 'MEMO_HASH', value: '0000...0000' } + */ +export function decodeMemo(memo: Memo): DecodedMemo { + switch (memo.type as MemoType) { + case 'none': + return { type: 'MEMO_NONE', value: null }; + + case 'text': + return { type: 'MEMO_TEXT', value: memo.value as string }; + + case 'id': + return { type: 'MEMO_ID', value: String(memo.value) }; + + case 'hash': { + const hashValue = memo.value as Buffer; + return { + type: 'MEMO_HASH', + value: Buffer.isBuffer(hashValue) + ? hashValue.toString('hex') + : String(hashValue), + }; + } + + case 'return': { + const returnValue = memo.value as Buffer; + return { + type: 'MEMO_RETURN', + value: Buffer.isBuffer(returnValue) + ? returnValue.toString('hex') + : String(returnValue), + }; + } + + default: + return { type: 'MEMO_NONE', value: null }; + } +} \ No newline at end of file diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 04d4874..d6fcad8 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1,2 +1,26 @@ +import { Asset, Operation } from '@stellar/stellar-sdk'; +import { isValidPublicKey, isValidAmount } from '../utils/validation'; +import { ValidationError } from '../utils/errors'; + +export { decodeMemo, type DecodedMemo } from './builder'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function buildMultisigTransaction(..._args: unknown[]): unknown { return undefined; } + +export function buildPaymentOp({ + destination, + amount, + asset = Asset.native(), +}: { + destination: string; + amount: string; + asset?: Asset; +}): ReturnType { + if (!isValidPublicKey(destination)) { + throw new ValidationError('destination', `Invalid Stellar public key: ${destination}`); + } + if (!isValidAmount(amount)) { + throw new ValidationError('amount', `Invalid amount: ${amount}. Must be a positive decimal string with up to 7 decimal places.`); + } + return Operation.payment({ destination, asset, amount }); +} diff --git a/tests/unit/transactions/decodeMemo.test.ts b/tests/unit/transactions/decodeMemo.test.ts new file mode 100644 index 0000000..6d6de39 --- /dev/null +++ b/tests/unit/transactions/decodeMemo.test.ts @@ -0,0 +1,47 @@ +import { Memo } from '@stellar/stellar-sdk'; +import { decodeMemo } from '../../../src/transactions/builder'; + +describe('decodeMemo', () => { + it('decodes MEMO_NONE to null', () => { + const result = decodeMemo(Memo.none()); + expect(result.type).toBe('MEMO_NONE'); + expect(result.value).toBeNull(); + }); + + it('decodes MEMO_TEXT to original string', () => { + const result = decodeMemo(Memo.text('hello world')); + expect(result.type).toBe('MEMO_TEXT'); + expect(result.value).toBe('hello world'); + }); + + it('decodes MEMO_TEXT with empty string', () => { + const result = decodeMemo(Memo.text('')); + expect(result.type).toBe('MEMO_TEXT'); + expect(result.value).toBe(''); + }); + + it('decodes MEMO_ID to numeric string', () => { + const result = decodeMemo(Memo.id('12345')); + expect(result.type).toBe('MEMO_ID'); + expect(result.value).toBe('12345'); + }); + + it('decodes MEMO_HASH to hex string', () => { + const hashBuffer = Buffer.alloc(32, 0xab); + const result = decodeMemo(Memo.hash(hashBuffer)); + expect(result.type).toBe('MEMO_HASH'); + expect(result.value).toBe('ab'.repeat(32)); + }); + + it('decodes MEMO_HASH of all zeros', () => { + const hashBuffer = Buffer.alloc(32, 0); + const result = decodeMemo(Memo.hash(hashBuffer)); + expect(result.type).toBe('MEMO_HASH'); + expect(result.value).toBe('00'.repeat(32)); + }); + + it('is exported from the public index', async () => { + const mod = await import('../../../src/index'); + expect(mod.decodeMemo).toBeDefined(); + }); +});