Repository Public
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