diff --git a/packages/batch-read-ts/src/index.ts b/packages/batch-read-ts/src/index.ts index 268d345..db0c430 100644 --- a/packages/batch-read-ts/src/index.ts +++ b/packages/batch-read-ts/src/index.ts @@ -24,12 +24,13 @@ async function main() { const multicallAddress = DeployOut.multicall as `0x${string}`; const tokenAddresses = DeployOut.tokens as `0x${string}`[]; - const { client, account } = await createClient(alicePrivateKey, mode); + const { client, account, chain } = await createClient(alicePrivateKey, mode); const expiry = createExpiry(1); const signature = await signBalanceRead( alicePrivateKey, account.address, expiry, + chain.id, ); console.log(`Network: ${mode}`); diff --git a/packages/batch-read-ts/src/util/signature.test.ts b/packages/batch-read-ts/src/util/signature.test.ts new file mode 100644 index 0000000..22449b6 --- /dev/null +++ b/packages/batch-read-ts/src/util/signature.test.ts @@ -0,0 +1,71 @@ +import { test, expect } from "bun:test"; +import { + type Hex, + type Address, + keccak256, + encodeAbiParameters, + concat, + recoverAddress, + toHex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +import { signBalanceRead } from "./signature"; + +// Fixed vector so the EIP-712 digest is deterministic across runs. +const PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const OWNER = privateKeyToAccount(PRIVATE_KEY).address as Address; +const EXPIRY = 1893456000n; // 2030-01-01T00:00:00Z +const CHAIN_ID = 5124n; // seismic devnet/testnet chain id + +// Reconstruct the EIP-712 digest per seismic-std-lib SRC20 (contract PR #209): +// domainSeparator = keccak256(abi.encode( +// keccak256("EIP712Domain(string name,string version,uint256 chainId)"), +// keccak256("SRC20BalanceRead"), keccak256("1"), chainId)) +// structHash = keccak256(abi.encode( +// keccak256("BalanceRead(address owner,uint256 expiry)"), owner, expiry)) +// digest = keccak256(0x1901 || domainSeparator || structHash) +// NOTE: domain is intentionally token-agnostic — no verifyingContract, no salt. +function eip712Digest(owner: Address, expiry: bigint, chainId: bigint): Hex { + const domainSeparator = keccak256( + encodeAbiParameters( + [ + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "uint256" }, + ], + [ + keccak256( + toHex("EIP712Domain(string name,string version,uint256 chainId)"), + ), + keccak256(toHex("SRC20BalanceRead")), + keccak256(toHex("1")), + chainId, + ], + ), + ); + + const structHash = keccak256( + encodeAbiParameters( + [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }], + [ + keccak256(toHex("BalanceRead(address owner,uint256 expiry)")), + owner, + expiry, + ], + ), + ); + + return keccak256(concat(["0x1901", domainSeparator, structHash])); +} + +test("signBalanceRead recovers to the owner under the EIP-712 chainId digest", async () => { + const signature = await signBalanceRead(PRIVATE_KEY, OWNER, EXPIRY, CHAIN_ID); + + const digest = eip712Digest(OWNER, EXPIRY, CHAIN_ID); + const recovered = await recoverAddress({ hash: digest, signature }); + + expect(recovered.toLowerCase()).toBe(OWNER.toLowerCase()); +}); diff --git a/packages/batch-read-ts/src/util/signature.ts b/packages/batch-read-ts/src/util/signature.ts index 0fb38bf..1423559 100644 --- a/packages/batch-read-ts/src/util/signature.ts +++ b/packages/batch-read-ts/src/util/signature.ts @@ -1,11 +1,4 @@ -import { - type Hex, - type Address, - keccak256, - encodePacked, - hexToSignature, - http, -} from "viem"; +import { type Hex, type Address, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { createShieldedWalletClient, @@ -26,34 +19,33 @@ export async function createClient(privateKey: Hex, mode: string) { return { client, account, chain }; } +// EIP-712 signature consumed by SRC20.balanceOfSigned (seismic-std-lib, contract PR #209). +// The domain is deliberately token-agnostic: only { name, version, chainId } — no +// verifyingContract and no salt, so one signature is valid across every SRC20 on the chain. +// Adding either field changes the domain typehash and the recovered signer no longer matches. export async function signBalanceRead( privateKey: Hex, owner: Address, expiry: bigint, + chainId: number | bigint, ): Promise { const account = privateKeyToAccount(privateKey); - const messageHash = keccak256( - encodePacked( - ["string", "address", "uint256"], - ["SRC20_BALANCE_READ", owner, expiry], - ), - ); - - const ethSignedHash = keccak256( - encodePacked( - ["string", "bytes32"], - ["\x19Ethereum Signed Message:\n32", messageHash], - ), - ); - - const signature = await account.sign({ hash: ethSignedHash }); - - const r = signature.slice(0, 66) as Hex; // First 32 bytes - const s = ("0x" + signature.slice(66, 130)) as Hex; // Next 32 bytes - const v = parseInt(signature.slice(130, 132), 16); // Last byte - - return encodePacked(["bytes32", "bytes32", "uint8"], [r, s, v]); + return account.signTypedData({ + domain: { + name: "SRC20BalanceRead", + version: "1", + chainId: Number(chainId), + }, + types: { + BalanceRead: [ + { name: "owner", type: "address" }, + { name: "expiry", type: "uint256" }, + ], + }, + primaryType: "BalanceRead", + message: { owner, expiry }, + }); } export function createExpiry(hoursFromNow: number = 1): bigint {