Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions bindings/node/__test__/permit.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { signPermit } from "../src/evm/permit.mjs";

// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------

function encodeString(value) {
const bytes = Buffer.from(value, "utf8");
const offset = "0000000000000000000000000000000000000000000000000000000000000020";
const length = bytes.length.toString(16).padStart(64, "0");
const data = bytes.toString("hex").padEnd(Math.ceil(bytes.length / 32) * 64, "0");
return "0x" + offset + length + data;
}

function encodeUint256(value) {
return "0x" + BigInt(value).toString(16).padStart(64, "0");
}

function makeMockFetch({ name = "USD Coin", nonce = 0n, eip712Domain = null, revertDomain = false } = {}) {
return async (_url, init) => {
const body = JSON.parse(init.body);
if (body.method === "eth_chainId") {
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x2105" }));
}
if (body.method === "eth_call") {
const selector = body.params[0].data.slice(0, 10);

if (selector === "0x84b0196e") {
if (revertDomain) {
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { message: "execution reverted" } }));
}
if (eip712Domain) {
const nameBytes = Buffer.from(eip712Domain.name, "utf8");
const versionBytes = Buffer.from(eip712Domain.version, "utf8");
const words = [
"0f00000000000000000000000000000000000000000000000000000000000000",
"00000000000000000000000000000000000000000000000000000000000000e0",
"0000000000000000000000000000000000000000000000000000000000000120",
eip712Domain.chainId.toString(16).padStart(64, "0"),
"000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"0000000000000000000000000000000000000000000000000000000000000000",
"0000000000000000000000000000000000000000000000000000000000000160",
nameBytes.length.toString(16).padStart(64, "0"),
nameBytes.toString("hex").padEnd(64, "0"),
versionBytes.length.toString(16).padStart(64, "0"),
versionBytes.toString("hex").padEnd(64, "0"),
"0000000000000000000000000000000000000000000000000000000000000000",
];
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x" + words.join("") }));
}
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { message: "execution reverted" } }));
}

if (selector === "0x06fdde03") {
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: encodeString(name) }));
}

if (selector === "0x7ecebe00") {
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: encodeUint256(nonce) }));
}
}
throw new Error("Unexpected: " + body.method);
};
}

const MOCK_SIG = "0x" + "a".repeat(64) + "b".repeat(64) + "1b";
const mockSignTypedData = async (_json) => MOCK_SIG;

