Repository Public
1import { describe, expect, it } from 'vitest';2import fc from 'fast-check';3import { ed25519 } from '@noble/curves/ed25519.js';4import { hmac } from '@noble/hashes/hmac.js';5import { sha256, sha512 } from '@noble/hashes/sha2.js';6import { Account, Asset, Keypair, Memo, MuxedAccount, Networks, Operation, StrKey, TransactionBuilder, type Transaction } from '@stellar/stellar-base';7import {8 buildStellarTx, canonicalBytes, DEFAULT_POLICY, formatUnits, intentHash, isValidAddress, NETWORKS, normalizeAddress, pluginFor,9 STELLAR_PUBNET_PASSPHRASE, stellarDecodeAccount, stellarEncodeAccount, stellarEnvelopeTxid, stellarMemoType, stellarPlugin, toHex,10 type Intent, type KeyHandle, type NetworkDef,11} from '../src/index.ts';1213const net = NETWORKS.stellar as NetworkDef;14const hexToBytes = (h: string) => Uint8Array.from(Buffer.from(h, 'hex'));1516// SEP-0005 "Test 5": BIP-39 seed of the public mnemonic `abandon ×11 about` (empty passphrase) and its published Stellar accounts.17// https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0005.md18const SEP5_SEED = hexToBytes('5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4');19const SEP5 = [20 ["m/44'/148'/0'", 'GB3JDWCQJCWMJ3IILWIGDTQJJC5567PGVEVXSCVPEQOTDN64VJBDQBYX', 'SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ'],21 ["m/44'/148'/1'", 'GDVSYYTUAJ3ACHTPQNSTQBDQ4LDHQCMNY4FCEQH5TJUMSSLWQSTG42MV', 'SCHDCVCWGAKGIMTORV6K5DYYV3BY4WG3RA4M6MCBGJLHUCWU2MC6DL66'],22 ["m/44'/148'/2'", 'GBFPWBTN4AXHPWPTQVQBP4KRZ2YVYYOGRMV2PEYL2OBPPJDP7LECEVHR', 'SAPLVTLUXSDLFRDGCCFLPDZMTCEVMP3ZXTM74EBJCVKZKM34LGQPF7K3'],23] as const;2425/** SLIP-0010 ed25519 (hardened only), independent of the signer's implementation. */26function slip10(seed: Uint8Array, path: string): Uint8Array {27 let I = hmac(sha512, new TextEncoder().encode('ed25519 seed'), seed);28 for (const seg of path.split('/').slice(1)) {29 if (!seg.endsWith("'")) throw new Error('hardened only');30 const data = new Uint8Array(37);31 data.set(I.subarray(0, 32), 1);32 new DataView(data.buffer).setUint32(33, (Number(seg.slice(0, -1)) | 0x80000000) >>> 0);33 I = hmac(sha512, I.subarray(32), data);34 }35 return I.slice(0, 32);36}37const PRIV = slip10(SEP5_SEED, stellarPlugin.path(net));38const PUB = ed25519.getPublicKey(PRIV);39const FROM = SEP5[0][1], TO = SEP5[1][1];40const key: KeyHandle = { curve: 'ed25519', publicKey: PUB, signMessage: (m) => ed25519.sign(m, PRIV) };4142const now = 1_800_000_000;43const base: Intent = {44 v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: now, expiresAt: now + 120, policyVersion: 3,45 network: 'stellar', chainRef: STELLAR_PUBNET_PASSPHRASE, asset: 'native', symbol: 'XLM', decimals: 7, amount: '125000000', from: FROM, to: TO, maxFee: '20000',46 x: { op: 'payment', sequence: '2390162185806736', fee: '10112', maxTime: String(now + 180), memoType: 'none' },47};48const withX = (x: Record<string, string>, over: Partial<Intent> = {}): Intent => ({ ...base, ...over, x: { ...base.x!, ...x } });4950/** The same transaction built by the official SDK from the same fields. */51function sdkTx(i: Intent): Transaction {52 const x = i.x!;53 const b = new TransactionBuilder(new Account(i.from, (BigInt(x.sequence!) - 1n).toString()), {54 fee: x.fee!, networkPassphrase: Networks.PUBLIC, timebounds: { minTime: 0, maxTime: x.maxTime! },55 memo: x.memoType === 'text' ? Memo.text(i.memo!) : x.memoType === 'id' ? Memo.id(i.memo!) : Memo.none(),56 });57 const amount = formatUnits(BigInt(i.amount), 7);58 b.addOperation(x.op === 'payment' ? Operation.payment({ destination: i.to, asset: Asset.native(), amount }) : Operation.createAccount({ destination: i.to, startingBalance: amount }));59 return b.build();60}61const sdkTxBytes = (t: Transaction) => new Uint8Array(t.toEnvelope().v1().tx().toXDR());6263describe('stellar: registration', () => {64 it('is registered with the pinned identity, derivation, limits and two sources', () => {65 expect(pluginFor(net)).toBe(stellarPlugin);66 expect(net.chainRef).toBe(Networks.PUBLIC);67 expect(toHex(sha256(new TextEncoder().encode(net.chainRef)))).toBe('7ac33997544e3175d266bd022439b22cdb16508c01163f26e5cb2a3e1045a979'); // pubnet network id68 expect([net.family, net.coinType, net.memo, net.activation, net.native.decimals]).toEqual(['stellar', 148, 'text', '1', 7]);69 expect(stellarPlugin.path(net)).toBe("m/44'/148'/0'");70 expect(() => stellarPlugin.path({ ...net, coinType: 501 })).toThrow();71 expect(new Set(net.rpc.map((u) => new URL(u).host)).size).toBe(2);72 expect(DEFAULT_POLICY.perTx['stellar:XLM']).toBe('3500000000');73 expect(DEFAULT_POLICY.maxFee.stellar).toBe('1000000');74 });75});7677describe('stellar: keys and addresses', () => {78 it('derives the SEP-0005 published accounts for the public test mnemonic', () => {79 for (const [path, address, secret] of SEP5) {80 const priv = slip10(SEP5_SEED, path);81 expect(toHex(priv)).toBe(Keypair.fromSecret(secret).rawSecretKey().toString('hex'));82 expect(stellarPlugin.address(net, ed25519.getPublicKey(priv))).toBe(address);83 }84 expect(stellarPlugin.address(net, PUB)).toBe('GB3JDWCQJCWMJ3IILWIGDTQJJC5567PGVEVXSCVPEQOTDN64VJBDQBYX');85 });86 it('encodes and decodes like the official SDK (property)', () => {87 fc.assert(fc.property(fc.uint8Array({ minLength: 32, maxLength: 32 }), (k) => {88 const a = stellarEncodeAccount(k);89 return a === StrKey.encodeEd25519PublicKey(Buffer.from(k)) && toHex(stellarDecodeAccount(a)) === toHex(k) && normalizeAddress('stellar', a) === a;90 }));91 });92 it('refuses everything that is not a canonical G… account id', () => {93 const muxed = new MuxedAccount(new Account(TO, '0'), '42').accountId();94 expect(muxed).toMatch(/^M/);95 expect(() => normalizeAddress('stellar', muxed)).toThrow(/muxed/);96 const bad = [97 TO.toLowerCase(), TO.slice(0, -1), TO + 'A', TO.slice(0, -1) + (TO.endsWith('A') ? 'B' : 'A'), 'X' + TO.slice(1), TO.replace('G', 'g'), ` ${TO}`.slice(0, 56),98 SEP5[1][2], // a secret seed is not an address99 StrKey.encodeContract(Buffer.alloc(32, 7)), StrKey.encodePreAuthTx(Buffer.alloc(32, 7)), StrKey.encodeSha256Hash(Buffer.alloc(32, 7)),100 '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', '',101 ];102 for (const a of bad) expect(isValidAddress('stellar', a), a).toBe(false);103 // every single-character substitution breaks the checksum104 for (let k = 1; k < 56; k += 5) { const c = TO[k] === 'A' ? 'B' : 'A'; expect(isValidAddress('stellar', TO.slice(0, k) + c + TO.slice(k + 1))).toBe(false); }105 expect(normalizeAddress('stellar', ` ${TO} `)).toBe(TO); // the shared entry point trims surrounding whitespace only106 });107});108109describe('stellar: memo rule', () => {110 it('classifies deterministically', () => {111 expect(stellarMemoType(undefined)).toBe('none');112 for (const m of ['0', '1', '123456', '18446744073709551615']) expect(stellarMemoType(m), m).toBe('id');113 for (const m of ['007', '18446744073709551616', '99999999999999999999', '12 34', '-1', '1.5', '+1', '123', 'hello', 'x'.repeat(28), 'é'.repeat(14), '1234567890123456789012345678'])114 expect(stellarMemoType(m), m).toBe('text');115 for (const m of ['', 'x'.repeat(29), 'é'.repeat(15), '12345678901234567890123456789', '\ud800'])116 expect(() => stellarMemoType(m), JSON.stringify(m)).toThrow();117 });118});119120describe('stellar: transaction bytes equal the official SDK', () => {121 const cases: [string, Intent][] = [122 ['payment, no memo', base],123 ['payment, text memo', withX({ memoType: 'text' }, { memo: 'invoice 2026-11' })],124 ['payment, UTF-8 text memo needing XDR padding', withX({ memoType: 'text' }, { memo: 'café ☕' })],125 ['payment, 28-byte text memo', withX({ memoType: 'text' }, { memo: 'x'.repeat(28) })],126 ['payment, leading-zero numeric memo stays text', withX({ memoType: 'text' }, { memo: '007' })],127 ['payment, numeric memo as MEMO_ID', withX({ memoType: 'id' }, { memo: '123456789' })],128 ['payment, largest MEMO_ID', withX({ memoType: 'id' }, { memo: '18446744073709551615' })],129 ['payment, 1 stroop, minimum fee', withX({ fee: '100' }, { amount: '1' })],130 ['payment, int64 max amount and sequence', withX({ sequence: '9223372036854775807' }, { amount: '9223372036854775807' })],131 ['createAccount, 1 XLM', withX({ op: 'createAccount' }, { amount: '10000000' })],132 ['createAccount with MEMO_ID', withX({ op: 'createAccount', memoType: 'id' }, { amount: '25000000', memo: '0' })],133 ];134 for (const [name, i] of cases) it(name, () => {135 const ours = buildStellarTx(i, net), ref = sdkTx(i);136 expect(toHex(ours.tx)).toBe(toHex(sdkTxBytes(ref)));137 expect(toHex(ours.hash)).toBe(ref.hash().toString('hex'));138 expect(toHex(ours.hash)).toBe(toHex(sha256(new Uint8Array(ref.signatureBase()))));139 const { signDocs, describe: d } = stellarPlugin.build(i, net);140 expect(signDocs.map(toHex)).toEqual([toHex(ours.hash)]);141 expect(d.txid).toBe(ref.hash().toString('hex'));142 });143 it('property: any valid field combination', () => {144 const memo = fc.oneof(fc.constant(undefined), fc.bigInt({ min: 0n, max: (1n << 64n) - 1n }).map(String), fc.string({ minLength: 1, maxLength: 9, unit: 'grapheme' }).filter((s) => { try { stellarMemoType(s); return true; } catch { return false; } }));145 fc.assert(fc.property(146 fc.bigInt({ min: 10_000_000n, max: (1n << 63n) - 1n }), fc.bigInt({ min: 1n, max: (1n << 63n) - 1n }), fc.integer({ min: 100, max: 0xffffffff }),147 fc.integer({ min: now + 120, max: now + 900 }), fc.boolean(), memo, fc.uint8Array({ minLength: 32, maxLength: 32 }),148 (amount, sequence, fee, maxTime, create, m, dest) => {149 const i = withX({ op: create ? 'createAccount' : 'payment', sequence: sequence.toString(), fee: String(fee), maxTime: String(maxTime), memoType: stellarMemoType(m) },150 { amount: amount.toString(), maxFee: String(fee), to: stellarEncodeAccount(dest), ...(m === undefined ? {} : { memo: m }) });151 const ours = buildStellarTx(i, net), ref = sdkTx(i);152 return toHex(ours.tx) === toHex(sdkTxBytes(ref)) && toHex(ours.hash) === ref.hash().toString('hex');153 }), { numRuns: 300 });154 });155});156157describe('stellar: signing', () => {158 it('produces the envelope the SDK produces, and the SDK verifies the signature', () => {159 for (const i of [base, withX({ op: 'createAccount', memoType: 'text' }, { amount: '15000000', memo: 'welcome' })]) {160 const signed = stellarPlugin.sign(i, net, key);161 // decode with the SDK: fields, re-encoding and signature162 const tx = TransactionBuilder.fromXDR(signed.raw, Networks.PUBLIC) as Transaction;163 expect(tx.toEnvelope().toXDR('base64')).toBe(signed.raw);164 expect(tx.hash().toString('hex')).toBe(signed.txid);165 expect(tx.source).toBe(i.from);166 expect(tx.fee).toBe(i.x!.fee);167 expect(tx.sequence).toBe(i.x!.sequence);168 expect(tx.timeBounds).toEqual({ minTime: '0', maxTime: i.x!.maxTime });169 expect(tx.ledgerBounds ?? null).toBeNull();170 expect(tx.operations.length).toBe(1);171 const op = tx.operations[0]!;172 expect(op.source).toBeUndefined();173 if (i.x!.op === 'payment') {174 expect(op.type).toBe('payment');175 if (op.type === 'payment') { expect(op.destination).toBe(i.to); expect(op.asset.isNative()).toBe(true); expect(op.amount).toBe('12.5000000'); }176 expect(tx.memo.type).toBe('none');177 } else {178 expect(op.type).toBe('createAccount');179 if (op.type === 'createAccount') { expect(op.destination).toBe(i.to); expect(op.startingBalance).toBe('1.5000000'); }180 expect([tx.memo.type, tx.memo.value?.toString()]).toEqual(['text', 'welcome']);181 }182 expect(tx.signatures.length).toBe(1);183 const kp = Keypair.fromPublicKey(i.from);184 expect(tx.signatures[0]!.hint().toString('hex')).toBe(kp.signatureHint().toString('hex'));185 expect(kp.verify(tx.hash(), tx.signatures[0]!.signature())).toBe(true);186 expect(ed25519.verify(new Uint8Array(tx.signatures[0]!.signature()), hexToBytes(signed.txid), PUB)).toBe(true);187 // the SDK signing the SDK-built transaction with the same seed gives the same bytes (ed25519 is deterministic)188 const ref = sdkTx(i);189 ref.sign(Keypair.fromRawEd25519Seed(Buffer.from(PRIV)));190 expect(ref.toEnvelope().toXDR('base64')).toBe(signed.raw);191 expect(stellarEnvelopeTxid(net, signed.raw)).toBe(signed.txid);192 }193 });194 it('refuses a key that does not control the source, the wrong curve, and a bad signature', () => {195 const other = slip10(SEP5_SEED, "m/44'/148'/1'");196 expect(() => stellarPlugin.sign(base, net, { curve: 'ed25519', publicKey: ed25519.getPublicKey(other), signMessage: (m) => ed25519.sign(m, other) })).toThrow(/does not control/);197 expect(() => stellarPlugin.sign(base, net, { curve: 'secp256k1', publicKey: new Uint8Array(33), signDigest: () => { throw new Error('no'); } })).toThrow(/ed25519/);198 expect(() => stellarPlugin.sign(base, net, { curve: 'ed25519', publicKey: PUB })).toThrow(/ed25519/);199 expect(() => stellarPlugin.sign(base, net, { curve: 'ed25519', publicKey: PUB, signMessage: (m) => ed25519.sign(m, other) })).toThrow(/self-check/);200 });201});202203describe('stellar: fail closed', () => {204 const muxed = new MuxedAccount(new Account(TO, '0'), '1').accountId();205 const bad: [string, Intent, RegExp][] = [206 ['wrong passphrase in the intent', { ...base, chainRef: Networks.TESTNET }, /identity/],207 ['another network id', { ...base, network: 'solana' }, /not a Stellar intent/],208 ['token asset', { ...base, asset: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' }, /native/],209 ['wrong symbol', { ...base, symbol: 'USDC' }, /metadata/],210 ['wrong decimals', { ...base, decimals: 6 }, /metadata/],211 ['amount zero', { ...base, amount: '0' }, /positive/],212 ['amount above int64', { ...base, amount: '9223372036854775808' }, /range/],213 ['amount not canonical', { ...base, amount: '0125' }, /base-unit/],214 ['destination equals source', { ...base, to: FROM }, /equals source/],215 ['muxed destination', { ...base, to: muxed }, /muxed/],216 ['lower-case destination', { ...base, to: TO.toLowerCase() }, /invalid Stellar address/],217 ['destination with a bad checksum', { ...base, to: TO.slice(0, -1) + (TO.endsWith('A') ? 'B' : 'A') }, /Stellar address/],218 ['invalid source', { ...base, from: FROM.slice(0, 55) + '=' }, /invalid Stellar address/],219 ['fee above maxFee', withX({ fee: '20001' }), /above approved maximum/],220 ['fee below the network minimum', withX({ fee: '99' }), /range/],221 ['fee above uint32', withX({ fee: '4294967296' }, { maxFee: '5000000000' }), /range/],222 ['fee not canonical', withX({ fee: '0100' }), /canonical/],223 ['fee negative', withX({ fee: '-100' }), /canonical/],224 ['unknown x key', withX({ data: 'AAAA' }), /unknown or missing/],225 ['extra operation hint', withX({ op2: 'payment' }), /unknown or missing/],226 ['missing x key', { ...base, x: { op: 'payment', sequence: '5', fee: '100', maxTime: String(now + 180) } }, /unknown or missing/],227 ['no x section', (() => { const { x: _x, ...r } = base; return r as Intent; })(), /missing Stellar parameters/],228 ['foreign chain section', { ...base, sol: { recentBlockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', lastValidBlockHeight: 1, createAta: false, computeUnitLimit: 1, computeUnitPrice: '0' } }, /unexpected chain section/],229 ['unknown operation', withX({ op: 'accountMerge' }), /x\.op/],230 ['operation in another case', withX({ op: 'Payment' }), /x\.op/],231 ['createAccount below 1 XLM', withX({ op: 'createAccount' }, { amount: '9999999' }), /at least 1 XLM/],232 ['sequence zero', withX({ sequence: '0' }), /range/],233 ['sequence above int64', withX({ sequence: '9223372036854775808' }), /range/],234 ['sequence not canonical', withX({ sequence: '0x10' }), /canonical/],235 ['time bound zero (no expiry)', withX({ maxTime: '0' }), /range/],236 ['time bound in the past', withX({ maxTime: String(now) }), /not after/],237 ['time bound before the approval window ends', withX({ maxTime: String(now + 119) }), /before the approval window/],238 ['time bound too far away', withX({ maxTime: String(now + 901) }), /too far/],239 ['memo present but memoType none', { ...base, memo: 'hello' }, /memoType/],240 ['numeric memo declared as text', withX({ memoType: 'text' }, { memo: '12345' }), /memoType/],241 ['text memo declared as id', withX({ memoType: 'id' }, { memo: 'hello' }), /memoType/],242 ['memoType without memo', withX({ memoType: 'text' }), /memoType/],243 ['unknown memoType', withX({ memoType: 'hash' }, { memo: 'ab'.repeat(32) }), /memo/],244 ['memo longer than 28 bytes', withX({ memoType: 'text' }, { memo: 'x'.repeat(29) }), /too long/],245 ['empty memo', withX({ memoType: 'text' }, { memo: '' }), /empty/],246 ];247 for (const [name, i, re] of bad) it(name, () => {248 expect(() => stellarPlugin.build(i, net)).toThrow(re);249 expect(() => stellarPlugin.sign(i, net, key)).toThrow(re);250 });251 it('refuses a network definition that is not the pinned public network', () => {252 expect(() => stellarPlugin.build({ ...base, chainRef: Networks.TESTNET }, { ...net, chainRef: Networks.TESTNET })).toThrow(/public network/);253 expect(() => stellarPlugin.build(base, { ...net, family: 'evm' })).toThrow(/not a Stellar intent/);254 expect(() => stellarPlugin.build({ ...base, symbol: 'ABC' }, { ...net, native: { ...net.native, symbol: 'ABC', id: 'ABC' } })).toThrow(/native/);255 });256 it('the intent schema and hash accept the Stellar section and bind every field', () => {257 expect(() => canonicalBytes(base)).not.toThrow();258 expect(() => canonicalBytes((() => { const { x: _x, ...r } = base; return r as Intent; })())).toThrow(/does not match network/);259 expect(() => canonicalBytes({ ...base, x: { ...base.x!, 'bad key': '1' } })).toThrow();260 });261 it('changing any intent field changes the signed bytes or throws', () => {262 const ref = toHex(stellarPlugin.build(base, net).signDocs[0]!);263 const txMuts: Intent[] = [264 { ...base, amount: '125000001' }, { ...base, to: SEP5[2][1] }, { ...base, from: SEP5[2][1] },265 withX({ fee: '10113' }), withX({ sequence: '2390162185806737' }), withX({ maxTime: String(now + 181) }), withX({ op: 'createAccount' }),266 withX({ memoType: 'text' }, { memo: 'a' }), withX({ memoType: 'id' }, { memo: '1' }),267 ];268 const seen = new Set([ref]);269 for (const m of txMuts) seen.add(toHex(stellarPlugin.build(m, net).signDocs[0]!));270 expect(seen.size).toBe(txMuts.length + 1);271 // '1' as MEMO_ID and '1' as MEMO_TEXT can never be confused: the type is fixed by the rule272 expect(() => stellarPlugin.build(withX({ memoType: 'text' }, { memo: '1' }), net)).toThrow();273 // fields that are not part of the Stellar transaction either throw or leave it unchanged, and always change the approved intent hash274 const other: Intent[] = [275 { ...base, chainRef: 'Test SDF Network ; September 2015' }, { ...base, network: 'solana' }, { ...base, asset: 'other' }, { ...base, symbol: 'XLN' }, { ...base, decimals: 8 },276 { ...base, maxFee: '10111' }, { ...base, createdAt: now + 200 }, { ...base, expiresAt: now + 181 }, { ...base, v: 2 as never },277 { ...base, maxFee: '20001' }, { ...base, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a12' }, { ...base, keyId: 'spw-0123456789abcdee' }, { ...base, policyVersion: 4 },278 ];279 const h0 = toHex(intentHash(base));280 other.forEach((m, k) => {281 let out: string | null = null;282 try { out = toHex(stellarPlugin.build(m, net).signDocs[0]!); } catch { /* refused */ }283 if (k < 9) expect(out, `mutation ${k}`).toBeNull(); else expect(out).toBe(ref);284 let h: string | null = null;285 try { h = toHex(intentHash(m)); } catch { /* refused by the schema */ }286 expect(h).not.toBe(h0);287 });288 for (const m of txMuts) expect(toHex(intentHash(m))).not.toBe(h0);289 });290});291