src/signing/typedData.test.ts
v0.2.0 · 8.8 KB
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);
});
});