const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const SPENDER = "0xDeadBeefDeadBeefDeadBeefDeadBeefDeadBeef";
const OWNER = "0x1111111111111111111111111111111111111111";
const BASE_CHAIN = "eip155:8453";
const BASE_PARAMS = {
token: BASE_USDC,
spender: SPENDER,
value: "1000000",
deadline: 1_800_000_000,
nonce: 0,
rpcUrl: "http://mock-rpc",
};

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("signPermit", () => {

it("returns correct v / r / s from mock signature", async () => {
globalThis.fetch = makeMockFetch({ eip712Domain: { name: "USD Coin", version: "2", chainId: 8453 } });
const result = await signPermit(OWNER, BASE_CHAIN, BASE_PARAMS, mockSignTypedData);
assert.equal(result.v, 27);
assert.equal(result.r, "0x" + "a".repeat(64));
assert.equal(result.s, "0x" + "b".repeat(64));
assert.equal(result.signature, MOCK_SIG);
});

it("builds correct EIP-712 typed data structure", async () => {
globalThis.fetch = makeMockFetch({ eip712Domain: { name: "USD Coin", version: "2", chainId: 8453 } });
const { typedData } = await signPermit(OWNER, BASE_CHAIN, BASE_PARAMS, mockSignTypedData);
assert.equal(typedData.primaryType, "Permit");
assert.equal(typedData.message.spender, SPENDER);
assert.equal(typedData.message.value, "1000000");
assert.equal(typedData.message.deadline, 1_800_000_000);
assert.equal(typedData.message.nonce, 0);
assert.deepEqual(typedData.types.Permit, [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
]);
});

it("resolves domain via eip712Domain()", async () => {
globalThis.fetch = makeMockFetch({ eip712Domain: { name: "USD Coin", version: "2", chainId: 8453 } });
const { typedData } = await signPermit(OWNER, BASE_CHAIN, BASE_PARAMS, mockSignTypedData);
assert.equal(typedData.domain.name, "USD Coin");
assert.equal(typedData.domain.version, "2");
assert.equal(typedData.domain.chainId, 8453);
});

it("falls back to name() + override when eip712Domain() reverts", async () => {
globalThis.fetch = makeMockFetch({ revertDomain: true, name: "USD Coin" });
const { typedData } = await signPermit(OWNER, BASE_CHAIN, BASE_PARAMS, mockSignTypedData);
assert.equal(typedData.domain.name, "USD Coin");
assert.equal(typedData.domain.version, "2");
});

it("auto-fetches nonce when not supplied", async () => {
globalThis.fetch = makeMockFetch({ eip712Domain: { name: "USD Coin", version: "2", chainId: 8453 }, nonce: 5n });
const params = { ...BASE_PARAMS, nonce: undefined };
const { typedData } = await signPermit(OWNER, BASE_CHAIN, params, mockSignTypedData);
assert.equal(typedData.message.nonce, 5);
});

it("uses supplied nonce without RPC call", async () => {
let nonceCalled = false;
globalThis.fetch = async (url, init) => {
const body = JSON.parse(init.body);
if (body.method === "eth_call" && body.params[0].data.startsWith("0x7ecebe00")) {
nonceCalled = true;
}
return makeMockFetch({ eip712Domain: { name: "USD Coin", version: "2", chainId: 8453 } })(url, init);
};
await signPermit(OWNER, BASE_CHAIN, { ...BASE_PARAMS, nonce: 7 }, mockSignTypedData);
assert.equal(nonceCalled, false);
});

it("omits version field in EIP712Domain when token has no version", async () => {
globalThis.fetch = makeMockFetch({ eip712Domain: { name: "MyToken", version: "", chainId: 8453 } });
const params = { ...BASE_PARAMS, token: "0x1234567890123456789012345678901234567890" };
const { typedData } = await signPermit(OWNER, BASE_CHAIN, params, mockSignTypedData);
const hasVersion = typedData.types.EIP712Domain.some(f => f.name === "version");
assert.equal(hasVersion, false);
});

it("chainId matches CAIP-2 chain segment", async () => {
globalThis.fetch = makeMockFetch({ eip712Domain: { name: "USD Coin", version: "2", chainId: 8453 } });
const { typedData } = await signPermit(OWNER, BASE_CHAIN, BASE_PARAMS, mockSignTypedData);
assert.equal(typedData.domain.chainId, 8453);
});

it("throws when token name is empty and no override exists", async () => {
globalThis.fetch = makeMockFetch({ revertDomain: true, name: "" });
const params = { ...BASE_PARAMS, token: "0x0000000000000000000000000000000000000001" };
await assert.rejects(
() => signPermit(OWNER, BASE_CHAIN, params, mockSignTypedData),
/Could not resolve EIP-712 domain/
);
});

it("throws when RPC is unreachable", async () => {
globalThis.fetch = async () => { throw new TypeError("fetch failed"); };
await assert.rejects(() => signPermit(OWNER, BASE_CHAIN, BASE_PARAMS, mockSignTypedData));
});

it("DAI fallback domain version is 1", async () => {
globalThis.fetch = makeMockFetch({ revertDomain: true, name: "Dai Stablecoin" });
const params = { ...BASE_PARAMS, token: "0x6B175474E89094C44Da98b954EedeAC495271d0F" };
const { typedData } = await signPermit(OWNER, "eip155:1", params, mockSignTypedData);
assert.equal(typedData.domain.version, "1");
});

it("Ethereum USDC fallback domain version is 2", async () => {
globalThis.fetch = makeMockFetch({ revertDomain: true, name: "USD Coin" });
const params = { ...BASE_PARAMS, token: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" };
const { typedData } = await signPermit(OWNER, "eip155:1", params, mockSignTypedData);
assert.equal(typedData.domain.version, "2");
});

});
191 changes: 191 additions & 0 deletions bindings/node/src/evm/permit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* EIP-2612 permit signing helper for OWS.
* Handles token metadata resolution, nonce fetching, and EIP-712 typed data
* construction automatically.
*
* @see https://eips.ethereum.org/EIPS/eip-2612
*/

const DOMAIN_OVERRIDES = {
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": { version: "2" },
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": { version: "2" },
"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359": { version: "2" },
"0xaf88d065e77c8cc2239327c5edb3a432268e5831": { version: "2" },
"0x0b2c639c533813f4aa9d7837caf62653d097ff85": { version: "2" },
"0x6b175474e89094c44da98b954eedeac495271d0f": { version: "1" },
"0xfde4c96c8593536e31f229ea8f37b2ada2699bb2": { version: "1" },
};

const SELECTORS = {
name: "0x06fdde03",
nonces: "0x7ecebe00",
eip712Domain: "0x84b0196e",
};

const DEFAULT_RPCS = {
1: "https://eth.llamarpc.com",
8453: "https://mainnet.base.org",
137: "https://polygon-rpc.com",
42161: "https://arb1.arbitrum.io/rpc",
10: "https://mainnet.optimism.io",
};

async function ethCall(rpcUrl, to, data) {
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: 1,
method: "eth_call",
params: [{ to, data }, "latest"],
}),
});
if (!res.ok) throw new Error(`RPC request failed: ${res.status}`);
const json = await res.json();
if (json.error) throw new Error(`eth_call error: ${json.error.message}`);
return json.result ?? "0x";
}

function encodeAddressArg(address) {
return address.toLowerCase().replace("0x", "").padStart(64, "0");
}

