Skip to content
markpaper

src/signing/typedData.test.ts

v0.2.0 · 8.8 KB

Download file
import { hashTypedData, verifyTypedData } from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
import { describe, expect, it } from 'vitest';
import { decimalToX18 } from '../numbers/x18.js';
import { buildAppendix, EXPIRATION_NEVER } from './appendix.js';
import { buildNonce } from './nonce.js';
import { productVerifyingContract, signerBytes32, subaccountBytes32, ZERO_ADDRESS } from './subaccount.js';
import {
  assertOrderMessage,
  buildCancelProductsTypedData,
  buildCancelTypedData,
  buildLinkSignerTypedData,
  buildOrderTypedData,
  orderDigest,
  orderToWire,
  signCancellation,
  signLinkSigner,
  signOrder,
  signTypedDataWith,
  verifyOrderSignature,
} from './typedData.js';
import {
  type Hex,
  NADO_DOMAIN_NAME,
  NADO_DOMAIN_VERSION,
  nadoDomain,
  type OrderMessage,
  type TypedDataSigner,
} from './types.js';

const CHAIN_ID = 57073;
const master = privateKeyToAccount(generatePrivateKey());
const signer = privateKeyToAccount(generatePrivateKey()); // linked signer key
const sender = subaccountBytes32(master.address, 'default');
const endpointAddr = privateKeyToAccount(generatePrivateKey()).address; // stands in for endpoint_addr
const digestA = `0x${'1a'.repeat(32)}` as Hex;
const digestB = `0x${'2b'.repeat(32)}` as Hex;

const order: OrderMessage = {
  sender,
  priceX18: decimalToX18('66120'),
  amount: -decimalToX18('0.0005'),
  expiration: EXPIRATION_NEVER,
  nonce: buildNonce({ recvTimeMs: 1_780_000_000_000 + 60_000, random: 1 }), // synthetic round clock
  appendix: buildAppendix('ioc', true),
};

describe('nadoDomain', () => {
  it('has the documented name / version and validates the chain id', () => {
    expect(nadoDomain(CHAIN_ID, ZERO_ADDRESS)).toEqual({
      name: NADO_DOMAIN_NAME,
      version: NADO_DOMAIN_VERSION,
      chainId: CHAIN_ID,
      verifyingContract: ZERO_ADDRESS,
    });
    expect(NADO_DOMAIN_NAME).toBe('Nado');
    expect(NADO_DOMAIN_VERSION).toBe('0.0.1');
    expect(() => nadoDomain(0, ZERO_ADDRESS)).toThrow(RangeError);
  });
});

describe('buildOrderTypedData', () => {
  it('uses address(productId) as the verifying contract', () => {
    const td = buildOrderTypedData({ chainId: CHAIN_ID, productId: 2, order });
    expect(td.domain.verifyingContract).toBe(productVerifyingContract(2));
    expect(td.primaryType).toBe('Order');
    expect(td.types.Order.map((f) => f.name)).toEqual([
      'sender',
      'priceX18',
      'amount',
      'expiration',
      'nonce',
      'appendix',
    ]);
    expect(td.message).toBe(order);
  });

  it('validates the message', () => {
    expect(() => assertOrderMessage({ ...order, sender: '0x12' as Hex })).toThrow(TypeError);
    expect(() => assertOrderMessage({ ...order, priceX18: 0n })).toThrow(RangeError);
    expect(() => assertOrderMessage({ ...order, amount: 0n })).toThrow(RangeError);
    expect(() => assertOrderMessage({ ...order, expiration: 1n << 64n })).toThrow(RangeError);
    expect(() => assertOrderMessage({ ...order, appendix: -1n })).toThrow(RangeError);
    expect(() => assertOrderMessage({ ...order, nonce: 1 as unknown as bigint })).toThrow(TypeError);
  });

  it('formats the wire order with decimal strings', () => {
    expect(orderToWire(order)).toEqual({
      sender,
      priceX18: '66120000000000000000000',
      amount: '-500000000000000',
      expiration: '18446744073709551615',
      nonce: order.nonce.toString(),
      appendix: '2561',
    });
  });
});

describe('signOrder', () => {
  it('signs with a viem account and verifies under the same product only (domain separation)', async () => {
    const sig = await signOrder(signer, { chainId: CHAIN_ID, productId: 18, order });
    expect(sig.startsWith('0x')).toBe(true);
    expect(
      await verifyOrderSignature({ chainId: CHAIN_ID, productId: 18, order, signature: sig, address: signer.address }),
    ).toBe(true);
    expect(
      await verifyOrderSignature({ chainId: CHAIN_ID, productId: 2, order, signature: sig, address: signer.address }),
    ).toBe(false);
    expect(
      await verifyOrderSignature({ chainId: 763373, productId: 18, order, signature: sig, address: signer.address }),
    ).toBe(false);
    expect(
      await verifyOrderSignature({ chainId: CHAIN_ID, productId: 18, order, signature: sig, address: master.address }),
    ).toBe(false);
  });

  it('accepts a bare signTypedData function and a TypedDataSigner object', async () => {
    const fn: TypedDataSigner['signTypedData'] = (p) => signer.signTypedData(p);
    const viaFn = await signOrder(fn, { chainId: CHAIN_ID, productId: 18, order });
    const asSigner: TypedDataSigner = signer;
    const viaObj = await signOrder(asSigner, { chainId: CHAIN_ID, productId: 18, order });
    expect(viaFn).toBe(viaObj);
  });

  it('rejects a malformed signature from the signer', async () => {
    await expect(
      signTypedDataWith(async () => 'nope' as Hex, buildOrderTypedData({ chainId: CHAIN_ID, productId: 2, order })),
    ).rejects.toThrow(TypeError);
  });
});

