SPB Git

Repository Public

sui.test.ts 23.9 KB 326 lines typescript

1import { describe, expect, it } from 'vitest';2import fc from 'fast-check';3import { ed25519 } from '@noble/curves/ed25519.js';4import { blake2b } from '@noble/hashes/blake2.js';5import { hmac } from '@noble/hashes/hmac.js';6import { sha512 } from '@noble/hashes/sha2.js';7import { base58 } from '@scure/base';8import { mnemonicToSeedSync } from '@scure/bip39';9import { bcs } from '@mysten/sui/bcs';10import { messageWithIntent, parseSerializedSignature } from '@mysten/sui/cryptography';11import { Ed25519Keypair, Ed25519PublicKey } from '@mysten/sui/keypairs/ed25519';12import { Transaction } from '@mysten/sui/transactions';13import { isValidSuiAddress, normalizeSuiAddress } from '@mysten/sui/utils';14import { verifyTransactionSignature } from '@mysten/sui/verify';15import {16  buildSuiTx, canonicalBytes, DEFAULT_POLICY, intentHash, isValidAddress, NETWORKS, normalizeAddress, pluginFor,17  SUI_MAINNET_GENESIS, SUI_MAX_GAS_COINS, suiAddressFromPublicKey, suiFormatPayment, suiParsePayment, suiParseRaw, suiPlugin, suiTxDigest, toHex,18  type Intent, type KeyHandle, type NetworkDef, type SuiObjectRef,19} from '../src/index.ts';2021const net = NETWORKS.sui as NetworkDef;22const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; // public BIP-39 test vector23const b64 = (b: Uint8Array) => Buffer.from(b).toString('base64');2425/** SLIP-0010 ed25519 (hardened only), independent of the signer's implementation and of the SDK. */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 SEED = mnemonicToSeedSync(MNEMONIC);38const PRIV = slip10(SEED, suiPlugin.path(net));39const PUB = ed25519.getPublicKey(PRIV);40const SDK_KP = Ed25519Keypair.deriveKeypair(MNEMONIC); // official SDK, default Sui path41const FROM = suiAddressFromPublicKey(PUB);42const TO = suiAddressFromPublicKey(ed25519.getPublicKey(slip10(SEED, "m/44'/784'/1'/0'/0'")));43const key: KeyHandle = { curve: 'ed25519', publicKey: PUB, signMessage: (m) => ed25519.sign(m, PRIV) };4445const digestOf = (seed: number) => base58.encode(blake2b(Uint8Array.of(seed), { dkLen: 32 }));46const COIN_A: SuiObjectRef = { objectId: '0x' + '1a'.repeat(32), version: 1065912677n, digest: digestOf(1) };47const COIN_B: SuiObjectRef = { objectId: '0x' + 'c7'.repeat(32), version: 7n, digest: digestOf(2) };48const now = 1_800_000_000;49const base: Intent = {50  v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: now, expiresAt: now + 120, policyVersion: 3,51  network: 'sui', chainRef: SUI_MAINNET_GENESIS, asset: 'native', symbol: 'SUI', decimals: 9, amount: '1250000000', from: FROM, to: TO, maxFee: '5000000',52  x: { payment: suiFormatPayment([COIN_A]), gasPrice: '100', gasBudget: '4152000', expiration: '1308' },53};54const withX = (x: Record<string, string>, over: Partial<Intent> = {}): Intent => ({ ...base, ...over, x: { ...base.x!, ...x } });5556/** The same transaction built offline by the official SDK from the same fields (no client: every input is explicit). */57async function sdkBytes(i: Intent): Promise<Uint8Array> {58  const x = i.x!, tx = new Transaction();59  tx.setSender(i.from);60  tx.setGasPrice(BigInt(x.gasPrice!));61  tx.setGasBudget(BigInt(x.gasBudget!));62  tx.setExpiration({ Epoch: x.expiration! });63  tx.setGasPayment(suiParsePayment(x.payment).map((p) => ({ objectId: p.objectId, version: p.version.toString(), digest: p.digest })));64  const [coin] = tx.splitCoins(tx.gas, [BigInt(i.amount)]);65  tx.transferObjects([coin!], i.to);66  return tx.build();67}6869describe('sui: registration', () => {70  it('is registered with the pinned identity, derivation, limits and two sources', () => {71    expect(pluginFor(net)).toBe(suiPlugin);72    expect(net.chainRef).toBe('4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S');73    // the well-known mainnet chain identifier is the first four bytes of the genesis checkpoint digest74    expect(toHex(base58.decode(net.chainRef).subarray(0, 4))).toBe('35834a8a');75    expect(base58.decode(net.chainRef).length).toBe(32);76    expect([net.family, net.coinType, net.memo, net.activation, net.native.symbol, net.native.decimals, net.tokens.length]).toEqual(['sui', 784, undefined, undefined, 'SUI', 9, 0]);77    expect(suiPlugin.path(net)).toBe("m/44'/784'/0'/0'/0'");78    expect(() => suiPlugin.path({ ...net, coinType: 501 })).toThrow();79    expect(new Set(net.rpc.map((u) => new URL(u).host)).size).toBe(2);80    expect(DEFAULT_POLICY.perTx['sui:SUI']).toBe('60000000000');81    expect(DEFAULT_POLICY.maxFee.sui).toBe('50000000');82  });83});8485describe('sui: keys and addresses', () => {86  it('derives the address the official SDK derives for the public test mnemonic', () => {87    expect(toHex(PUB)).toBe(toHex(SDK_KP.getPublicKey().toRawBytes()));88    expect(FROM).toBe(SDK_KP.toSuiAddress());89    expect(suiPlugin.address(net, PUB)).toBe(SDK_KP.getPublicKey().toSuiAddress());90    expect(FROM).toBe('0x5e93a736d04fbb25737aa40bee40171ef79f65fae833749e3c089fe7cc2161f1');91    expect(TO).toBe(Ed25519Keypair.deriveKeypair(MNEMONIC, "m/44'/784'/1'/0'/0'").toSuiAddress());92  });93  it('matches the vector published in the Sui SDK test suite (ed25519-keypair.test.ts)', () => {94    const m = 'film crazy soon outside stand loop subway crumble thrive popular green nuclear struggle pistol arm wife phrase warfare march wheat nephew ask sunny firm';95    const pub = ed25519.getPublicKey(slip10(mnemonicToSeedSync(m), "m/44'/784'/0'/0'/0'"));96    expect(suiAddressFromPublicKey(pub)).toBe('0xa2d14fad60c56049ecf75246a481934691214ce413e6a8ae2fe6834c173a6133');97    expect(b64(pub)).toBe('ImR/7u82MGC9QgWhZxoV8QoSNnZZGLG19jjYLzPPxGk=');98  });99  it('computes addresses like the official SDK (property)', () => {100    fc.assert(fc.property(fc.uint8Array({ minLength: 32, maxLength: 32 }), (k) => {101      const a = suiAddressFromPublicKey(k);102      return a === new Ed25519PublicKey(k).toSuiAddress() && isValidSuiAddress(a) && normalizeSuiAddress(a) === a && normalizeAddress('sui', a) === a;103    }));104    expect(() => suiAddressFromPublicKey(new Uint8Array(33))).toThrow();105  });106  it('accepts only full 32-byte addresses; lowers hex case, pads nothing, refuses reserved addresses', () => {107    expect(normalizeAddress('sui', `  ${TO} `)).toBe(TO);108    expect(normalizeAddress('sui', '0x' + TO.slice(2).toUpperCase())).toBe(TO);109    const bad = [110      TO.slice(2), TO.slice(0, -1), TO + '0', '0X' + TO.slice(2), TO.slice(0, -1) + 'g', '0x2', '0x5', '0x' + '0'.repeat(64), '0x' + '0'.repeat(63) + '2', '0x' + '0'.repeat(63) + '6',111      '0x' + '0'.repeat(60) + 'dee9', '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', '4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S', 'alice.sui', '@alice', '',112    ];113    for (const a of bad) expect(isValidAddress('sui', a), a).toBe(false);114    expect(isValidAddress('sui', '0x' + '0'.repeat(59) + '10000')).toBe(true); // first address above the reserved range115  });116});117118describe('sui: transaction bytes equal the official SDK', () => {119  const many = Array.from({ length: SUI_MAX_GAS_COINS }, (_v, k) => ({ objectId: '0x' + (k + 1).toString(16).padStart(64, '0').replace(/^0{4}/, 'beef'), version: BigInt(k + 1), digest: digestOf(k + 10) }));120  const cases: [string, Intent][] = [121    ['one gas coin', base],122    ['two gas coins', withX({ payment: suiFormatPayment([COIN_A, COIN_B]) })],123    ['two gas coins, other order', withX({ payment: suiFormatPayment([COIN_B, COIN_A]) })],124    [`${SUI_MAX_GAS_COINS} gas coins`, withX({ payment: suiFormatPayment(many) })],125    ['1 MIST, smallest budget', withX({ gasPrice: '1', gasBudget: '1' }, { amount: '1' })],126    // (the SDK holds an epoch in a JavaScript number, so it can only be compared up to 2^53 - 1; see the u64 test below)127    ['u64 max amount and version, largest epoch the SDK can hold', withX({ payment: suiFormatPayment([{ ...COIN_A, version: (1n << 64n) - 1n }]), expiration: '9007199254740991' }, { amount: '18446744073709551615' })],128    ['largest budget, epoch 0', withX({ gasBudget: '50000000000', gasPrice: '50000', expiration: '0' }, { maxFee: '50000000000' })],129    ['digest with leading zero bytes', withX({ payment: suiFormatPayment([{ ...COIN_A, digest: base58.encode(Uint8Array.from({ length: 32 }, (_v, k) => (k < 3 ? 0 : k))) }]) })],130  ];131  for (const [name, i] of cases) it(name, async () => {132    const ours = buildSuiTx(i, net), ref = await sdkBytes(i);133    expect(toHex(ours.tx)).toBe(toHex(ref));134    // decoded by the SDK's BCS schema and re-encoded: identical135    expect(toHex(bcs.TransactionData.serialize(bcs.TransactionData.parse(ours.tx)).toBytes())).toBe(toHex(ours.tx));136    const back = Transaction.from(ours.tx);137    expect(toHex(await back.build())).toBe(toHex(ours.tx));138    expect(await back.getDigest()).toBe(ours.txid);139    expect(suiTxDigest(ref)).toBe(ours.txid);140    expect(toHex(ours.signingDigest)).toBe(toHex(blake2b(messageWithIntent('TransactionData', ref), { dkLen: 32 })));141    const { signDocs, describe: d } = suiPlugin.build(i, net);142    expect(signDocs.map(toHex)).toEqual([toHex(ours.signingDigest)]);143    expect(d.txid).toBe(ours.txid);144  });145  it('decodes, with the SDK, to exactly the approved fields and nothing else', () => {146    const i = withX({ payment: suiFormatPayment([COIN_A, COIN_B]) });147    const d = bcs.TransactionData.parse(buildSuiTx(i, net).tx);148    expect(d.$kind).toBe('V1');149    const v1 = d.V1!;150    expect(v1.sender).toBe(i.from);151    expect(v1.expiration).toMatchObject({ $kind: 'Epoch', Epoch: 1308 });152    expect(v1.gasData).toEqual({ owner: i.from, price: '100', budget: '4152000', payment: [COIN_A, COIN_B].map((p) => ({ objectId: p.objectId, version: p.version.toString(), digest: p.digest })) });153    const ptb = v1.kind.ProgrammableTransaction!;154    expect(ptb.inputs).toHaveLength(2);155    expect(ptb.inputs[0]).toMatchObject({ $kind: 'Pure', Pure: { bytes: b64(bcs.u64().serialize(i.amount).toBytes()) } });156    expect(ptb.inputs[1]).toMatchObject({ $kind: 'Pure', Pure: { bytes: b64(bcs.Address.serialize(i.to).toBytes()) } });157    expect(ptb.commands).toHaveLength(2);158    expect(ptb.commands[0]).toMatchObject({ $kind: 'SplitCoins', SplitCoins: { coin: { $kind: 'GasCoin' }, amounts: [{ $kind: 'Input', Input: 0 }] } });159    expect(ptb.commands[1]).toMatchObject({ $kind: 'TransferObjects', TransferObjects: { objects: [{ $kind: 'NestedResult', NestedResult: [0, 0] }], address: { $kind: 'Input', Input: 1 } } });160  });161  it('writes the expiration epoch as a full little-endian u64', () => {162    const a = buildSuiTx(withX({ expiration: '18446744073709551615' }), net).tx, b = buildSuiTx(base, net).tx;163    expect(toHex(a.subarray(a.length - 9))).toBe('01' + 'ff'.repeat(8));164    expect(toHex(b.subarray(b.length - 9))).toBe('011c05000000000000'); // Epoch(1308)165    expect(toHex(a.subarray(0, a.length - 8))).toBe(toHex(b.subarray(0, b.length - 8)));166  });167  it('property: any valid field combination', async () => {168    const ref = fc.record({ id: fc.uint8Array({ minLength: 32, maxLength: 32 }), version: fc.bigInt({ min: 1n, max: (1n << 64n) - 1n }), digest: fc.uint8Array({ minLength: 32, maxLength: 32 }) });169    await fc.assert(fc.asyncProperty(170      fc.bigInt({ min: 1n, max: (1n << 64n) - 1n }), fc.uint8Array({ minLength: 32, maxLength: 32 }), fc.uniqueArray(ref, { minLength: 1, maxLength: 5, selector: (r) => toHex(r.id) }),171      fc.bigInt({ min: 1n, max: 100_000n }), fc.bigInt({ min: 100_000n, max: 50_000_000_000n }), fc.bigInt({ min: 0n, max: BigInt(Number.MAX_SAFE_INTEGER) }),172      async (amount, dest, refs, gasPrice, gasBudget, epoch) => {173        const to = '0x' + toHex(dest), payment = refs.map((r) => ({ objectId: '0x' + toHex(r.id), version: r.version, digest: base58.encode(r.digest) }));174        fc.pre(to !== FROM && !/^0x0{60}/.test(to) && payment.every((p) => p.objectId !== to && p.objectId !== FROM));175        const i = withX({ payment: suiFormatPayment(payment), gasPrice: gasPrice.toString(), gasBudget: gasBudget.toString(), expiration: epoch.toString() }, { amount: amount.toString(), to, maxFee: gasBudget.toString() });176        const ours = buildSuiTx(i, net), sdk = await sdkBytes(i);177        return toHex(ours.tx) === toHex(sdk) && ours.txid === suiTxDigest(sdk);178      }), { numRuns: 200 });179  });180});181182describe('sui: signing', () => {183  it('produces the signature the SDK produces, and the SDK verifies it', async () => {184    for (const i of [base, withX({ payment: suiFormatPayment([COIN_A, COIN_B]) }, { amount: '1' })]) {185      const signed = suiPlugin.sign(i, net, key);186      const { tx, signature, txid } = suiParseRaw(signed.raw);187      expect(txid).toBe(signed.txid);188      expect(toHex(tx)).toBe(toHex(await sdkBytes(i)));189      expect(await Transaction.from(tx).getDigest()).toBe(signed.txid);190      // serialized signature: flag 0x00 || 64-byte signature || 32-byte public key191      expect(signature.length).toBe(97);192      expect(signature[0]).toBe(0);193      expect(toHex(signature.subarray(65))).toBe(toHex(PUB));194      const parsed = parseSerializedSignature(b64(signature));195      expect(parsed.signatureScheme).toBe('ED25519');196      expect(parsed.signatureScheme === 'ED25519' && toHex(parsed.publicKey)).toBe(toHex(PUB));197      // the SDK verifies it as a transaction signature of the sender, and refuses it for anyone else or any other bytes198      const pk = await verifyTransactionSignature(tx, b64(signature), { address: i.from });199      expect(pk.toSuiAddress()).toBe(i.from);200      await expect(verifyTransactionSignature(tx, b64(signature), { address: TO })).rejects.toThrow();201      const other = tx.slice(); other[5] = other[5]! ^ 1;202      await expect(verifyTransactionSignature(other, b64(signature))).rejects.toThrow();203      // noble, against the raw public key, over blake2b-256(intent prefix || tx)204      expect(ed25519.verify(signature.subarray(1, 65), blake2b(Uint8Array.from([0, 0, 0, ...tx]), { dkLen: 32 }), PUB)).toBe(true);205      // the SDK signing the same bytes with the same mnemonic gives the same serialized signature (ed25519 is deterministic)206      const ref = await SDK_KP.signTransaction(tx);207      expect(ref.signature).toBe(b64(signature));208      expect(ref.bytes).toBe(b64(tx));209    }210  });211  it('refuses a key that does not control the source, the wrong curve, and a bad signature', () => {212    const other = slip10(SEED, "m/44'/784'/1'/0'/0'");213    expect(() => suiPlugin.sign(base, net, { curve: 'ed25519', publicKey: ed25519.getPublicKey(other), signMessage: (m) => ed25519.sign(m, other) })).toThrow(/does not control/);214    expect(() => suiPlugin.sign(base, net, { curve: 'secp256k1', publicKey: new Uint8Array(33), signDigest: () => { throw new Error('no'); } })).toThrow(/ed25519/);215    expect(() => suiPlugin.sign(base, net, { curve: 'ed25519', publicKey: PUB })).toThrow(/ed25519/);216    expect(() => suiPlugin.sign(base, net, { curve: 'ed25519', publicKey: PUB, signMessage: (m) => ed25519.sign(m, other) })).toThrow(/self-check/);217  });218  it('parses only its own broadcast payload', () => {219    const raw = suiPlugin.sign(base, net, key).raw;220    const [t, s] = raw.split(':') as [string, string];221    for (const bad of ['', t, `${t}:`, `${t}:${s}:${s}`, `${t}:${s.slice(4)}`, `${t} :${s}`, `${t}:${b64(Uint8Array.from([1, ...Buffer.from(s, 'base64').subarray(1)]))}`, `AAAA:${s}`])222      expect(() => suiParseRaw(bad), bad.slice(0, 20)).toThrow();223  });224});225226describe('sui: fail closed', () => {227  const pay = (refs: { objectId: string; version: string; digest: string }[]) => refs.map((r) => `${r.objectId}:${r.version}:${r.digest}`).join(',');228  const A = { objectId: COIN_A.objectId, version: COIN_A.version.toString(), digest: COIN_A.digest };229  const bad: [string, Intent, RegExp][] = [230    ['wrong genesis in the intent', { ...base, chainRef: '69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD' }, /identity/], // Sui testnet231    ['short chain identifier instead of the digest', { ...base, chainRef: '35834a8a' }, /identity/],232    ['another network id', { ...base, network: 'solana' }, /not a Sui intent/],233    ['token asset (USDC)', { ...base, asset: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC' }, /native/],234    ['wrong symbol', { ...base, symbol: 'USDC' }, /metadata/],235    ['wrong decimals', { ...base, decimals: 6 }, /metadata/],236    ['amount zero', { ...base, amount: '0' }, /positive/],237    ['amount above u64', { ...base, amount: '18446744073709551616' }, /range/],238    ['amount not canonical', { ...base, amount: '0125' }, /base-unit/],239    ['destination equals source', { ...base, to: FROM }, /equals source/],240    ['upper-case destination', { ...base, to: '0x' + TO.slice(2).toUpperCase() }, /invalid Sui address/],241    ['short destination', { ...base, to: '0x' + '0'.repeat(20) + '2' }, /invalid Sui address/],242    ['destination without 0x', { ...base, to: TO.slice(2) + '00' }, /invalid Sui address/],243    ['zero address', { ...base, to: '0x' + '0'.repeat(64) }, /reserved/],244    ['system object as destination', { ...base, to: '0x' + '0'.repeat(63) + '5' }, /reserved/],245    ['invalid source', { ...base, from: FROM.slice(0, 65) + 'G' }, /invalid Sui address/],246    ['memo', { ...base, memo: 'hello' }, /memo/],247    ['budget above maxFee', withX({ gasBudget: '5000001' }), /above approved maximum/],248    ['budget above the protocol maximum', withX({ gasBudget: '50000000001' }, { maxFee: '60000000000' }), /protocol maximum/],249    ['budget zero', withX({ gasBudget: '0' }), /range/],250    ['budget below one gas unit', withX({ gasBudget: '99' }), /below the price/],251    ['budget not canonical', withX({ gasBudget: '04152000' }), /canonical/],252    ['budget negative', withX({ gasBudget: '-1' }), /canonical/],253    ['budget above u64', withX({ gasBudget: '18446744073709551616' }, { maxFee: '18446744073709551616' }), /range/],254    ['gas price zero', withX({ gasPrice: '0' }), /range/],255    ['gas price in hex', withX({ gasPrice: '0x64' }), /canonical/],256    ['expiration missing value', withX({ expiration: '' }), /canonical/],257    ['expiration above u64', withX({ expiration: '18446744073709551616' }), /range/],258    ['unknown x key', withX({ sponsor: TO }), /unknown or missing/],259    ['extra command hint', withX({ moveCall: '0x2::coin::burn' }), /unknown or missing/],260    ['missing x key', { ...base, x: { payment: base.x!.payment!, gasPrice: '100', gasBudget: '4152000' } }, /unknown or missing/],261    ['no x section', (() => { const { x: _x, ...r } = base; return r as Intent; })(), /missing Sui parameters/],262    ['foreign chain section', { ...base, sol: { recentBlockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', lastValidBlockHeight: 1, createAta: false, computeUnitLimit: 1, computeUnitPrice: '0' } }, /unexpected chain section/],263    ['no gas coin', withX({ payment: '' }), /missing/],264    ['too many gas coins', withX({ payment: pay(Array.from({ length: SUI_MAX_GAS_COINS + 1 }, (_v, k) => ({ ...A, objectId: '0x' + (k + 0x10000).toString(16).padStart(64, '0') }))) }), /too many/],265    ['duplicate gas coin', withX({ payment: pay([A, A]) }), /duplicate/],266    ['gas coin id not canonical', withX({ payment: pay([{ ...A, objectId: '0x1a' }]) }), /invalid Sui address/],267    ['gas coin id upper case', withX({ payment: pay([{ ...A, objectId: A.objectId.toUpperCase().replace('0X', '0x') }]) }), /invalid Sui address/],268    ['gas coin version zero', withX({ payment: pay([{ ...A, version: '0' }]) }), /range/],269    ['gas coin version not canonical', withX({ payment: pay([{ ...A, version: '01' }]) }), /canonical/],270    ['gas coin version above u64', withX({ payment: pay([{ ...A, version: '18446744073709551616' }]) }), /range/],271    ['gas coin digest too short', withX({ payment: pay([{ ...A, digest: base58.encode(new Uint8Array(31).fill(7)) }]) }), /digest/],272    ['gas coin digest too long', withX({ payment: pay([{ ...A, digest: base58.encode(new Uint8Array(33).fill(7)) }]) }), /digest/],273    ['gas coin digest not base58', withX({ payment: pay([{ ...A, digest: A.digest.slice(0, -1) + '0' }]) }), /digest/],274    ['gas coin with a fourth field', withX({ payment: `${pay([A])}:1` }), /objectId:version:digest/],275    ['trailing separator', withX({ payment: `${pay([A])},` }), /objectId:version:digest/],276    ['whitespace in payment', withX({ payment: ` ${pay([A])}` }), /invalid Sui address/],277    ['gas coin is the destination', withX({ payment: pay([{ ...A, objectId: TO }]) }), /cannot be the sender or the destination/],278    ['gas coin is the sender', withX({ payment: pay([{ ...A, objectId: FROM }]) }), /cannot be the sender or the destination/],279  ];280  for (const [name, i, re] of bad) it(name, () => {281    expect(() => suiPlugin.build(i, net)).toThrow(re);282    expect(() => suiPlugin.sign(i, net, key)).toThrow(re);283  });284  it('refuses a network definition that is not the pinned mainnet', () => {285    const testnet = '69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD';286    expect(() => suiPlugin.build({ ...base, chainRef: testnet }, { ...net, chainRef: testnet })).toThrow(/only mainnet/);287    expect(() => suiPlugin.build(base, { ...net, family: 'evm' })).toThrow(/not a Sui intent/);288    expect(() => suiPlugin.build({ ...base, symbol: 'ABC' }, { ...net, native: { ...net.native, symbol: 'ABC', id: 'ABC' } })).toThrow(/native/);289  });290  it('the intent schema and hash accept the Sui section and bind every field', () => {291    expect(() => canonicalBytes(base)).not.toThrow();292    expect(() => canonicalBytes(withX({ payment: suiFormatPayment(Array.from({ length: SUI_MAX_GAS_COINS }, (_v, k) => ({ ...COIN_A, objectId: '0x' + (k + 0x10000).toString(16).padStart(64, '0'), version: (1n << 64n) - 1n }))) }))).not.toThrow();293    expect(() => canonicalBytes((() => { const { x: _x, ...r } = base; return r as Intent; })())).toThrow(/does not match network/);294    expect(() => canonicalBytes({ ...base, x: { ...base.x!, 'bad key': '1' } })).toThrow();295  });296  it('changing any intent field changes the signed bytes or throws', () => {297    const ref = toHex(suiPlugin.build(base, net).signDocs[0]!);298    const other3 = suiAddressFromPublicKey(ed25519.getPublicKey(slip10(SEED, "m/44'/784'/2'/0'/0'")));299    const txMuts: Intent[] = [300      { ...base, amount: '1250000001' }, { ...base, to: other3 }, { ...base, from: other3 },301      withX({ gasPrice: '101' }), withX({ gasBudget: '4152001' }), withX({ expiration: '1309' }),302      withX({ payment: suiFormatPayment([{ ...COIN_A, version: COIN_A.version + 1n }]) }), withX({ payment: suiFormatPayment([{ ...COIN_A, digest: digestOf(3) }]) }),303      withX({ payment: suiFormatPayment([{ ...COIN_A, objectId: COIN_B.objectId }]) }), withX({ payment: suiFormatPayment([COIN_A, COIN_B]) }), withX({ payment: suiFormatPayment([COIN_B, COIN_A]) }),304    ];305    const seen = new Set([ref]);306    for (const m of txMuts) seen.add(toHex(suiPlugin.build(m, net).signDocs[0]!));307    expect(seen.size).toBe(txMuts.length + 1);308    // fields that are not part of the Sui transaction either throw or leave it unchanged, and always change the approved intent hash309    const other: Intent[] = [310      { ...base, chainRef: '69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD' }, { ...base, network: 'solana' }, { ...base, asset: 'other' }, { ...base, symbol: 'SUL' }, { ...base, decimals: 8 },311      { ...base, maxFee: '4151999' }, { ...base, memo: 'x' }, { ...base, v: 2 as never },312      { ...base, maxFee: '5000001' }, { ...base, createdAt: now + 1 }, { ...base, expiresAt: now + 121 }, { ...base, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a12' }, { ...base, keyId: 'spw-0123456789abcdee' }, { ...base, policyVersion: 4 },313    ];314    const h0 = toHex(intentHash(base));315    other.forEach((m, k) => {316      let out: string | null = null;317      try { out = toHex(suiPlugin.build(m, net).signDocs[0]!); } catch { /* refused */ }318      if (k < 8) expect(out, `mutation ${k}`).toBeNull(); else expect(out).toBe(ref);319      let h: string | null = null;320      try { h = toHex(intentHash(m)); } catch { /* refused by the schema */ }321      expect(h).not.toBe(h0);322    });323    for (const m of txMuts) expect(toHex(intentHash(m))).not.toBe(h0);324  });325});326
Files main
Menu

Theme