SPB Git

Repository Public

stellar.test.ts 19.6 KB 291 lines typescript

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
Files main
Menu

Theme