From 9fdaf3f8db27cb95a8104fea80a9e8fe1eac7468 Mon Sep 17 00:00:00 2001 From: fadesany Date: Tue, 23 Jun 2026 14:30:26 +0000 Subject: [PATCH] feat(stellar): add StellarBatchBuilder for multi-stealth-payment transactions StellarBatchBuilder accepts multiple stealth payment configs and builds Stellar transactions with payment + manageData announcement pairs. - Groups payments into chunks of 50 (max 100 ops per tx) - Validates total fee against account balance - Splits into multiple transactions if op count > 100 - Encodes announcement data as 34-byte manageData values - Full validation for addresses, ephemeral keys, view tags, amounts --- src/chains/stellar/batch.ts | 328 ++++++++++++++++++++++++++ src/chains/stellar/index.ts | 2 + test/chains/stellar/batch.test.ts | 370 ++++++++++++++++++++++++++++++ 3 files changed, 700 insertions(+) create mode 100644 src/chains/stellar/batch.ts create mode 100644 test/chains/stellar/batch.test.ts diff --git a/src/chains/stellar/batch.ts b/src/chains/stellar/batch.ts new file mode 100644 index 0000000..56d8da4 --- /dev/null +++ b/src/chains/stellar/batch.ts @@ -0,0 +1,328 @@ +import { + TransactionBuilder, + Account, + Operation, + BASE_FEE, + Asset, + StrKey, +} from '@stellar/stellar-sdk'; +import { hexToBytes } from './utils'; +import { SCHEME_ID } from './constants'; + +const MAX_OPS_PER_TX = 100; +const OPS_PER_PAYMENT = 2; +const MAX_PAYMENTS_PER_TX = Math.floor(MAX_OPS_PER_TX / OPS_PER_PAYMENT); + +export interface StealthPaymentConfig { + /** Stellar public key (G...) of the stealth address. */ + destination: string; + /** Amount to send (e.g. "10.5"). */ + amount: string; + /** Asset code ("native", "XLM", or a custom asset code). Defaults to "native". */ + asset?: string; + /** Asset issuer G... address (required for non-native assets). */ + assetIssuer?: string; + /** Hex-encoded 32-byte ephemeral public key. */ + ephemeralPubKey: string; + /** View tag byte (0–255). */ + viewTag: number; + /** Optional caller — defaults to the source account. */ + caller?: string; +} + +export interface BatchConfig { + /** Source account public key (G...). */ + source: string; + /** Current sequence number of the source account. */ + sequence: string; + /** Stellar network passphrase (e.g. Networks.TESTNET). */ + networkPassphrase: string; + /** Fee per operation in stroops. Defaults to BASE_FEE (100). */ + feePerOp?: number; + /** Timeout in seconds. Defaults to 300. */ + timeout?: number; +} + +export interface BuildResult { + /** Transaction envelope XDR (base64) strings, one per transaction. */ + transactions: string[]; + /** Number of transactions built. */ + txCount: number; + /** Total number of stealth payments across all transactions. */ + paymentCount: number; + /** Total fee across all transactions in stroops. */ + totalFee: number; +} + +/** + * Encodes announcement data into a compact binary format suitable for + * storing in a Stellar manageData operation value (max 64 bytes). + * + * Format (34 bytes): + * [0] — schemeId (1 byte) + * [1..32] — ephemeral public key (32 bytes) + * [33] — view tag (1 byte) + */ +export function encodeAnnouncementData(ephemeralPubKey: Uint8Array, viewTag: number): Uint8Array { + const data = new Uint8Array(1 + 32 + 1); + data[0] = SCHEME_ID; + data.set(ephemeralPubKey, 1); + data[33] = viewTag; + return data; +} + +/** + * Decodes announcement data back into its components. + */ +export function decodeAnnouncementData(data: Uint8Array): { + schemeId: number; + ephemeralPubKey: Uint8Array; + viewTag: number; +} { + if (data.length < 34) { + throw new Error(`Invalid announcement data length: expected 34 bytes, got ${data.length}`); + } + const ephPubKey = new Uint8Array(data.slice(1, 33)); + const viewTag = data[33]; + return { + schemeId: data[0], + ephemeralPubKey: ephPubKey, + viewTag, + }; +} + +/** + * Builds Stellar transactions for multiple stealth payments in a batch. + * + * Stellar supports up to 100 operations per transaction with atomic semantics, + * so a batch of N stealth payments produces ceil(N / 50) transactions, each + * containing up to 50 payment+announcement pairs (100 ops). + * + * Usage: + * ```ts + * const builder = new StellarBatchBuilder({ + * source: 'G...', + * sequence: '1234', + * networkPassphrase: Networks.TESTNET, + * }); + * + * builder + * .addPayment({ + * destination: 'G...', + * amount: '10', + * asset: 'native', + * ephemeralPubKey: 'aabb...', + * viewTag: 42, + * }) + * .addPayment({ ... }); + * + * const result = builder.build(); + * // result.transactions — XDR base64 strings ready to sign + * ``` + */ +export class StellarBatchBuilder { + private config: BatchConfig; + private payments: StealthPaymentConfig[] = []; + private _needsBuild = true; + private _cachedResult: BuildResult | null = null; + + constructor(config: BatchConfig) { + this.config = { + feePerOp: Number(BASE_FEE), + timeout: 300, + ...config, + }; + this.validateAddress(config.source, 'source'); + } + + /** + * Validates a Stellar G... address. + */ + private validateAddress(addr: string, label: string): void { + if (!StrKey.isValidEd25519PublicKey(addr)) { + throw new Error(`Invalid ${label} address: ${addr}`); + } + } + + /** + * Validates a single payment config. + */ + private validatePayment(payment: StealthPaymentConfig, index: number): void { + this.validateAddress(payment.destination, `payment[${index}].destination`); + + const ephBytes = hexToBytes(payment.ephemeralPubKey); + if (ephBytes.length !== 32) { + throw new Error( + `payment[${index}].ephemeralPubKey: expected 32 bytes (64 hex chars), got ${ephBytes.length} bytes`, + ); + } + + if (payment.viewTag < 0 || payment.viewTag > 255) { + throw new Error(`payment[${index}].viewTag: expected 0–255, got ${payment.viewTag}`); + } + + const amount = Number(payment.amount); + if (!isFinite(amount) || amount <= 0) { + throw new Error( + `payment[${index}].amount: expected positive number, got "${payment.amount}"`, + ); + } + + if (payment.asset && payment.asset !== 'native' && payment.asset !== 'XLM') { + if (!payment.assetIssuer) { + throw new Error(`payment[${index}].assetIssuer is required for asset "${payment.asset}"`); + } + this.validateAddress(payment.assetIssuer, `payment[${index}].assetIssuer`); + } + } + + /** + * Adds a stealth payment to the batch. + * + * Each payment produces two operations: + * - A payment operation sending assets to the stealth address. + * - A manageData operation storing the announcement data. + */ + addPayment(payment: StealthPaymentConfig): this { + this.validatePayment(payment, this.payments.length); + this.payments.push(payment); + this._needsBuild = true; + return this; + } + + /** + * Returns the number of payments currently in the batch. + */ + get paymentCount(): number { + return this.payments.length; + } + + /** + * Returns the number of operations the batch would produce + * (payments * 2) before splitting. + */ + get operationCount(): number { + return this.payments.length * OPS_PER_PAYMENT; + } + + /** + * Returns the number of transactions the batch will split into. + */ + get expectedTransactionCount(): number { + if (this.payments.length === 0) return 0; + return Math.ceil(this.payments.length / MAX_PAYMENTS_PER_TX); + } + + /** + * Validates that the source account balance can cover the total fee. + * + * @param balanceXlm Account XLM balance as a string (e.g. "100.5"). + * @throws If the total fee (converted to XLM) exceeds the balance. + */ + validateBalance(balanceXlm: string): void { + const feePerOp = this.config.feePerOp!; + const totalOps = this.operationCount; + const totalFeeStroops = totalOps * feePerOp; + const totalFeeXlm = totalFeeStroops / 10_000_000; + + const balance = Number(balanceXlm); + if (!isFinite(balance) || balance < 0) { + throw new Error(`Invalid balance: "${balanceXlm}"`); + } + + if (totalFeeXlm > balance) { + throw new Error( + `Insufficient balance: ${totalFeeXlm} XLM fee required but account has ${balanceXlm} XLM`, + ); + } + } + + /** + * Builds the transaction(s) for all added payments. + * + * Groups payments into chunks of at most `MAX_PAYMENTS_PER_TX` (50), + * producing one Stellar transaction per chunk. Each transaction includes + * the payment operations followed by announcement manageData operations + * for every recipient in the chunk. + * + * @returns BuildResult containing the XDR base64-encoded transactions. + */ + build(): BuildResult { + if (!this._needsBuild && this._cachedResult) { + return this._cachedResult; + } + + if (this.payments.length === 0) { + throw new Error('No payments added to the batch'); + } + + const feePerOp = this.config.feePerOp!; + const timeout = this.config.timeout!; + const sourceStr = this.config.source; + const chunks: StealthPaymentConfig[][] = []; + + for (let i = 0; i < this.payments.length; i += MAX_PAYMENTS_PER_TX) { + chunks.push(this.payments.slice(i, i + MAX_PAYMENTS_PER_TX)); + } + + const transactions: string[] = []; + let seqNum = BigInt(this.config.sequence); + + for (const chunk of chunks) { + const sourceAccount = new Account(sourceStr, seqNum.toString()); + const opsCount = chunk.length * OPS_PER_PAYMENT; + const fee = String(opsCount * feePerOp); + + const builder = new TransactionBuilder(sourceAccount, { + fee, + networkPassphrase: this.config.networkPassphrase, + }); + + for (let i = 0; i < chunk.length; i++) { + const payment = chunk[i]; + + const asset = + payment.asset && payment.asset !== 'native' && payment.asset !== 'XLM' + ? new Asset(payment.asset, payment.assetIssuer!) + : Asset.native(); + + builder.addOperation( + Operation.payment({ + destination: payment.destination, + asset, + amount: payment.amount, + }), + ); + + const ephBytes = hexToBytes(payment.ephemeralPubKey); + const annData = encodeAnnouncementData(ephBytes, payment.viewTag); + + builder.addOperation( + Operation.manageData({ + name: `wraith:ann:${i}`, + value: Buffer.from(annData), + }), + ); + } + + builder.setTimeout(timeout); + const tx = builder.build(); + transactions.push(tx.toEnvelope().toXDR('base64')); + + seqNum++; + } + + const totalOps = this.payments.length * OPS_PER_PAYMENT; + const totalFee = totalOps * feePerOp; + + this._cachedResult = { + transactions, + txCount: transactions.length, + paymentCount: this.payments.length, + totalFee, + }; + this._needsBuild = false; + + return this._cachedResult; + } +} diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 8ef51b4..2f59249 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -47,6 +47,8 @@ export { } from './event-filters'; export type { SorobanEventFilter, SorobanTopicMatcher } from './event-filters'; export { DEPLOYMENTS, getDeployment } from './deployments'; +export { StellarBatchBuilder, encodeAnnouncementData, decodeAnnouncementData } from './batch'; +export type { StealthPaymentConfig, BatchConfig, BuildResult } from './batch'; export type { StellarChainDeployment } from './deployments'; export type { HexString, diff --git a/test/chains/stellar/batch.test.ts b/test/chains/stellar/batch.test.ts new file mode 100644 index 0000000..12f382e --- /dev/null +++ b/test/chains/stellar/batch.test.ts @@ -0,0 +1,370 @@ +import { describe, test, expect } from 'vitest'; +import { Networks } from '@stellar/stellar-sdk'; +import { + StellarBatchBuilder, + encodeAnnouncementData, + decodeAnnouncementData, +} from '../../../src/chains/stellar/batch'; +import type { StealthPaymentConfig, BatchConfig } from '../../../src/chains/stellar/batch'; +import { SCHEME_ID } from '../../../src/chains/stellar/constants'; + +const SOURCE = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const DEST = 'GCVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVH7N'; +const DEST2 = 'GC53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XUGE'; +const EPH_PUB_KEY = 'aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd'; +const VIEW_TAG = 42; + +function makePayment(overrides?: Partial): StealthPaymentConfig { + return { + destination: DEST, + amount: '10', + asset: 'native', + ephemeralPubKey: EPH_PUB_KEY, + viewTag: VIEW_TAG, + ...overrides, + }; +} + +function makeConfig(overrides?: Partial): BatchConfig { + return { + source: SOURCE, + sequence: '1234', + networkPassphrase: Networks.TESTNET, + ...overrides, + }; +} + +const ephBytes = Buffer.from(EPH_PUB_KEY, 'hex'); + +describe('encodeAnnouncementData / decodeAnnouncementData', () => { + test('roundtrip', () => { + const encoded = encodeAnnouncementData(ephBytes, VIEW_TAG); + expect(encoded.length).toBe(34); + expect(encoded[0]).toBe(SCHEME_ID); + + const decoded = decodeAnnouncementData(encoded); + expect(decoded.schemeId).toBe(SCHEME_ID); + expect(Buffer.from(decoded.ephemeralPubKey).toString('hex')).toBe(EPH_PUB_KEY); + expect(decoded.viewTag).toBe(VIEW_TAG); + }); + + test('roundtrip with different values', () => { + const eph = '1122334411223344112233441122334411223344112233441122334411223344'; + const tag = 200; + const ephBytes2 = Buffer.from(eph, 'hex'); + const encoded = encodeAnnouncementData(ephBytes2, tag); + expect(encoded.length).toBe(34); + + const decoded = decodeAnnouncementData(encoded); + expect(decoded.schemeId).toBe(SCHEME_ID); + expect(Buffer.from(decoded.ephemeralPubKey).toString('hex')).toBe(eph); + expect(decoded.viewTag).toBe(tag); + }); + + test('throws on short data', () => { + expect(() => decodeAnnouncementData(new Uint8Array(10))).toThrow( + 'Invalid announcement data length', + ); + expect(() => decodeAnnouncementData(new Uint8Array(33))).toThrow( + 'Invalid announcement data length', + ); + }); +}); + +describe('StellarBatchBuilder', () => { + test('builds a single payment transaction', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + const result = builder.build(); + + expect(result.transactions).toHaveLength(1); + expect(result.txCount).toBe(1); + expect(result.paymentCount).toBe(1); + expect(result.totalFee).toBe(200); // 2 ops * 100 stroops + expect(typeof result.transactions[0]).toBe('string'); + expect(result.transactions[0].length).toBeGreaterThan(100); + }); + + test('builds multiple payments in one transaction', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment({ destination: DEST })); + builder.addPayment(makePayment({ destination: DEST2 })); + const result = builder.build(); + + expect(result.transactions).toHaveLength(1); + expect(result.paymentCount).toBe(2); + expect(result.totalFee).toBe(400); // 4 ops * 100 stroops + }); + + test('splits into multiple transactions when exceeding 50 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 60; i++) { + builder.addPayment(makePayment({ destination: DEST })); + } + expect(builder.expectedTransactionCount).toBe(2); + + const result = builder.build(); + expect(result.transactions).toHaveLength(2); + expect(result.paymentCount).toBe(60); + + // First tx: 50 payments * 2 ops = 100 ops, second: 10 payments * 2 = 20 ops + const totalFee = 60 * 2 * 100; + expect(result.totalFee).toBe(totalFee); + + // Verify splitting boundary + expect(builder.paymentCount).toBe(60); + expect(builder.operationCount).toBe(120); + }); + + test('splits exactly at 51 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 51; i++) { + builder.addPayment(makePayment()); + } + expect(builder.expectedTransactionCount).toBe(2); + const result = builder.build(); + expect(result.transactions).toHaveLength(2); + expect(result.paymentCount).toBe(51); + }); + + test('single tx at exactly 50 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 50; i++) { + builder.addPayment(makePayment()); + } + expect(builder.expectedTransactionCount).toBe(1); + const result = builder.build(); + expect(result.transactions).toHaveLength(1); + expect(result.paymentCount).toBe(50); + }); + + test('splits at 101 payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + for (let i = 0; i < 101; i++) { + builder.addPayment(makePayment()); + } + expect(builder.expectedTransactionCount).toBe(3); + const result = builder.build(); + expect(result.transactions).toHaveLength(3); + expect(result.paymentCount).toBe(101); + }); + + test('caches build result', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + const r1 = builder.build(); + const r2 = builder.build(); + expect(r1).toBe(r2); + }); + + test('invalidates cache after addPayment', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + const r1 = builder.build(); + builder.addPayment(makePayment({ destination: DEST2 })); + const r2 = builder.build(); + expect(r1.paymentCount).toBe(1); + expect(r2.paymentCount).toBe(2); + expect(r1.transactions).not.toEqual(r2.transactions); + }); + + test('throws on empty batch', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.build()).toThrow('No payments added'); + }); + + test('expectedTransactionCount is 0 for empty builder', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(builder.expectedTransactionCount).toBe(0); + expect(builder.paymentCount).toBe(0); + expect(builder.operationCount).toBe(0); + }); + + test('paymentCount and operationCount reflect added payments', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(builder.paymentCount).toBe(0); + expect(builder.operationCount).toBe(0); + builder.addPayment(makePayment()); + expect(builder.paymentCount).toBe(1); + expect(builder.operationCount).toBe(2); + builder.addPayment(makePayment()); + expect(builder.paymentCount).toBe(2); + expect(builder.operationCount).toBe(4); + }); +}); + +describe('validation', () => { + test('rejects invalid source address', () => { + expect(() => new StellarBatchBuilder(makeConfig({ source: 'invalid' }))).toThrow( + 'Invalid source address', + ); + }); + + test('rejects invalid destination address', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ destination: 'invalid' }))).toThrow( + 'destination', + ); + }); + + test('rejects invalid ephemeral pub key (wrong length)', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ ephemeralPubKey: 'aabb' }))).toThrow( + 'expected 32 bytes', + ); + }); + + test('rejects invalid view tag (>255)', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ viewTag: 300 }))).toThrow( + 'viewTag: expected 0–255', + ); + }); + + test('rejects invalid view tag (<0)', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ viewTag: -1 }))).toThrow( + 'viewTag: expected 0–255', + ); + }); + + test('rejects negative amount', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ amount: '-10' }))).toThrow( + 'expected positive number', + ); + }); + + test('rejects zero amount', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ amount: '0' }))).toThrow( + 'expected positive number', + ); + }); + + test('rejects non-numeric amount', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ amount: 'abc' }))).toThrow( + 'expected positive number', + ); + }); + + test('rejects custom asset without issuer', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ asset: 'USDC' }))).toThrow( + 'assetIssuer is required', + ); + }); + + test('accepts custom asset with issuer', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => + builder.addPayment( + makePayment({ + asset: 'USDC', + assetIssuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }), + ), + ).not.toThrow(); + }); + + test('rejects custom asset with invalid issuer', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => + builder.addPayment( + makePayment({ + asset: 'USDC', + assetIssuer: 'invalid', + }), + ), + ).toThrow('assetIssuer'); + }); + + test('accepts XLM as asset alias', () => { + const builder = new StellarBatchBuilder(makeConfig()); + expect(() => builder.addPayment(makePayment({ asset: 'XLM' }))).not.toThrow(); + }); +}); + +describe('validateBalance', () => { + test('passes with sufficient balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('1')).not.toThrow(); + }); + + test('passes with exact balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + // 2 ops * 100 stroops = 200 stroops = 0.00002 XLM + expect(() => builder.validateBalance('0.00002')).not.toThrow(); + }); + + test('throws with insufficient balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('0.00001')).toThrow('Insufficient balance'); + }); + + test('throws with invalid balance string', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('abc')).toThrow('Invalid balance'); + }); + + test('throws with negative balance', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + expect(() => builder.validateBalance('-1')).toThrow('Invalid balance'); + }); + + test('accounts for multiple payments correctly', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()); + builder.addPayment(makePayment()); + // 4 ops * 100 = 400 stroops = 0.00004 XLM + expect(() => builder.validateBalance('0.00004')).not.toThrow(); + expect(() => builder.validateBalance('0.00003')).toThrow('Insufficient balance'); + }); +}); + +describe('custom fee per op', () => { + test('uses custom fee per operation', () => { + const builder = new StellarBatchBuilder(makeConfig({ feePerOp: 200 })); + builder.addPayment(makePayment()); + builder.addPayment(makePayment()); + const result = builder.build(); + // 4 ops * 200 = 800 stroops + expect(result.totalFee).toBe(800); + }); + + test('validateBalance accounts for custom fee', () => { + const builder = new StellarBatchBuilder(makeConfig({ feePerOp: 1000 })); + builder.addPayment(makePayment()); + // 2 ops * 1000 = 2000 stroops = 0.0002 XLM + expect(() => builder.validateBalance('0.0002')).not.toThrow(); + expect(() => builder.validateBalance('0.00019')).toThrow('Insufficient balance'); + }); +}); + +describe('chained addPayment calls', () => { + test('supports method chaining', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment(makePayment()).addPayment(makePayment()).addPayment(makePayment()); + expect(builder.paymentCount).toBe(3); + }); +}); + +describe('non-native asset in transaction', () => { + test('builds transaction with custom asset', () => { + const builder = new StellarBatchBuilder(makeConfig()); + builder.addPayment( + makePayment({ + asset: 'USDC', + assetIssuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }), + ); + const result = builder.build(); + expect(result.transactions).toHaveLength(1); + }); +});