function decodeString(hex) {
const raw = hex.startsWith("0x") ? hex.slice(2) : hex;
if (raw.length < 128) return "";
const length = parseInt(raw.slice(64, 128), 16);
const dataHex = raw.slice(128, 128 + length * 2);
return Buffer.from(dataHex, "hex").toString("utf8");
}

function decodeUint256(hex) {
const raw = hex.startsWith("0x") ? hex.slice(2) : hex;
return BigInt("0x" + (raw || "0"));
}

async function resolveDomain(rpcUrl, tokenAddress, chainId) {
const lower = tokenAddress.toLowerCase();

try {
const raw = await ethCall(rpcUrl, tokenAddress, SELECTORS.eip712Domain);
const stripped = raw.startsWith("0x") ? raw.slice(2) : raw;
if (stripped.length > 64) {
const words = stripped.match(/.{1,64}/g) ?? [];
const nameOffset = parseInt(words[1] ?? "0", 16) / 32;
const versionOffset = parseInt(words[2] ?? "0", 16) / 32;
const domainChainId = parseInt(words[3] ?? "0", 16);

const nameLength = parseInt(words[nameOffset] ?? "0", 16);
const nameData = words.slice(nameOffset + 1).join("").slice(0, nameLength * 2);
const resolvedName = Buffer.from(nameData, "hex").toString("utf8");

const versionLength = parseInt(words[versionOffset] ?? "0", 16);
const versionData = words.slice(versionOffset + 1).join("").slice(0, versionLength * 2);
const resolvedVersion = Buffer.from(versionData, "hex").toString("utf8");

if (resolvedName) {
return {
name: resolvedName,
version: resolvedVersion || undefined,
chainId: domainChainId || chainId,
verifyingContract: tokenAddress,
};
}
}
} catch { /* eip712Domain() not supported */ }

const override = DOMAIN_OVERRIDES[lower];
const nameHex = await ethCall(rpcUrl, tokenAddress, SELECTORS.name);
const tokenName = decodeString(nameHex);

if (!tokenName) {
throw new Error(
`Could not resolve EIP-712 domain for token ${tokenAddress}. ` +
`Ensure the token implements name() or eip712Domain(), or pass rpcUrl.`
);
}

return {
name: tokenName,
version: override?.omitVersion ? undefined : (override?.version ?? "1"),
chainId,
verifyingContract: tokenAddress,
};
}

function buildTypedData(domain, owner, spender, value, nonce, deadline) {
const domainFields = [
{ name: "name", type: "string" },
...(domain.version !== undefined ? [{ name: "version", type: "string" }] : []),
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
];

return {
types: {
EIP712Domain: domainFields,
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
domain: {
name: domain.name,
...(domain.version !== undefined && { version: domain.version }),
chainId: domain.chainId,
verifyingContract: domain.verifyingContract,
},
message: { owner, spender, value, nonce, deadline },
};
}

/**
* Signs an EIP-2612 permit for an ERC-20 token.
*
* @param {string} ownerAddress - Token owner address (from OWS wallet)
* @param {string} chainId - CAIP-2 chain ID, e.g. "eip155:8453"
* @param {object} params - { token, spender, value, deadline, nonce?, rpcUrl? }
* @param {Function} signTypedData - OWS signTypedData(typedDataJson) => Promise<hexSig>
* @returns {Promise<{ signature, v, r, s, typedData }>}
*
* @example
* const sig = await signPermit(ownerAddress, "eip155:8453", {
* token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
* spender: "0xYourProtocol",
* value: "1000000",
* deadline: Math.floor(Date.now() / 1000) + 3600,
* }, owsSignTypedData);
*/
export async function signPermit(ownerAddress, chainId, params, signTypedData) {
const { token, spender, value, deadline } = params;
const numericChainId = parseInt(chainId.split(":")[1] ?? "1", 10);
const rpcUrl = params.rpcUrl ?? DEFAULT_RPCS[numericChainId];

if (!rpcUrl) {
throw new Error(
`No default RPC configured for chain ${numericChainId}. Pass rpcUrl in params.`
);
}

let nonce = params.nonce;
if (nonce === undefined) {
const nonceData = SELECTORS.nonces + encodeAddressArg(ownerAddress);
const nonceHex = await ethCall(rpcUrl, token, nonceData);
nonce = Number(decodeUint256(nonceHex));
}

const domain = await resolveDomain(rpcUrl, token, numericChainId);
const typedData = buildTypedData(domain, ownerAddress, spender, value, nonce, deadline);
const rawSignature = await signTypedData(JSON.stringify(typedData));

const sig = rawSignature.startsWith("0x") ? rawSignature.slice(2) : rawSignature;
const r = "0x" + sig.slice(0, 64);
const s = "0x" + sig.slice(64, 128);
const v = parseInt(sig.slice(128, 130), 16);

return { signature: "0x" + sig, v, r, s, typedData };
}
Loading