diff --git a/etc/sdk-stellar.api.md b/etc/sdk-stellar.api.md index dddc646..447e42c 100644 --- a/etc/sdk-stellar.api.md +++ b/etc/sdk-stellar.api.md @@ -714,6 +714,8 @@ export interface StealthMetaAddress { // @public export interface StealthPayment { amount: string; + asset?: string; + assetIssuer?: string; metaAddress: string; } diff --git a/etc/sdk.api.md b/etc/sdk.api.md index 39bd528..04a05d1 100644 --- a/etc/sdk.api.md +++ b/etc/sdk.api.md @@ -670,10 +670,10 @@ export abstract class WraithWalletError extends WraithError { // Warnings were encountered during analysis: // -// dist/unified-DZ7PcCQN.d.ts:166:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_2" needs to be exported by the entry point index.d.ts -// dist/unified-DZ7PcCQN.d.ts:171:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement$1" needs to be exported by the entry point index.d.ts -// dist/unified-DZ7PcCQN.d.ts:176:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_3" needs to be exported by the entry point index.d.ts -// dist/unified-DZ7PcCQN.d.ts:181:5 - (ae-forgotten-export) The symbol "MatchedStealthCell" needs to be exported by the entry point index.d.ts +// dist/unified-BQ_-bZ0t.d.ts:166:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_2" needs to be exported by the entry point index.d.ts +// dist/unified-BQ_-bZ0t.d.ts:171:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement$1" needs to be exported by the entry point index.d.ts +// dist/unified-BQ_-bZ0t.d.ts:176:5 - (ae-forgotten-export) The symbol "MatchedAnnouncement_3" needs to be exported by the entry point index.d.ts +// dist/unified-BQ_-bZ0t.d.ts:181:5 - (ae-forgotten-export) The symbol "MatchedStealthCell" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/chains/stellar/tx-builder.ts b/src/chains/stellar/tx-builder.ts index d8f953e..364a499 100644 --- a/src/chains/stellar/tx-builder.ts +++ b/src/chains/stellar/tx-builder.ts @@ -73,7 +73,17 @@ export function buildBatchSendTx(params: BuildBatchSendTxParams): BuildBatchSend batchSenderContract !== undefined && payments.length >= batchSenderThreshold; // Load Stellar SDK dynamically (peer dependency) - const { TransactionBuilder, Operation, Memo, Asset } = require('@stellar/stellar-sdk'); + const { + TransactionBuilder, + Operation, + Memo, + Asset, + Contract, + Address, + StrKey, + nativeToScVal, + xdr, + } = require('@stellar/stellar-sdk'); // Calculate total fee with scaling // Fee scales with operation count to ensure timely inclusion @@ -98,13 +108,55 @@ export function buildBatchSendTx(params: BuildBatchSendTxParams): BuildBatchSend } if (useBatchSender && batchSenderContract) { - // Use stealth-batch-sender contract for large batches - // This would call a Soroban contract that handles the batch efficiently - // For now, this is a placeholder - the actual contract implementation - // would be added when the contract is deployed - throw new Error( - 'stealth-batch-sender contract integration not yet implemented. ' + - 'Please provide batchSenderContract only when the contract is deployed.', + if (!StrKey.isValidContract(batchSenderContract)) { + throw new Error(`Invalid batchSenderContract: expected a Soroban contract address`); + } + + const accountId = sourceAccount.accountId(); + const assets = payments.map((payment) => resolvePaymentAsset({ Asset, StrKey }, payment)); + const firstAsset = assets[0]; + if ( + assets.some( + (asset: any) => + asset.contractId(networkPassphrase) !== firstAsset.contractId(networkPassphrase), + ) + ) { + throw new Error('Batch sender payments must all use the same asset'); + } + + const tokenContract = firstAsset.contractId(networkPassphrase); + const contract = new Contract(batchSenderContract); + const transfers = xdr.ScVal.scvVec( + stealthAddresses.map((stealth: GeneratedStealthAddress, index: number) => { + const transferEntries = [ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('stealth_address'), + val: new Address(stealth.stealthAddress).toScVal(), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('amount'), + val: nativeToScVal(toStroops(payments[index].amount), { type: 'i128' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('ephemeral_pub_key'), + val: xdr.ScVal.scvBytes(Buffer.from(stealth.ephemeralPubKey)), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('metadata'), + val: xdr.ScVal.scvBytes(Buffer.from([stealth.viewTag])), + }), + ]; + return xdr.scvSortedMap(transferEntries); + }), + ); + + builder = builder.addOperation( + contract.call( + 'batch_send', + new Address(accountId).toScVal(), + transfers, + new Address(tokenContract).toScVal(), + ), ); } else { // Build individual payment operations @@ -115,7 +167,7 @@ export function buildBatchSendTx(params: BuildBatchSendTxParams): BuildBatchSend builder = builder.addOperation( Operation.payment({ destination: stealth.stealthAddress, - asset: Asset.native(), + asset: resolvePaymentAsset({ Asset, StrKey }, payment), amount: payment.amount, }), ); @@ -136,6 +188,27 @@ export function buildBatchSendTx(params: BuildBatchSendTxParams): BuildBatchSend }; } +function resolvePaymentAsset( + sdk: { Asset: any; StrKey: any }, + payment: { asset?: string; assetIssuer?: string }, +): any { + const { Asset, StrKey } = sdk; + const asset = payment.asset; + if (!asset || asset === 'native' || asset === 'XLM') return Asset.native(); + if (!payment.assetIssuer || !StrKey.isValidEd25519PublicKey(payment.assetIssuer)) { + throw new Error(`assetIssuer is required and must be a valid account for asset "${asset}"`); + } + return new Asset(asset, payment.assetIssuer); +} + +function toStroops(amount: string): bigint { + if (!/^\d+(?:\.\d{1,7})?$/.test(amount) || amount === '0') { + throw new Error(`Invalid payment amount: ${amount}`); + } + const [whole, fraction = ''] = amount.split('.'); + return BigInt(whole) * 10_000_000n + BigInt(fraction.padEnd(7, '0')); +} + /** * Builds announcement data for stealth payments. * This can be used to publish announcements after sending payments. diff --git a/src/chains/stellar/types.ts b/src/chains/stellar/types.ts index b0121f8..bd132e1 100644 --- a/src/chains/stellar/types.ts +++ b/src/chains/stellar/types.ts @@ -274,6 +274,10 @@ export interface StealthPayment { metaAddress: string; /** Amount in XLM (string to preserve precision) */ amount: string; + /** Asset code ("native"/"XLM" or an issued asset code). Defaults to native XLM. */ + asset?: string; + /** Issuer account for an issued asset. Required when asset is not native/XLM. */ + assetIssuer?: string; } /** Parameters for building a batch send transaction. */ diff --git a/test/chains/stellar/tx-builder.test.ts b/test/chains/stellar/tx-builder.test.ts index a7e7c13..f305716 100644 --- a/test/chains/stellar/tx-builder.test.ts +++ b/test/chains/stellar/tx-builder.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from 'vitest'; +import { Asset, Operation, scValToNative } from '@stellar/stellar-sdk'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; import { encodeStealthMetaAddress } from '../../../src/chains/stellar/meta-address'; import { @@ -234,7 +235,7 @@ describe('tx-builder: buildBatchSendTx', () => { ).toThrow('Memo too long'); }); - test('throws error when batchSenderContract is provided but not implemented', () => { + test('builds a native XLM batch-sender contract invocation', () => { const payments: StealthPayment[] = Array.from( { length: DEFAULT_BATCH_SENDER_THRESHOLD }, () => ({ @@ -243,6 +244,72 @@ describe('tx-builder: buildBatchSendTx', () => { }), ); + const result = buildBatchSendTx({ + payments, + sourceAccount, + networkPassphrase, + batchSenderContract: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + }); + const operation = result.transaction.operations[0] as Operation.InvokeHostFunction; + const invocation = operation.func.invokeContract(); + expect(operation.type).toBe('invokeHostFunction'); + expect(invocation.functionName().toString()).toBe('batch_send'); + expect(invocation.args()).toHaveLength(3); + expect(scValToNative(invocation.args()[0])).toBe(sourceAccount.accountId()); + expect(scValToNative(invocation.args()[1])).toHaveLength(payments.length); + const decodedTransfer = scValToNative(invocation.args()[1])[0]; + expect(Object.keys(decodedTransfer)).toEqual([ + 'amount', + 'ephemeral_pub_key', + 'metadata', + 'stealth_address', + ]); + expect(decodedTransfer).toMatchObject({ + amount: 10_000_000n, + }); + expect(decodedTransfer.stealth_address).toMatch(/^G[A-Z2-7]{55}$/); + expect(decodedTransfer.ephemeral_pub_key).toHaveLength(32); + expect(decodedTransfer.metadata).toHaveLength(1); + expect(scValToNative(invocation.args()[2])).toMatch(/^C[A-Z2-7]{55}$/); + expect(result.totalFee).toBe(DEFAULT_BASE_FEE); + expect(result.usedBatchSender).toBe(true); + expect(result.stealthAddresses).toHaveLength(payments.length); + }); + + test('supports the documented issued-asset batch path', () => { + const issuer = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const payments: StealthPayment[] = Array.from( + { length: DEFAULT_BATCH_SENDER_THRESHOLD }, + () => ({ + metaAddress, + amount: '1.25', + asset: 'USDC', + assetIssuer: issuer, + }), + ); + + const result = buildBatchSendTx({ + payments, + sourceAccount, + networkPassphrase, + batchSenderContract: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + }); + const operation = result.transaction.operations[0] as Operation.InvokeHostFunction; + const invocation = operation.func.invokeContract(); + expect(scValToNative(invocation.args()[2])).toBe( + new Asset('USDC', issuer).contractId(networkPassphrase), + ); + expect( + scValToNative(invocation.args()[1]).map((transfer: { amount: bigint }) => transfer.amount), + ).toEqual(payments.map(() => 12_500_000n)); + }); + + test('rejects an invalid batch-sender contract configuration', () => { + const payments: StealthPayment[] = Array.from( + { length: DEFAULT_BATCH_SENDER_THRESHOLD }, + () => ({ metaAddress, amount: '1' }), + ); + expect(() => buildBatchSendTx({ payments, @@ -250,7 +317,28 @@ describe('tx-builder: buildBatchSendTx', () => { networkPassphrase, batchSenderContract: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', }), - ).toThrow('stealth-batch-sender contract integration not yet implemented'); + ).toThrow('Invalid batchSenderContract'); + }); + + test('rejects mixed assets in one contract batch', () => { + const issuer = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const payments: StealthPayment[] = Array.from( + { length: DEFAULT_BATCH_SENDER_THRESHOLD }, + (_, i) => ({ + metaAddress, + amount: '1', + ...(i === 0 ? { asset: 'USDC', assetIssuer: issuer } : {}), + }), + ); + + expect(() => + buildBatchSendTx({ + payments, + sourceAccount, + networkPassphrase, + batchSenderContract: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + }), + ).toThrow('same asset'); }); test('does not use batch sender below threshold', () => {