diff --git a/docs/TRANSACTION_SERIALIZATION.md b/docs/TRANSACTION_SERIALIZATION.md new file mode 100644 index 00000000..a6d4cde7 --- /dev/null +++ b/docs/TRANSACTION_SERIALIZATION.md @@ -0,0 +1,38 @@ +# Transaction Serialization + +`serializeTransactionEnvelope` creates a compact base64 JSON envelope around a +transaction XDR string and the network passphrase required to parse it later. It +uses a `WeakMap` cache so repeated serialization of the same transaction object +does not call `toXDR()` more than once for the same option set. + +```ts +import { deserializeTransactionEnvelope, serializeTransactionEnvelope } from 'axionvera-sdk'; + +const encoded = serializeTransactionEnvelope(transaction, { + networkPassphrase: 'Test SDF Network ; September 2015', + includeMetadata: true, +}); + +const { transaction: parsed } = deserializeTransactionEnvelope(encoded, (xdr, networkPassphrase) => + TransactionBuilder.fromXDR(xdr, networkPassphrase) +); +``` + +## Compatibility + +The serialized payload stores: + +- `version` +- `xdr` +- `networkPassphrase` +- optional diagnostics metadata: source, fee, sequence, operation count + +Protocol parsing remains caller-owned through the parser callback. That keeps the +serialization helper independent from a specific Stellar SDK runtime while still +preserving protocol-compatible XDR payloads. + +## Performance Notes + +The cache is scoped by transaction object and serialization options. It reduces +repeat calls to `transaction.toXDR()` and repeated object allocation when SDK code +needs to log, queue, persist, or retry the same transaction. diff --git a/src/serialization/index.ts b/src/serialization/index.ts new file mode 100644 index 00000000..c2da67ff --- /dev/null +++ b/src/serialization/index.ts @@ -0,0 +1,16 @@ +export { + TransactionSerializationCache, + createTransactionEnvelope, + decodeTransactionEnvelope, + deserializeTransactionEnvelope, + encodeTransactionEnvelope, + serializeTransactionEnvelope, + transactionSerializationCache, +} from './transactionSerialization'; +export type { + DeserializedTransactionEnvelope, + SerializableTransactionLike, + SerializedTransactionEnvelope, + TransactionParser, + TransactionSerializationOptions, +} from './transactionSerialization'; diff --git a/src/serialization/transactionSerialization.ts b/src/serialization/transactionSerialization.ts new file mode 100644 index 00000000..0c6c1ffb --- /dev/null +++ b/src/serialization/transactionSerialization.ts @@ -0,0 +1,150 @@ +export interface SerializableTransactionLike { + toXDR(): string; + source?: string; + fee?: string | number; + sequence?: string; + operations?: readonly unknown[]; +} + +export interface TransactionSerializationOptions { + networkPassphrase: string; + includeMetadata?: boolean; +} + +export interface SerializedTransactionEnvelope { + version: 1; + xdr: string; + networkPassphrase: string; + metadata?: { + source?: string; + fee?: string | number; + sequence?: string; + operationCount?: number; + }; +} + +export interface DeserializedTransactionEnvelope { + envelope: SerializedTransactionEnvelope; + transaction: TTransaction; +} + +export type TransactionParser = ( + xdr: string, + networkPassphrase: string +) => TTransaction; + +export class TransactionSerializationCache { + private readonly cache = new WeakMap< + SerializableTransactionLike, + Map + >(); + + get( + transaction: SerializableTransactionLike, + options: TransactionSerializationOptions + ): SerializedTransactionEnvelope | undefined { + return this.cache.get(transaction)?.get(this.getCacheKey(options)); + } + + set( + transaction: SerializableTransactionLike, + options: TransactionSerializationOptions, + envelope: SerializedTransactionEnvelope + ): void { + let transactionCache = this.cache.get(transaction); + + if (!transactionCache) { + transactionCache = new Map(); + this.cache.set(transaction, transactionCache); + } + + transactionCache.set(this.getCacheKey(options), envelope); + } + + clear(transaction: SerializableTransactionLike): void { + this.cache.delete(transaction); + } + + private getCacheKey(options: TransactionSerializationOptions): string { + return `${options.networkPassphrase}:${String(options.includeMetadata ?? false)}`; + } +} + +export const transactionSerializationCache = new TransactionSerializationCache(); + +export function createTransactionEnvelope( + transaction: SerializableTransactionLike, + options: TransactionSerializationOptions, + cache = transactionSerializationCache +): SerializedTransactionEnvelope { + const cached = cache.get(transaction, options); + if (cached) { + return cached; + } + + const envelope: SerializedTransactionEnvelope = { + version: 1, + xdr: transaction.toXDR(), + networkPassphrase: options.networkPassphrase, + }; + + if (options.includeMetadata) { + envelope.metadata = { + source: transaction.source, + fee: transaction.fee, + sequence: transaction.sequence, + operationCount: transaction.operations?.length, + }; + } + + cache.set(transaction, options, envelope); + return envelope; +} + +export function serializeTransactionEnvelope( + transaction: SerializableTransactionLike, + options: TransactionSerializationOptions, + cache = transactionSerializationCache +): string { + return encodeTransactionEnvelope(createTransactionEnvelope(transaction, options, cache)); +} + +export function deserializeTransactionEnvelope( + encodedEnvelope: string, + parser: TransactionParser +): DeserializedTransactionEnvelope { + const envelope = decodeTransactionEnvelope(encodedEnvelope); + + return { + envelope, + transaction: parser(envelope.xdr, envelope.networkPassphrase), + }; +} + +export function encodeTransactionEnvelope(envelope: SerializedTransactionEnvelope): string { + return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64'); +} + +export function decodeTransactionEnvelope(encodedEnvelope: string): SerializedTransactionEnvelope { + try { + const decoded = Buffer.from(encodedEnvelope, 'base64').toString('utf8'); + const envelope = JSON.parse(decoded) as Partial; + + if (envelope.version !== 1 || !envelope.xdr || !envelope.networkPassphrase) { + throw new Error('missing required transaction envelope fields'); + } + + return { + version: 1, + xdr: envelope.xdr, + networkPassphrase: envelope.networkPassphrase, + metadata: envelope.metadata, + }; + } catch (error) { + throw new Error( + `Failed to decode serialized transaction envelope: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} diff --git a/tests/serialization/transactionSerialization.test.ts b/tests/serialization/transactionSerialization.test.ts new file mode 100644 index 00000000..b2cc1b6f --- /dev/null +++ b/tests/serialization/transactionSerialization.test.ts @@ -0,0 +1,94 @@ +import { + TransactionSerializationCache, + createTransactionEnvelope, + decodeTransactionEnvelope, + deserializeTransactionEnvelope, + serializeTransactionEnvelope, +} from '../../src/serialization'; + +function createTransaction(xdr = 'AAAA-test-xdr') { + return { + source: 'G_SOURCE', + fee: '100', + sequence: '123', + operations: [{ type: 'invoke' }], + toXDR: jest.fn(() => xdr), + }; +} + +describe('transaction serialization utilities', () => { + it('serializes protocol payloads with optional metadata', () => { + const transaction = createTransaction(); + + const encoded = serializeTransactionEnvelope(transaction, { + networkPassphrase: 'Test SDF Network ; September 2015', + includeMetadata: true, + }); + const decoded = decodeTransactionEnvelope(encoded); + + expect(decoded).toEqual({ + version: 1, + xdr: 'AAAA-test-xdr', + networkPassphrase: 'Test SDF Network ; September 2015', + metadata: { + source: 'G_SOURCE', + fee: '100', + sequence: '123', + operationCount: 1, + }, + }); + }); + + it('caches repeated serialization for the same transaction and options', () => { + const transaction = createTransaction(); + const cache = new TransactionSerializationCache(); + const options = { + networkPassphrase: 'testnet', + includeMetadata: false, + }; + + const first = createTransactionEnvelope(transaction, options, cache); + const second = createTransactionEnvelope(transaction, options, cache); + + expect(first).toBe(second); + expect(transaction.toXDR).toHaveBeenCalledTimes(1); + + createTransactionEnvelope(transaction, { ...options, includeMetadata: true }, cache); + expect(transaction.toXDR).toHaveBeenCalledTimes(2); + + cache.clear(transaction); + createTransactionEnvelope(transaction, options, cache); + expect(transaction.toXDR).toHaveBeenCalledTimes(3); + }); + + it('deserializes through a caller-provided protocol parser', () => { + const transaction = createTransaction('AAAA-roundtrip'); + const encoded = serializeTransactionEnvelope(transaction, { + networkPassphrase: 'mainnet', + }); + const parser = jest.fn((xdr: string, networkPassphrase: string) => ({ + parsedXdr: xdr, + networkPassphrase, + })); + + const result = deserializeTransactionEnvelope(encoded, parser); + + expect(parser).toHaveBeenCalledWith('AAAA-roundtrip', 'mainnet'); + expect(result.transaction).toEqual({ + parsedXdr: 'AAAA-roundtrip', + networkPassphrase: 'mainnet', + }); + expect(result.envelope.xdr).toBe('AAAA-roundtrip'); + }); + + it('rejects malformed or incomplete envelopes', () => { + expect(() => decodeTransactionEnvelope('not-base64-json')).toThrow( + 'Failed to decode serialized transaction envelope' + ); + + const incomplete = Buffer.from(JSON.stringify({ version: 1, xdr: 'AAAA' })).toString('base64'); + expect(() => decodeTransactionEnvelope(incomplete)).toThrow( + 'missing required transaction envelope fields' + ); + }); +});