From bc62080db899ee24ad55d6ba9f394bd4ac1971b3 Mon Sep 17 00:00:00 2001 From: bbkenny Date: Fri, 29 May 2026 18:40:01 +0100 Subject: [PATCH] feat: implement Stellar custom-asset (issued token) send builder --- .gitignore | 1 + pnpm-workspace.yaml | 4 + src/chains/stellar/builders.ts | 155 +++++++++++++++++++++++++++ src/chains/stellar/index.ts | 2 + test/chains/stellar/builders.test.ts | 91 ++++++++++++++++ 5 files changed, 253 insertions(+) create mode 100644 pnpm-workspace.yaml create mode 100644 src/chains/stellar/builders.ts create mode 100644 test/chains/stellar/builders.test.ts diff --git a/.gitignore b/.gitignore index e87a9c5..2ac727f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ reference/ node_modules/ dist/ *.tsbuildinfo +package-lock.json diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..255b5cd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + bufferutil: set this to true or false + esbuild: set this to true or false + utf-8-validate: set this to true or false diff --git a/src/chains/stellar/builders.ts b/src/chains/stellar/builders.ts new file mode 100644 index 0000000..5f02e04 --- /dev/null +++ b/src/chains/stellar/builders.ts @@ -0,0 +1,155 @@ +import { + Asset, + Operation, + Claimant, + TransactionBuilder, + Account, + Contract, + Address, + nativeToScVal, + xdr, +} from '@stellar/stellar-sdk'; +import { SCHEME_ID } from './constants'; +import { GeneratedStealthAddress } from './types'; + +/** + * Options for building a Stellar stealth payment transaction. + */ +export interface BuildStealthPaymentOptions { + /** The public key (G...) of the sender. */ + sender: string; + /** The current sequence number of the sender account. */ + sequence: string; + /** The generated stealth address result (address, ephemeral key, view tag). */ + stealthResult: GeneratedStealthAddress; + /** The amount to send (as a string, e.g., "100.0"). */ + amount: string; + /** The asset to send. Defaults to native XLM. */ + asset?: Asset; + /** The network passphrase (e.g., Testnet or Public). */ + networkPassphrase: string; + /** The base fee for the transaction (in stroops). Defaults to "100". */ + fee?: string; + /** Whether the stealth account already exists (only relevant for XLM payments). */ + stealthExists?: boolean; +} + +/** + * Builds a Stellar transaction that sends funds to a stealth address. + * + * For native XLM: + * - If the stealth account doesn't exist, it uses CreateAccount. + * - If it exists, it uses Payment. + * + * For non-native assets (Issued Tokens): + * - It uses CreateClaimableBalance. This allows sending assets to a stealth + * address even if it doesn't have a trustline yet. + * + * @param options Transaction building options. + * @returns The built Transaction object. + */ +export function buildStealthPayment(options: BuildStealthPaymentOptions) { + const { + sender, + sequence, + stealthResult, + amount, + asset = Asset.native(), + networkPassphrase, + fee = '100', + stealthExists = false, + } = options; + + const source = new Account(sender, sequence); + const builder = new TransactionBuilder(source, { + fee, + networkPassphrase, + }).setTimeout(180); + + if (asset.isNative()) { + if (stealthExists) { + builder.addOperation( + Operation.payment({ + destination: stealthResult.stealthAddress, + asset, + amount, + }), + ); + } else { + builder.addOperation( + Operation.createAccount({ + destination: stealthResult.stealthAddress, + startingBalance: amount, + }), + ); + } + } else { + // For custom assets, use Claimable Balance to bypass trustline requirements + builder.addOperation( + Operation.createClaimableBalance({ + asset, + amount, + claimants: [new Claimant(stealthResult.stealthAddress, Claimant.predicateUnconditional())], + }), + ); + } + + return builder.build(); +} + +/** + * Options for building a Soroban announcement transaction. + */ +export interface BuildAnnouncementOptions { + /** The public key (G...) of the sender. */ + sender: string; + /** The current sequence number of the sender account. */ + sequence: string; + /** The generated stealth address result. */ + stealthResult: GeneratedStealthAddress; + /** The address of the Wraith Announcer contract. */ + announcerContract: string; + /** The network passphrase. */ + networkPassphrase: string; + /** The base fee. Defaults to "100". */ + fee?: string; +} + +/** + * Builds a Soroban transaction that announces a stealth payment. + * + * This should be called alongside buildStealthPayment so that recipients + * can detect the payment via events. + * + * @param options Transaction building options. + * @returns The built Transaction object (pre-simulation). + */ +export function buildStealthAnnouncement(options: BuildAnnouncementOptions) { + const { + sender, + sequence, + stealthResult, + announcerContract, + networkPassphrase, + fee = '100', + } = options; + + const source = new Account(sender, sequence); + const contract = new Contract(announcerContract); + + return new TransactionBuilder(source, { + fee, + networkPassphrase, + }) + .addOperation( + contract.call( + 'announce', + nativeToScVal(SCHEME_ID, { type: 'u32' }), + new Address(stealthResult.stealthAddress).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)), + xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])), + ), + ) + .setTimeout(180) + .build(); +} diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index f8a02db..b64a8e4 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -4,6 +4,8 @@ export { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-addre export { generateStealthAddress, computeSharedSecret, computeViewTag } from './stealth'; export { checkStealthAddress, scanAnnouncements, scanAnnouncementsStream } from './scan'; export { deriveStealthPrivateScalar, signStellarTransaction } from './spend'; +export { buildStealthPayment, buildStealthAnnouncement } from './builders'; +export type { BuildStealthPaymentOptions, BuildAnnouncementOptions } from './builders'; export { seedToScalar, hashToScalar, diff --git a/test/chains/stellar/builders.test.ts b/test/chains/stellar/builders.test.ts new file mode 100644 index 0000000..b4a534e --- /dev/null +++ b/test/chains/stellar/builders.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { Asset, Operation, TransactionBuilder, Keypair } from '@stellar/stellar-sdk'; +import { + buildStealthPayment, + buildStealthAnnouncement, +} from '../../../src/chains/stellar/builders'; + +describe('Stellar Builders', () => { + const sender = Keypair.random().publicKey(); + const sequence = '12345'; + const networkPassphrase = 'Test SDF Network ; September 2015'; + const stealthResult = { + stealthAddress: Keypair.random().publicKey(), + ephemeralPubKey: new Uint8Array(32).fill(1), + viewTag: 42, + }; + + describe('buildStealthPayment', () => { + it('builds a native payment if stealth exists', () => { + const tx = buildStealthPayment({ + sender, + sequence, + stealthResult, + amount: '100', + networkPassphrase, + stealthExists: true, + }); + + expect(tx.operations.length).toBe(1); + const op = tx.operations[0] as Operation.Payment; + expect(op.type).toBe('payment'); + expect(op.destination).toBe(stealthResult.stealthAddress); + expect(op.amount).toBe('100.0000000'); + expect(op.asset.isNative()).toBe(true); + }); + + it('builds a createAccount if stealth does not exist (native)', () => { + const tx = buildStealthPayment({ + sender, + sequence, + stealthResult, + amount: '100', + networkPassphrase, + stealthExists: false, + }); + + expect(tx.operations.length).toBe(1); + const op = tx.operations[0] as Operation.CreateAccount; + expect(op.type).toBe('createAccount'); + expect(op.destination).toBe(stealthResult.stealthAddress); + expect(op.startingBalance).toBe('100.0000000'); + }); + + it('builds a createClaimableBalance for custom assets', () => { + const issuer = Keypair.random().publicKey(); + const customAsset = new Asset('USDC', issuer); + const tx = buildStealthPayment({ + sender, + sequence, + stealthResult, + amount: '50', + asset: customAsset, + networkPassphrase, + }); + + expect(tx.operations.length).toBe(1); + const op = tx.operations[0] as Operation.CreateClaimableBalance; + expect(op.type).toBe('createClaimableBalance'); + expect(op.amount).toBe('50.0000000'); + expect(op.asset.code).toBe('USDC'); + expect(op.claimants[0].destination).toBe(stealthResult.stealthAddress); + }); + }); + + describe('buildStealthAnnouncement', () => { + it('builds a contract call for announcement', () => { + const announcerContract = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; + const tx = buildStealthAnnouncement({ + sender, + sequence, + stealthResult, + announcerContract, + networkPassphrase, + }); + + expect(tx.operations.length).toBe(1); + const op = tx.operations[0] as Operation.InvokeHostFunction; + expect(op.type).toBe('invokeHostFunction'); + }); + }); +});