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
38 changes: 38 additions & 0 deletions docs/TRANSACTION_SERIALIZATION.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions src/serialization/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export {
TransactionSerializationCache,
createTransactionEnvelope,
decodeTransactionEnvelope,
deserializeTransactionEnvelope,
encodeTransactionEnvelope,
serializeTransactionEnvelope,
transactionSerializationCache,
} from './transactionSerialization';
export type {
DeserializedTransactionEnvelope,
SerializableTransactionLike,
SerializedTransactionEnvelope,
TransactionParser,
TransactionSerializationOptions,
} from './transactionSerialization';
150 changes: 150 additions & 0 deletions src/serialization/transactionSerialization.ts
Original file line number Diff line number Diff line change
@@ -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<TTransaction> {
envelope: SerializedTransactionEnvelope;
transaction: TTransaction;
}

export type TransactionParser<TTransaction> = (
xdr: string,
networkPassphrase: string
) => TTransaction;

export class TransactionSerializationCache {
private readonly cache = new WeakMap<
SerializableTransactionLike,
Map<string, SerializedTransactionEnvelope>
>();

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<string, SerializedTransactionEnvelope>();
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<TTransaction>(
encodedEnvelope: string,
parser: TransactionParser<TTransaction>
): DeserializedTransactionEnvelope<TTransaction> {
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<SerializedTransactionEnvelope>;

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)
}`
);
}
}
94 changes: 94 additions & 0 deletions tests/serialization/transactionSerialization.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
Loading