SPB Git

Repository Public

near.test.ts 23.0 KB 311 lines typescript

1import { createRequire } from 'node:module';2import { describe, expect, it } from 'vitest';3import fc from 'fast-check';4import { ed25519 } from '@noble/curves/ed25519.js';5import { hmac } from '@noble/hashes/hmac.js';6import { sha256, sha512 } from '@noble/hashes/sha2.js';7import { base58 } from '@scure/base';8import { KeyType, PublicKey } from '@near-js/crypto';9import { actionCreators, createTransaction, decodeSignedTransaction, decodeTransaction, encodeTransaction, Signature, SignedTransaction } from '@near-js/transactions';10import {11  buildNearTx, canonicalBytes, DEFAULT_POLICY, intentHash, IntentSchema, isValidAddress, NEAR_GAS_BOUND_IMPLICIT, NEAR_GAS_BOUND_NAMED, NEAR_MAINNET_GENESIS,12  NEAR_MAX_GAS_PRICE, nearAccountKind, nearDecodeHash, nearFeeCeiling, nearImplicitAccount, nearPlugin, nearSignedTxInfo, NETWORKS, normalizeAddress, parseUnits,13  pluginFor, toHex, type Intent, type KeyHandle, type NetworkDef,14} from '../src/index.ts';1516// near-seed-phrase (the derivation used by NEAR wallets) is CommonJS without types17const { parseSeedPhrase, KEY_DERIVATION_PATH } = createRequire(import.meta.url)('near-seed-phrase') as {18  parseSeedPhrase: (phrase: string, path?: string) => { publicKey: string; secretKey: string }; KEY_DERIVATION_PATH: string;19};2021const net = NETWORKS.near as NetworkDef;22const hexToBytes = (h: string) => Uint8Array.from(Buffer.from(h, 'hex'));23// Public BIP-39 test mnemonic (known to everyone, used here for derivation and signing vectors only; nothing is ever broadcast) and its BIP-39 seed.24const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';25const SEED = hexToBytes('5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4');26/** the wallet's NEAR account for that mnemonic, as produced by near-seed-phrase 0.2.1 (m/44'/397'/0') */27const ACCOUNT_0 = '5510e2b44cae6eb807e3e0e45d579dda058c274abcba15e5cb84636f5d1ee412';2829/** SLIP-0010 ed25519 (hardened only), independent of the signer's implementation. */30function slip10(seed: Uint8Array, path: string): Uint8Array {31  let I = hmac(sha512, new TextEncoder().encode('ed25519 seed'), seed);32  for (const seg of path.split('/').slice(1)) {33    if (!seg.endsWith("'")) throw new Error('hardened only');34    const data = new Uint8Array(37);35    data.set(I.subarray(0, 32), 1);36    new DataView(data.buffer).setUint32(33, (Number(seg.slice(0, -1)) | 0x80000000) >>> 0);37    I = hmac(sha512, I.subarray(32), data);38  }39  return I.slice(0, 32);40}41const PRIV = slip10(SEED, nearPlugin.path(net));42const PUB = ed25519.getPublicKey(PRIV);43const FROM = nearImplicitAccount(PUB);44const OTHER = nearImplicitAccount(ed25519.getPublicKey(slip10(SEED, "m/44'/397'/1'")));45const key: KeyHandle = { curve: 'ed25519', publicKey: PUB, signMessage: (m) => ed25519.sign(m, PRIV) };46const BLOCK = 'bRJrdMWsAoEJdNrSK1KXJFHwGC16Rtth9NhP6ZomSBh'; // a real mainnet block hash (height 223991714)4748const 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: 'near', chainRef: NEAR_MAINNET_GENESIS, asset: 'native', symbol: 'NEAR', decimals: 24, amount: '1250000000000000000000001', from: FROM, to: 'alice.near',52  maxFee: '2000000000000000000000', x: { nonce: '223991684000002', blockHash: BLOCK },53};54const toImplicit = (over: Partial<Intent> = {}): Intent => ({ ...base, to: OTHER, maxFee: '20000000000000000000000', ...over });55const withX = (x: Record<string, string>, over: Partial<Intent> = {}): Intent => ({ ...base, ...over, x: { ...base.x!, ...x } });5657/** The same transaction built by the official SDK from the same fields. */58const sdkTx = (i: Intent) => createTransaction(i.from, PublicKey.fromString('ed25519:' + base58.encode(hexToBytes(i.from))), i.to, BigInt(i.x!.nonce!),59  [actionCreators.transfer(BigInt(i.amount))], base58.decode(i.x!.blockHash!));6061describe('near: registration', () => {62  it('is registered with the pinned identity, derivation, limits and two sources', () => {63    expect(pluginFor(net)).toBe(nearPlugin);64    expect(net.chainRef).toBe('EPnLgE7iEq9s7yTkos96M3cWymH5avBAPm3qx3NXqR8H');65    expect([net.id, net.family, net.coinType, net.memo, net.activation, net.native.symbol, net.native.decimals, net.tokens.length]).toEqual(['near', 'near', 397, undefined, undefined, 'NEAR', 24, 0]);66    expect(nearPlugin.path(net)).toBe("m/44'/397'/0'");67    expect(nearPlugin.path(net)).toBe(KEY_DERIVATION_PATH); // the path NEAR wallets use68    expect(() => nearPlugin.path({ ...net, coinType: 501 })).toThrow();69    expect(new Set(net.rpc.map((u) => new URL(u).host)).size).toBe(2);70    expect(DEFAULT_POLICY.perTx['near:NEAR']).toBe(parseUnits('14', 24).toString());71    expect(DEFAULT_POLICY.daily['near:NEAR']).toBe('28000000000000000000000000');72    // the policy fee ceiling admits the worst case of both transfer kinds, and nothing larger than 0.02 NEAR73    expect(BigInt(DEFAULT_POLICY.maxFee.near!)).toBe(nearFeeCeiling(OTHER));74    expect(nearFeeCeiling('alice.near')).toBe(NEAR_GAS_BOUND_NAMED * NEAR_MAX_GAS_PRICE);75    expect([nearFeeCeiling('alice.near'), nearFeeCeiling(OTHER)]).toEqual([2_000_000_000_000_000_000_000n, 20_000_000_000_000_000_000_000n]);76    expect(NEAR_GAS_BOUND_IMPLICIT).toBeGreaterThan(8_349_895_375_000n); // protocol 88: send + execution gas of a transfer creating an implicit account77    expect(NEAR_GAS_BOUND_NAMED).toBeGreaterThan(446_365_125_000n);78  });79});8081describe('near: keys and addresses', () => {82  it('derives the same account as near-seed-phrase for the public test mnemonic', () => {83    for (const path of ["m/44'/397'/0'", "m/44'/397'/1'"]) {84      const sdk = parseSeedPhrase(MNEMONIC, path), pub = ed25519.getPublicKey(slip10(SEED, path));85      expect('ed25519:' + base58.encode(pub)).toBe(sdk.publicKey);86      expect(nearPlugin.address(net, pub)).toBe(Buffer.from(PublicKey.fromString(sdk.publicKey).data).toString('hex'));87      expect(base58.encode(Uint8Array.from([...slip10(SEED, path), ...pub]))).toBe(sdk.secretKey.replace('ed25519:', ''));88    }89    expect(parseSeedPhrase(MNEMONIC).publicKey).toBe('ed25519:6j4b6zUaty6fD1awqcGCCU9JYGCWYUgdJhQrzfZhqE25');90    expect(FROM).toBe(ACCOUNT_0);91    expect(() => nearImplicitAccount(new Uint8Array(33))).toThrow();92  });93  it('implicit account id = lowercase hex of the key (property), accepted as a destination', () => {94    fc.assert(fc.property(fc.uint8Array({ minLength: 32, maxLength: 32 }), (k) => {95      const a = nearImplicitAccount(k);96      return a === Buffer.from(k).toString('hex') && nearAccountKind(a) === 'implicit' && normalizeAddress('near', a) === a;97    }));98  });99  it('accepts valid named accounts and nothing else', () => {100    const good = ['alice.near', 'near', 'aa', 'a.b', 'app.alice.near', 'a-b_c.d1.near', '100.near', 'x'.repeat(64), 'bridge-mng.near', 'usdt.tether-token.near', '0'.repeat(63) + 'g', 'f'.repeat(63), 'f'.repeat(62) + '.f'];101    for (const a of good) { expect(nearAccountKind(a), a).toBe('named'); expect(normalizeAddress('near', a)).toBe(a); }102    const bad = [103      '', 'a', 'x'.repeat(65), 'Alice.near', 'ALICE.NEAR', FROM.toUpperCase(), 'alice..near', '.alice', 'alice.', '-alice', 'alice-', '_a', 'a_', 'a-.b', 'a._b', 'a--b', 'a__b', 'a-_b',104      'alice near', 'alice@near', 'alice.near\n', 'ålice.near', 'alice.near/', 'alice,near', 'al*ce', '😀😀',105      'system', // reserved virtual account106      '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', '0X70997970C51812DC3A010C7D01B50E0D17DC79C8', '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // Ethereum-style implicit accounts: refused107      '0s70997970c51812dc3a010c7d01b50e0d17dc79c8', '0u70997970c51812dc3a010c7d01b50e0d17dc79c8', // deterministic / universal accounts: refused108      'ed25519:6j4b6zUaty6fD1awqcGCCU9JYGCWYUgdJhQrzfZhqE25', '6j4b6zUaty6fD1awqcGCCU9JYGCWYUgdJhQrzfZhqE25', // a public key is not an account id109      'GDVSYYTUAJ3ACHTPQNSTQBDQ4LDHQCMNY4FCEQH5TJUMSSLWQSTG42MV', 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8',110    ];111    for (const a of bad) { expect(() => nearAccountKind(a), JSON.stringify(a)).toThrow(); if (a.trim() === a) expect(isValidAddress('near', a), JSON.stringify(a)).toBe(false); }112    expect(() => nearAccountKind(undefined as never)).toThrow();113    expect(normalizeAddress('near', '  alice.near ')).toBe('alice.near'); // the shared entry point trims surrounding whitespace only114    expect(nearAccountKind('f'.repeat(64))).toBe('implicit');115    // An all-lowercase address of another chain is a syntactically valid NEAR name (no such account exists on NEAR):116    // the grammar cannot tell, so the API refuses a named destination that does not exist on-chain (see the adapter tests).117    expect(nearAccountKind('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu')).toBe('named');118  });119  it('agrees with the reference account id grammar (property)', () => {120    // near-account-id: 2..64 chars, /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/121    const re = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/;122    const special = (s: string) => s === 'system' || /^0[a-z][0-9a-f]{40}$/.test(s);123    fc.assert(fc.property(fc.string({ unit: fc.constantFrom('a', 'b', 'z', '0', '9', '.', '-', '_', 'A', ' '), minLength: 0, maxLength: 12 }), (s) => {124      const want = s.length >= 2 && re.test(s) && !special(s);125      let got = true;126      try { nearAccountKind(s); } catch { got = false; }127      return got === want;128    }), { numRuns: 3000 });129  });130});131132describe('near: transaction bytes', () => {133  const cases: [string, Intent][] = [134    ['to a named account', base],135    ['to an implicit account', toImplicit()],136    ['1 yoctoNEAR', { ...base, amount: '1' }],137    ['a 24-decimal amount with every digit significant', { ...base, amount: '13999999999999999999999999' }],138    ['the largest u128', { ...base, amount: ((1n << 128n) - 1n).toString() }],139    ['the largest nonce', withX({ nonce: ((1n << 64n) - 1n).toString() })],140    ['a 2-character receiver', { ...base, to: 'aa' }],141    ['a 64-character named receiver', { ...base, to: 'x'.repeat(64) }],142  ];143  for (const [name, i] of cases) it(`is byte-identical to @near-js/transactions: ${name}`, () => {144    const b = buildNearTx(i, net), sdk = sdkTx(i);145    expect(toHex(b.tx)).toBe(toHex(encodeTransaction(sdk)));146    expect(toHex(b.hash)).toBe(toHex(sha256(encodeTransaction(sdk))));147    expect(b.txid).toBe(base58.encode(sha256(encodeTransaction(sdk))));148    // decoded by the SDK, every field is the approved one149    const d = decodeTransaction(b.tx) as any; // borsh-deserialized plain fields150    expect([d.signerId, d.receiverId, d.nonce, base58.encode(Uint8Array.from(d.blockHash)), d.actions.length]).toEqual([i.from, i.to, BigInt(i.x!.nonce!), i.x!.blockHash, 1]);151    expect(Object.keys(d.actions[0])).toEqual(['transfer']);152    expect(d.actions[0].transfer.deposit).toBe(BigInt(i.amount));153    expect(Object.keys(d.publicKey)).toEqual(['ed25519Key']);154    expect(toHex(Uint8Array.from(d.publicKey.ed25519Key.data))).toBe(i.from);155    expect(toHex(encodeTransaction(d))).toBe(toHex(b.tx)); // re-encoded by the SDK: unchanged156    expect(nearPlugin.build(i, net).signDocs.map(toHex)).toEqual([toHex(b.hash)]);157  });158  it('is byte-identical to the SDK for arbitrary fields (property)', () => {159    const name = fc.stringMatching(/^[a-z0-9]{1,10}(\.[a-z0-9]{1,10}){0,2}$/).filter((s) => s.length >= 2 && isValidAddress('near', s));160    fc.assert(fc.property(fc.bigInt({ min: 1n, max: (1n << 128n) - 1n }), fc.bigInt({ min: 1n, max: (1n << 64n) - 1n }), fc.uint8Array({ minLength: 32, maxLength: 32 }),161      fc.oneof(name, fc.uint8Array({ minLength: 32, maxLength: 32 }).map((k) => Buffer.from(k).toString('hex'))), (amount, nonce, block, to) => {162        fc.pre(to !== FROM);163        const i: Intent = { ...base, to, amount: amount.toString(), maxFee: '20000000000000000000000', x: { nonce: nonce.toString(), blockHash: base58.encode(block) } };164        return toHex(buildNearTx(i, net).tx) === toHex(encodeTransaction(sdkTx(i)));165      }), { numRuns: 300 });166  });167  it('known layout: signer, key, nonce, receiver, block hash, one Transfer', () => {168    const b = buildNearTx(base, net);169    const le = (v: bigint, bytes: number) => Array.from({ length: bytes }, (_, k) => Number((v >> BigInt(8 * k)) & 0xffn).toString(16).padStart(2, '0')).join('');170    const hex = le(64n, 4) + Buffer.from(FROM).toString('hex') + '00' + FROM + le(223991684000002n, 8) + le(10n, 4) + Buffer.from('alice.near').toString('hex')171      + toHex(base58.decode(BLOCK)) + le(1n, 4) + '03' + le(1250000000000000000000001n, 16);172    expect(hex.length / 2).toBe(4 + 64 + 33 + 8 + 4 + 10 + 32 + 4 + 17);173    expect(toHex(b.tx)).toBe(hex);174    expect(b.describe).toMatchObject({ from: FROM, to: 'alice.near', amountYocto: base.amount, nonce: '223991684000002', blockHash: BLOCK, feeCeilingYocto: '2000000000000000000000', txid: b.txid });175  });176});177178describe('near: signing', () => {179  for (const [name, i] of [['named', base], ['implicit', toImplicit()]] as const) it(`signs sha256(borsh tx); the SDK decodes the result and verifies the signature (${name})`, () => {180    const s = nearPlugin.sign(i, net, key), raw = Buffer.from(s.raw, 'base64');181    const d = decodeSignedTransaction(raw) as any; // borsh-deserialized plain fields182    expect(toHex(encodeTransaction(d.transaction))).toBe(toHex(encodeTransaction(sdkTx(i))));183    const hash = sha256(encodeTransaction(d.transaction));184    expect(s.txid).toBe(base58.encode(hash));185    expect(Object.keys(d.signature)).toEqual(['ed25519Signature']);186    const sig = Uint8Array.from(d.signature.ed25519Signature.data);187    const sdkKey = PublicKey.fromString('ed25519:' + base58.encode(Uint8Array.from(d.transaction.publicKey.ed25519Key.data)));188    expect(sdkKey.toString()).toBe(parseSeedPhrase(MNEMONIC).publicKey);189    expect(sdkKey.verify(hash, sig)).toBe(true); // @near-js/crypto190    expect(ed25519.verify(sig, hash, PUB)).toBe(true); // noble191    expect(sdkKey.verify(sha256(Uint8Array.from([...hash, 1])), sig)).toBe(false);192    // the SDK serializes the same signed transaction to the same bytes193    const sdkSigned = new SignedTransaction({ transaction: sdkTx(i), signature: new Signature({ keyType: KeyType.ED25519, data: sig }) });194    expect(toHex(sdkSigned.encode())).toBe(toHex(raw));195    expect(raw.length).toBe(encodeTransaction(sdkTx(i)).length + 65);196    expect(nearSignedTxInfo(s.raw)).toEqual({ txid: s.txid, signerId: FROM });197    expect(nearPlugin.sign(i, net, key)).toEqual(s); // deterministic198  });199  it('refuses a key that does not control the source account, or of the wrong kind', () => {200    const otherPriv = slip10(SEED, "m/44'/397'/1'");201    const other: KeyHandle = { curve: 'ed25519', publicKey: ed25519.getPublicKey(otherPriv), signMessage: (m) => ed25519.sign(m, otherPriv) };202    expect(() => nearPlugin.sign(base, net, other)).toThrow(/does not control/);203    expect(() => nearPlugin.sign(base, net, { curve: 'secp256k1', publicKey: new Uint8Array(33) })).toThrow(/ed25519/);204    expect(() => nearPlugin.sign(base, net, { curve: 'ed25519', publicKey: PUB })).toThrow(/ed25519/);205    expect(() => nearPlugin.sign(base, net, { ...key, signMessage: () => new Uint8Array(64) })).toThrow(/self-check/);206    expect(() => nearPlugin.sign(base, net, { ...key, signMessage: (m) => ed25519.sign(m, otherPriv) })).toThrow(/self-check/);207  });208  it('nearSignedTxInfo refuses anything that is not a signed transaction of this wallet', () => {209    const raw = Buffer.from(nearPlugin.sign(base, net, key).raw, 'base64');210    const flip = (k: number) => { const c = Buffer.from(raw); c[k]! ^= 1; return c.toString('base64'); };211    for (const bad of ['', 'not base64!', raw.subarray(0, 100).toString('base64'), flip(0), flip(10), flip(68), flip(80), flip(raw.length - 65)]) expect(() => nearSignedTxInfo(bad), bad.slice(0, 20)).toThrow();212    expect(nearSignedTxInfo(flip(120)).txid).not.toBe(nearSignedTxInfo(raw.toString('base64')).txid);213  });214});215216describe('near: fail closed', () => {217  const bad: [string, Intent, RegExp][] = [218    ['wrong chain reference', { ...base, chainRef: 'FWJ9kR6KFWoyMoNjpLXXGHeuiy7tEY6GmoFeCA5yuc6b' }, /chain identity/], // testnet genesis219    ['another network id', { ...base, network: 'stellar' }, /not a NEAR intent/],220    ['unsupported intent version', { ...base, v: 2 as never }, /version/],221    ['a token instead of the native coin', { ...base, asset: 'usdt.tether-token.near' }, /only native NEAR/],222    ['wrong symbol', { ...base, symbol: 'NEAT' }, /metadata/],223    ['wrong decimals', { ...base, decimals: 18 }, /metadata/],224    ['amount zero', { ...base, amount: '0' }, /positive/],225    ['amount negative', { ...base, amount: '-1' }, /invalid base-unit/],226    ['amount with a fraction', { ...base, amount: '1.5' }, /invalid base-unit/],227    ['amount above u128', { ...base, amount: (1n << 128n).toString() }, /out of range/],228    ['destination equals source', { ...base, to: FROM, maxFee: '20000000000000000000000' }, /equals source/],229    ['destination in upper case', { ...base, to: 'Alice.near' }, /upper-case/],230    ['implicit destination in upper case', toImplicit({ to: OTHER.toUpperCase() }), /upper-case/],231    ['destination with a space', { ...base, to: 'alice.near ' }, /invalid NEAR account id/],232    ['Ethereum-style destination', { ...base, to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' }, /not supported/],233    ['reserved destination', { ...base, to: 'system' }, /reserved/],234    ['too short destination', { ...base, to: 'a' }, /2 to 64/],235    ['source is a named account', { ...base, from: 'x'.repeat(20) + '.near' }, /source is not an implicit/],236    ['source in upper case', { ...base, from: FROM.toUpperCase() }, /upper-case/],237    ['fee ceiling above maxFee (named)', { ...base, maxFee: '1999999999999999999999' }, /fee above approved maximum/],238    ['fee ceiling above maxFee (implicit destination costs more)', toImplicit({ maxFee: '19999999999999999999999' }), /fee above approved maximum/],239    ['implicit destination with the named ceiling', { ...base, to: OTHER }, /fee above approved maximum/],240    ['maxFee zero', { ...base, maxFee: '0' }, /fee above approved maximum/],241    ['a memo', { ...base, memo: 'hello' }, /memo/],242    ['unknown x key', withX({ gas: '1' }), /unknown or missing/],243    ['action smuggled in x', withX({ action: 'FunctionCall' }), /unknown or missing/],244    ['missing nonce', { ...base, x: { blockHash: BLOCK } }, /unknown or missing/],245    ['missing block hash', { ...base, x: { nonce: '5' } }, /unknown or missing/],246    ['no x section', (() => { const { x: _x, ...r } = base; return r as Intent; })(), /missing NEAR parameters/],247    ['foreign chain section', { ...base, sol: { recentBlockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', lastValidBlockHeight: 1, createAta: false, computeUnitLimit: 1, computeUnitPrice: '0' } }, /unexpected chain section/],248    ['nonce zero', withX({ nonce: '0' }), /canonical/],249    ['nonce with a leading zero', withX({ nonce: '05' }), /canonical/],250    ['nonce in hex', withX({ nonce: '0x10' }), /canonical/],251    ['nonce above u64', withX({ nonce: (1n << 64n).toString() }), /out of range/],252    ['block hash too short', withX({ blockHash: BLOCK.slice(0, 30) }), /invalid NEAR hash/],253    ['block hash of 31 bytes', withX({ blockHash: base58.encode(new Uint8Array(31).fill(7)) }), /invalid NEAR hash/],254    ['block hash of 33 bytes', withX({ blockHash: base58.encode(new Uint8Array(33).fill(7)) }), /invalid NEAR hash/],255    ['block hash with a non-base58 character', withX({ blockHash: BLOCK.slice(0, -1) + '0' }), /invalid NEAR hash/],256    ['block hash in hex', withX({ blockHash: 'ab'.repeat(32) }), /invalid NEAR hash/],257    ['empty block hash', withX({ blockHash: '' }), /invalid NEAR hash/],258  ];259  for (const [name, i, re] of bad) it(name, () => {260    expect(() => nearPlugin.build(i, net)).toThrow(re);261    expect(() => nearPlugin.sign(i, net, key)).toThrow(re);262  });263  it('refuses a network definition that is not the pinned mainnet', () => {264    const testnet = 'FWJ9kR6KFWoyMoNjpLXXGHeuiy7tEY6GmoFeCA5yuc6b';265    expect(() => nearPlugin.build({ ...base, chainRef: testnet }, { ...net, chainRef: testnet })).toThrow(/only mainnet/);266    expect(() => nearPlugin.build(base, { ...net, family: 'evm' })).toThrow(/not a NEAR intent/);267    expect(() => nearPlugin.build({ ...base, symbol: 'ABC' }, { ...net, native: { ...net.native, symbol: 'ABC', id: 'ABC' } })).toThrow(/native/);268  });269  it('hash decoding is canonical', () => {270    expect(toHex(nearDecodeHash(BLOCK))).toBe(toHex(base58.decode(BLOCK)));271    expect(nearDecodeHash('1'.repeat(32)).every((b) => b === 0)).toBe(true);272    for (const h of ['1' + BLOCK, BLOCK + '1', ' ' + BLOCK, BLOCK + ' ', BLOCK.replace('b', 'l')]) expect(() => nearDecodeHash(h)).toThrow();273  });274  it('the intent schema and hash accept the NEAR section (short named destinations included) and bind every field', () => {275    expect(() => canonicalBytes(base)).not.toThrow();276    expect(() => canonicalBytes({ ...base, to: 'aa' })).not.toThrow();277    expect(IntentSchema.safeParse({ ...base, to: 'a' }).success).toBe(false);278    expect(IntentSchema.safeParse({ ...base, from: 'alice.near' }).success).toBe(false); // a source is never this short279    expect(() => canonicalBytes((() => { const { x: _x, ...r } = base; return r as Intent; })())).toThrow(/does not match network/);280    expect(() => canonicalBytes({ ...base, x: { ...base.x!, 'bad key': '1' } })).toThrow();281  });282  it('changing any intent field changes the signed bytes or throws', () => {283    const ref = toHex(nearPlugin.build(base, net).signDocs[0]!);284    const txMuts: Intent[] = [285      { ...base, amount: '1250000000000000000000002' }, { ...base, amount: '1250000000000000000000000' }, { ...base, to: 'alice.neas' }, { ...base, to: 'alice.near.near' },286      { ...base, to: OTHER, maxFee: '20000000000000000000000' }, { ...base, from: OTHER },287      withX({ nonce: '223991684000003' }), withX({ blockHash: 'Gi1nwG4WGSBuQi6eqRsim7CynVKj4w3hfy1uQS5fw9bB' }),288    ];289    const seen = new Set([ref]);290    for (const m of txMuts) seen.add(toHex(nearPlugin.build(m, net).signDocs[0]!));291    expect(seen.size).toBe(txMuts.length + 1);292    // fields that are not part of the NEAR transaction either throw or leave it unchanged, and always change the approved intent hash293    const other: Intent[] = [294      { ...base, chainRef: 'FWJ9kR6KFWoyMoNjpLXXGHeuiy7tEY6GmoFeCA5yuc6b' }, { ...base, network: 'solana' }, { ...base, asset: 'other' }, { ...base, symbol: 'NEAT' }, { ...base, decimals: 8 },295      { ...base, maxFee: '1999999999999999999999' }, { ...base, memo: 'x' }, { ...base, v: 2 as never },296      { ...base, maxFee: '2000000000000000000001' }, { ...base, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a12' }, { ...base, keyId: 'spw-0123456789abcdee' }, { ...base, policyVersion: 4 },297      { ...base, createdAt: now + 1 }, { ...base, expiresAt: now + 121 },298    ];299    const h0 = toHex(intentHash(base));300    other.forEach((m, k) => {301      let out: string | null = null;302      try { out = toHex(nearPlugin.build(m, net).signDocs[0]!); } catch { /* refused */ }303      if (k < 8) expect(out, `mutation ${k}`).toBeNull(); else expect(out).toBe(ref);304      let h: string | null = null;305      try { h = toHex(intentHash(m)); } catch { /* refused by the schema */ }306      expect(h).not.toBe(h0);307    });308    for (const m of txMuts) expect(toHex(intentHash(m))).not.toBe(h0);309  });310});311
Files main
Menu

Theme