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
2 changes: 2 additions & 0 deletions etc/sdk-stellar.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,8 @@ export interface StealthMetaAddress {
// @public
export interface StealthPayment {
amount: string;
asset?: string;
assetIssuer?: string;
metaAddress: string;
}

Expand Down
8 changes: 4 additions & 4 deletions etc/sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
91 changes: 82 additions & 9 deletions src/chains/stellar/tx-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
}),
);
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/chains/stellar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
92 changes: 90 additions & 2 deletions test/chains/stellar/tx-builder.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 },
() => ({
Expand All @@ -243,14 +244,101 @@ 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,
sourceAccount,
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', () => {
Expand Down
Loading