describe('orderDigest (experimental)', () => {
  it('is the EIP-712 hash of the order typed data and changes with product / chain / nonce', () => {
    const td = buildOrderTypedData({ chainId: CHAIN_ID, productId: 2, order });
    expect(orderDigest({ chainId: CHAIN_ID, productId: 2, order })).toBe(hashTypedData(td));
    expect(orderDigest({ chainId: CHAIN_ID, productId: 3, order })).not.toBe(
      orderDigest({ chainId: CHAIN_ID, productId: 2, order }),
    );
    expect(orderDigest({ chainId: 763373, productId: 2, order })).not.toBe(
      orderDigest({ chainId: CHAIN_ID, productId: 2, order }),
    );
    expect(orderDigest({ chainId: CHAIN_ID, productId: 2, order: { ...order, nonce: order.nonce + 1n } })).not.toBe(
      orderDigest({ chainId: CHAIN_ID, productId: 2, order }),
    );
    expect(orderDigest({ chainId: CHAIN_ID, productId: 2, order })).toMatch(/^0x[0-9a-f]{64}$/);
  });
});

describe('buildCancelTypedData / signCancellation', () => {
  const tx = { sender, productIds: [2, 18], digests: [digestA, digestB], nonce: 5n };

  it('signs against the endpoint address', async () => {
    const td = buildCancelTypedData({ chainId: CHAIN_ID, endpointAddr, tx });
    expect(td.domain.verifyingContract).toBe(endpointAddr.toLowerCase());
    expect(td.primaryType).toBe('Cancellation');
    const sig = await signCancellation(signer, { chainId: CHAIN_ID, endpointAddr, tx });
    expect(await verifyTypedData({ ...td, signature: sig, address: signer.address })).toBe(true);
    expect(
      await verifyTypedData({
        ...td,
        domain: { ...td.domain, verifyingContract: productVerifyingContract(2) },
        signature: sig,
        address: signer.address,
      }),
    ).toBe(false);
  });

  it('validates the message', () => {
    expect(() => buildCancelTypedData({ chainId: CHAIN_ID, endpointAddr, tx: { ...tx, digests: [digestA] } })).toThrow(
      /same length/,
    );
    expect(() =>
      buildCancelTypedData({ chainId: CHAIN_ID, endpointAddr, tx: { ...tx, productIds: [], digests: [] } }),
    ).toThrow(/nothing to cancel/);
    expect(() =>
      buildCancelTypedData({ chainId: CHAIN_ID, endpointAddr, tx: { ...tx, digests: ['0x12' as Hex, digestB] } }),
    ).toThrow(TypeError);
    expect(() => buildCancelTypedData({ chainId: CHAIN_ID, endpointAddr, tx: { ...tx, productIds: [2, -1] } })).toThrow(
      RangeError,
    );
    expect(() => buildCancelTypedData({ chainId: CHAIN_ID, endpointAddr: 'bad', tx })).toThrow(TypeError);
  });

  it('builds CancellationProducts typed data (experimental)', () => {
    const td = buildCancelProductsTypedData({
      chainId: CHAIN_ID,
      endpointAddr,
      tx: { sender, productIds: [2], nonce: 1n },
    });
    expect(td.primaryType).toBe('CancellationProducts');
    expect(() =>
      buildCancelProductsTypedData({ chainId: CHAIN_ID, endpointAddr, tx: { sender, productIds: [], nonce: 1n } }),
    ).toThrow(RangeError);
  });
});

describe('buildLinkSignerTypedData / signLinkSigner', () => {
  it('is signed by the master with the tx_nonce; revoke links the zero address', async () => {
    const tx = { sender, signer: signerBytes32(signer.address), nonce: 3n };
    const td = buildLinkSignerTypedData({ chainId: CHAIN_ID, endpointAddr, tx });
    expect(td.primaryType).toBe('LinkSigner');
    expect(td.message.signer).toBe(`${signer.address.toLowerCase()}${'0'.repeat(24)}`);
    const sig = await signLinkSigner(master, { chainId: CHAIN_ID, endpointAddr, tx });
    expect(await verifyTypedData({ ...td, signature: sig, address: master.address })).toBe(true);
    const revoke = buildLinkSignerTypedData({
      chainId: CHAIN_ID,
      endpointAddr,
      tx: { ...tx, signer: signerBytes32(ZERO_ADDRESS) },
    });
    expect(revoke.message.signer).toBe(`0x${'0'.repeat(64)}`);
    expect(() =>
      buildLinkSignerTypedData({ chainId: CHAIN_ID, endpointAddr, tx: { ...tx, signer: signer.address as Hex } }),
    ).toThrow(TypeError);
  });
});
All files