SPB Git

Repository Public

near.test.ts 6.1 KB 97 lines typescript

1import { createHash } from 'node:crypto';2import { mkdtempSync } from 'node:fs';3import { createRequire } from 'node:module';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { describe, expect, it } from 'vitest';7import { base58 } from '@scure/base';8import { sha256 } from '@noble/hashes/sha2.js';9import { PublicKey } from '@near-js/crypto';10import { decodeSignedTransaction, encodeTransaction } from '@near-js/transactions';11import { intentHash, NETWORKS, verifyAuditChain, type Intent } from '@spw/core';12import { TestPasskey } from '../../../tests/helpers/passkey.ts';13import { deriveKeys } from '../src/keys.ts';14import { Signer } from '../src/signer.ts';15import { Store } from '../src/store.ts';1617// Public BIP-39 test vector mnemonic: known to everyone, used for vectors only; nothing signed here ever leaves the test.18const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';19const keys = deriveKeys(MNEMONIC);20const { parseSeedPhrase } = createRequire(import.meta.url)('near-seed-phrase') as { parseSeedPhrase: (phrase: string, path?: string) => { publicKey: string } };21const RP = 'wallet.test', ORIGIN = 'https://wallet.test';22const IMPLICIT = Buffer.from(PublicKey.fromString(parseSeedPhrase(MNEMONIC, "m/44'/397'/1'").publicKey).data).toString('hex');23const BLOCK = 'bRJrdMWsAoEJdNrSK1KXJFHwGC16Rtth9NhP6ZomSBh';2425async function setup() {26  const dir = mkdtempSync(join(tmpdir(), 'spw-signer-near-'));27  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));28  store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');29  const t = Math.floor(Date.now() / 1000);30  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => t);31  const pk = new TestPasskey(RP, ORIGIN);32  await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });33  return { store, signer, pk, t };34}35let n = 0;36const near = (t: number, over: Partial<Intent> = {}): Intent => ({37  v: 1, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`, keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 4,38  network: 'near', chainRef: NETWORKS.near.chainRef, asset: 'native', symbol: 'NEAR', decimals: 24, amount: '2500000000000000000000007', from: keys.addresses.near!, to: 'alice.near',39  maxFee: '2000000000000000000000', x: { nonce: '223991684000002', blockHash: BLOCK }, ...over,40});4142describe('near through the signer', () => {43  it("derives the account NEAR wallets derive from the mnemonic (m/44'/397'/0', implicit account)", () => {44    expect(keys.addresses.near).toBe('5510e2b44cae6eb807e3e0e45d579dda058c274abcba15e5cb84636f5d1ee412');45    expect(keys.addresses.near).toBe(Buffer.from(PublicKey.fromString(parseSeedPhrase(MNEMONIC).publicKey).data).toString('hex'));46  });4748  it('signs an approved NEAR transfer; the official SDK decodes it and verifies the signature', async () => {49    const { signer, pk, t, store } = await setup();50    const i = near(t);51    const r = await signer.sign(i, pk.assert(intentHash(i)));52    const d = decodeSignedTransaction(Buffer.from(r.raw, 'base64')) as any; // borsh-deserialized plain fields53    const tx = d.transaction, hash = sha256(encodeTransaction(tx));54    expect(r.txid).toBe(base58.encode(hash));55    expect([tx.signerId, tx.receiverId, tx.nonce, base58.encode(Uint8Array.from(tx.blockHash))]).toEqual([i.from, 'alice.near', 223991684000002n, BLOCK]);56    expect(tx.actions).toHaveLength(1);57    expect(Object.keys(tx.actions[0])).toEqual(['transfer']);58    expect(tx.actions[0].transfer.deposit).toBe(2500000000000000000000007n); // 24 decimals, exact59    expect(Buffer.from(Uint8Array.from(tx.publicKey.ed25519Key.data)).toString('hex')).toBe(i.from);60    expect(PublicKey.fromString(parseSeedPhrase(MNEMONIC).publicKey).verify(hash, Uint8Array.from(d.signature.ed25519Signature.data))).toBe(true);61    expect(verifyAuditChain(store.state.journal)).toBeNull();62    await expect(signer.sign(i, pk.assert(intentHash(i)))).rejects.toThrow(/already signed/);63  });6465  it('signs a transfer to an implicit account only with the higher approved fee ceiling; an approval cannot be reused for other bytes', async () => {66    const { signer, pk, t } = await setup();67    const i = near(t, { to: IMPLICIT, maxFee: '20000000000000000000000' });68    const r = await signer.sign(i, pk.assert(intentHash(i)));69    expect((decodeSignedTransaction(Buffer.from(r.raw, 'base64')) as any).transaction.receiverId).toBe(IMPLICIT);70    const a = near(t), swapped = { ...a, x: { ...a.x!, nonce: '223991684000003' } };71    await expect(signer.sign(swapped, pk.assert(intentHash(a)))).rejects.toThrow(/approval/);72    const b = near(t), other = { ...b, to: 'mallory.near' };73    await expect(signer.sign(other, pk.assert(intentHash(b)))).rejects.toThrow(/approval/);74  });7576  it('refuses unsupported or out-of-policy NEAR intents before looking at the approval', async () => {77    const { signer, pk, t } = await setup();78    const cases: [Partial<Intent>, RegExp][] = [79      [{ chainRef: 'FWJ9kR6KFWoyMoNjpLXXGHeuiy7tEY6GmoFeCA5yuc6b' }, /chain identity/], // testnet genesis80      [{ from: IMPLICIT }, /not controlled/],81      [{ to: keys.addresses.near!, maxFee: '20000000000000000000000' }, /equals source/],82      [{ to: 'Alice.near' }, /upper-case/],83      [{ to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' }, /not supported/],84      [{ asset: 'usdt.tether-token.near' }, /native/],85      [{ to: IMPLICIT }, /fee above approved maximum/], // an implicit destination needs the higher ceiling86      [{ maxFee: '1999999999999999999999' }, /fee above approved maximum/],87      [{ x: { ...near(t).x!, extra: '1' } }, /unknown or missing/],88      [{ x: { nonce: '0', blockHash: BLOCK } }, /canonical/],89      [{ x: { nonce: '5', blockHash: 'ab'.repeat(32) } }, /invalid NEAR hash/],90      [{ memo: 'x' }, /memo/],91      [{ amount: '14000000000000000000000001' }, /per-transaction limit/], // default cap: 14 NEAR92      [{ to: IMPLICIT, maxFee: '20000000000000000000001' }, /fee ceiling/],93    ];94    for (const [over, re] of cases) { const i = near(t, over); await expect(signer.sign(i, pk.assert(intentHash(i))), JSON.stringify(over)).rejects.toThrow(re); }95  });96});97
Files main
Menu

Theme