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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ reference/
node_modules/
dist/
*.tsbuildinfo
package-lock.json
4 changes: 4 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -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
155 changes: 155 additions & 0 deletions src/chains/stellar/builders.ts
Original file line number Diff line number Diff line change
@@ -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();
}
2 changes: 2 additions & 0 deletions src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions test/chains/stellar/builders.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});