diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 238cf44..4bb9034 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,4 +23,9 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build - - run: pnpm publish --no-git-checks + - run: | + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + pnpm publish --no-git-checks --tag canary + else + pnpm publish --no-git-checks + fi diff --git a/examples/account/approveDepositWalletAllowances.ts b/examples/account/approveDepositWalletAllowances.ts new file mode 100644 index 0000000..352dc78 --- /dev/null +++ b/examples/account/approveDepositWalletAllowances.ts @@ -0,0 +1,250 @@ +import { resolve } from "node:path"; +import { config as dotenvConfig } from "dotenv"; +import { createPublicClient, encodeFunctionData, http, maxUint256, parseAbi } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { polygon, polygonAmoy } from "viem/chains"; + +import { Chain } from "../../src"; +import { getContractConfig } from "../../src/config"; + +dotenvConfig({ path: resolve(__dirname, "../../.env") }); + +const WALLET_ABI = parseAbi(["function nonce() view returns (uint256)"]); +const ERC20_ABI = parseAbi([ + "function allowance(address,address) view returns (uint256)", + "function approve(address,uint256) returns (bool)", +]); +const CTF_ABI = parseAbi([ + "function isApprovedForAll(address,address) view returns (bool)", + "function setApprovalForAll(address,bool)", +]); + +const BATCH_EIP712_TYPES = { + Batch: [ + { name: "wallet", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "calls", type: "Call[]" }, + ], + Call: [ + { name: "target", type: "address" }, + { name: "value", type: "uint256" }, + { name: "data", type: "bytes" }, + ], +} as const; + +const DEADLINE_OFFSET = 240n; // 4 min; relayer max is 300s + +async function gammaLogin( + account: ReturnType, + chainId: number, + gammaUrl: string, +): Promise { + const nonceRes = await fetch(`${gammaUrl}/nonce`); + if (!nonceRes.ok) throw new Error(`/nonce ${nonceRes.status}`); + const { nonce } = (await nonceRes.json()) as { nonce: string }; + const nonceCookies = parseCookies(nonceRes.headers); + + const issuedAt = new Date().toISOString(); + const expiration = new Date(Date.now() + 7 * 24 * 3_600_000).toISOString(); + const domain = "polymarket.com"; + + const siweMessage = [ + `${domain} wants you to sign in with your Ethereum account:`, + account.address, + "", + "Welcome to Polymarket! Sign to connect.", + "", + `URI: https://${domain}`, + "Version: 1", + `Chain ID: ${chainId}`, + `Nonce: ${nonce}`, + `Issued At: ${issuedAt}`, + `Expiration Time: ${expiration}`, + ].join("\n"); + + const signature = await account.signMessage({ message: siweMessage }); + + const jsonPayload = JSON.stringify({ + domain, + address: account.address, + statement: "Welcome to Polymarket! Sign to connect.", + uri: `https://${domain}`, + version: "1", + chainId, + nonce, + issuedAt, + expirationTime: expiration, + }); + const authToken = Buffer.from(`${jsonPayload}:::${signature}`).toString("base64"); + + const loginRes = await fetch(`${gammaUrl}/login`, { + headers: { Authorization: `Bearer ${authToken}`, Cookie: nonceCookies }, + }); + if (!loginRes.ok) throw new Error(`/login ${loginRes.status}: ${await loginRes.text()}`); + + const loginCookies = parseCookies(loginRes.headers); + console.log("Gamma login successful"); + return mergeCookies(nonceCookies, loginCookies); +} + +async function submitBatch( + account: ReturnType, + chainId: number, + wallet: `0x${string}`, + factory: `0x${string}`, + nonce: bigint, + calls: Array<{ target: `0x${string}`; value: bigint; data: `0x${string}` }>, + cookies: string, + relayerUrl: string, +): Promise { + const deadline = BigInt(Math.floor(Date.now() / 1000)) + DEADLINE_OFFSET; + + const sig = await account.signTypedData({ + domain: { name: "DepositWallet", version: "1", chainId, verifyingContract: wallet }, + types: BATCH_EIP712_TYPES, + primaryType: "Batch", + message: { wallet, nonce, deadline, calls }, + }); + + const resp = await fetch(`${relayerUrl}/submit`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookies }, + body: JSON.stringify({ + type: "WALLET", + from: account.address, + to: factory, + nonce: nonce.toString(), + signature: sig, + depositWalletParams: { + depositWallet: wallet, + deadline: deadline.toString(), + calls: calls.map((c) => ({ target: c.target, value: c.value.toString(), data: c.data })), + }, + }), + }); + if (!resp.ok) throw new Error(`Submit failed ${resp.status}: ${await resp.text()}`); + + const result = (await resp.json()) as { transactionID: string; state: string }; + console.log(` submitted txnID=${result.transactionID} state=${result.state}`); + return result.transactionID; +} + +async function pollConfirmed(txnId: string, relayerUrl: string): Promise { + while (true) { + const resp = await fetch(`${relayerUrl}/transaction?id=${txnId}`); + const data = (await resp.json()) as Array<{ state: string; transactionHash?: string }>; + const state = data[0]?.state ?? "UNKNOWN"; + console.log(` state=${state}`); + if (state === "STATE_CONFIRMED") { + console.log(` txHash=${data[0]?.transactionHash}`); + return; + } + if (state === "STATE_FAILED") throw new Error(`Transaction ${txnId} failed`); + await new Promise((r) => setTimeout(r, 3000)); + } +} + +async function main() { + const isMainnet = true; + + const pk = process.env.PK as `0x${string}`; + const rpcUrl = process.env.RPC_URL as string; + const wallet = process.env.DEPOSIT_WALLET as `0x${string}`; + const factory = process.env.DEPOSIT_WALLET_FACTORY as `0x${string}`; + const gammaUrl = process.env.GAMMA_API_URL as string; + const relayerUrl = process.env.RELAYER_API_URL as string; + + if (!pk || !rpcUrl || !wallet || !factory || !gammaUrl || !relayerUrl) { + throw new Error( + "Missing required env: PK, RPC_URL, DEPOSIT_WALLET, DEPOSIT_WALLET_FACTORY, GAMMA_API_URL, RELAYER_API_URL", + ); + } + + const chainId = isMainnet ? Chain.POLYGON : Chain.AMOY; + const viemChain = isMainnet ? polygon : polygonAmoy; + const contracts = getContractConfig(chainId); + + const account = privateKeyToAccount(pk); + const publicClient = createPublicClient({ chain: viemChain, transport: http(rpcUrl) }); + + console.log(`Signer: ${account.address}`); + console.log(`Deposit wallet: ${wallet}`); + console.log(`Chain ID: ${chainId}`); + console.log(`USDC: ${contracts.collateral}`); + console.log(`CTF: ${contracts.conditionalTokens}`); + console.log(`Exchange V2: ${contracts.exchangeV2}`); + + const usdc = contracts.collateral as `0x${string}`; + const ctf = contracts.conditionalTokens as `0x${string}`; + const exchange = contracts.exchangeV2 as `0x${string}`; + + const [usdcAllowanceCtf, usdcAllowanceExchange, ctfApprovedExchange] = await Promise.all([ + publicClient.readContract({ address: usdc, abi: ERC20_ABI, functionName: "allowance", args: [wallet, ctf] }), + publicClient.readContract({ address: usdc, abi: ERC20_ABI, functionName: "allowance", args: [wallet, exchange] }), + publicClient.readContract({ address: ctf, abi: CTF_ABI, functionName: "isApprovedForAll", args: [wallet, exchange] }), + ]); + + console.log(`\nCurrent state:`); + console.log(` USDC → CTF: ${usdcAllowanceCtf}`); + console.log(` USDC → Exchange V2: ${usdcAllowanceExchange}`); + console.log(` CTF → Exchange V2: ${ctfApprovedExchange}`); + + const needsUsdcCtf = usdcAllowanceCtf === 0n; + const needsUsdcExchange = usdcAllowanceExchange === 0n; + const needsCtfExchange = !ctfApprovedExchange; + + if (!needsUsdcCtf && !needsUsdcExchange && !needsCtfExchange) { + console.log("\nAll approvals already set — nothing to do"); + return; + } + + console.log("\nLogging into Gamma..."); + const cookies = await gammaLogin(account, chainId, gammaUrl); + + if (needsUsdcCtf) { + console.log("\nApproving USDC → CTF..."); + const nonce = await publicClient.readContract({ address: wallet, abi: WALLET_ABI, functionName: "nonce" }); + const data = encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", args: [ctf, maxUint256] }); + const txnId = await submitBatch(account, chainId, wallet, factory, nonce, [{ target: usdc, value: 0n, data }], cookies, relayerUrl); + await pollConfirmed(txnId, relayerUrl); + } + + if (needsUsdcExchange) { + console.log("\nApproving USDC → Exchange V2..."); + const nonce = await publicClient.readContract({ address: wallet, abi: WALLET_ABI, functionName: "nonce" }); + const data = encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", args: [exchange, maxUint256] }); + const txnId = await submitBatch(account, chainId, wallet, factory, nonce, [{ target: usdc, value: 0n, data }], cookies, relayerUrl); + await pollConfirmed(txnId, relayerUrl); + } + + if (needsCtfExchange) { + console.log("\nSetting CTF approval for Exchange V2..."); + const nonce = await publicClient.readContract({ address: wallet, abi: WALLET_ABI, functionName: "nonce" }); + const data = encodeFunctionData({ abi: CTF_ABI, functionName: "setApprovalForAll", args: [exchange, true] }); + const txnId = await submitBatch(account, chainId, wallet, factory, nonce, [{ target: ctf, value: 0n, data }], cookies, relayerUrl); + await pollConfirmed(txnId, relayerUrl); + } + + console.log("\nAll approvals done"); +} + +function parseCookies(headers: Headers): string { + const raw: string[] = + typeof (headers as any).getSetCookie === "function" + ? (headers as any).getSetCookie() + : (headers.get("set-cookie") ?? "").split(/,(?=[^ ])/).filter(Boolean); + return raw.map((c) => c.split(";")[0]).join("; "); +} + +function mergeCookies(a: string, b: string): string { + const map = new Map(); + for (const part of [...a.split("; "), ...b.split("; ")]) { + const eq = part.indexOf("="); + if (eq === -1) continue; + map.set(part.slice(0, eq).trim(), part.trim()); + } + return [...map.values()].join("; "); +} + +main(); diff --git a/examples/keys/signatureTypes.ts b/examples/keys/signatureTypes.ts index ce34228..5bd88fb 100644 --- a/examples/keys/signatureTypes.ts +++ b/examples/keys/signatureTypes.ts @@ -46,6 +46,17 @@ async function main() { signatureType: SignatureTypeV2.POLY_GNOSIS_SAFE, funderAddress: gnosisSafeAddress, }); + + // Client used with a Polymarket Deposit Wallet: Signature type 3 (POLY_1271) + const depositWalletAddress = "0x..."; + const depositWalletClient = new ClobClient({ + host, + chain: chainId, + signer: walletClient, + creds, + signatureType: SignatureTypeV2.POLY_1271, + funderAddress: depositWalletAddress, + }); } main(); diff --git a/package.json b/package.json index 17e3c85..8b4d92e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@polymarket/clob-client-v2", "description": "TypeScript client for Polymarket's CLOB", - "version": "1.0.2", + "version": "1.0.3-canary.0", "type": "module", "main": "dist/index.cjs", "types": "dist/index.d.ts", diff --git a/src/client.ts b/src/client.ts index 1f4c77d..062628b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,3 +1,5 @@ +import type { LocalAccount } from "viem"; + import { BUILDER_FEES_BPS, bytes32Zero, @@ -84,7 +86,7 @@ import { calculateSellMarketPrice, } from "./order-builder/helpers/index.js"; import { OrderBuilder } from "./order-builder/index.js"; -import type { SignatureTypeV2 } from "./order-utils/model/signatureTypeV2.js"; +import { SignatureTypeV2 } from "./order-utils/model/signatureTypeV2.js"; import type { ClobSigner } from "./signing/signer.js"; import type { ApiKeyCreds, @@ -179,6 +181,7 @@ export interface ClobClientOptions { getSigner?: () => Promise | ClobSigner; retryOnError?: boolean; throwOnError?: boolean; + sessionSigner?: LocalAccount; } export class ClobClient { @@ -211,6 +214,10 @@ export class ClobClient { readonly builderConfig?: BuilderConfig; + readonly signatureType: SignatureTypeV2; + + readonly funderAddress?: string; + private cachedVersion?: number; readonly retryOnError?: boolean; @@ -229,6 +236,7 @@ export class ClobClient { getSigner, retryOnError, throwOnError, + sessionSigner, }: ClobClientOptions) { this.host = host.endsWith("/") ? host.slice(0, -1) : host; this.chainId = chain; @@ -245,7 +253,10 @@ export class ClobClient { signatureType, funderAddress, getSigner, + sessionSigner, ); + this.signatureType = signatureType ?? SignatureTypeV2.EOA; + this.funderAddress = funderAddress; this.tickSizes = {}; this.negRisk = {}; this.feeRates = {}; diff --git a/src/constants.ts b/src/constants.ts index dc820f8..d36c5ea 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,3 +11,6 @@ export const bytes32Zero = "0x00000000000000000000000000000000000000000000000000 export const ORDER_VERSION_MISMATCH_ERROR = "order_version_mismatch"; export const BUILDER_FEES_BPS = 10000; + +export const SESSION_SIGNER_MAGIC = + "6492649264926492649264926492649264926492649264926492649264926492"; diff --git a/src/headers/index.ts b/src/headers/index.ts index c04bac9..c1cad11 100644 --- a/src/headers/index.ts +++ b/src/headers/index.ts @@ -17,6 +17,7 @@ export const createL1Headers = async ( chainId: Chain, nonce?: number, timestamp?: number, + address?: string, ): Promise => { let ts = Math.floor(Date.now() / 1000); if (timestamp !== undefined) { @@ -27,11 +28,11 @@ export const createL1Headers = async ( n = nonce; } - const sig = await buildClobEip712Signature(signer, chainId, ts, n); - const address = await getSignerAddress(signer); + const sig = await buildClobEip712Signature(signer, chainId, ts, n, address); + const resolvedAddress = address ?? (await getSignerAddress(signer)); const headers = { - POLY_ADDRESS: address, + POLY_ADDRESS: resolvedAddress, POLY_SIGNATURE: sig, POLY_TIMESTAMP: `${ts}`, POLY_NONCE: `${n}`, diff --git a/src/order-builder/helpers/buildOrder.ts b/src/order-builder/helpers/buildOrder.ts index 91ebcc8..14f3d5c 100644 --- a/src/order-builder/helpers/buildOrder.ts +++ b/src/order-builder/helpers/buildOrder.ts @@ -1,3 +1,5 @@ +import type { LocalAccount } from "viem"; + import { ExchangeOrderBuilderV1, ExchangeOrderBuilderV2, @@ -23,12 +25,19 @@ export const buildOrder = async ( chainId: number, orderData: OrderDataV1 | OrderDataV2, version: number = 2, + sessionSigner?: LocalAccount, ): Promise => { switch (version) { case 1: return buildOrderV1(signer, exchangeAddress, chainId, orderData as OrderDataV1); case 2: - return buildOrderV2(signer, exchangeAddress, chainId, orderData as OrderDataV2); + return buildOrderV2( + signer, + exchangeAddress, + chainId, + orderData as OrderDataV2, + sessionSigner, + ); default: throw new Error(`unsupported order version ${version}`); } @@ -49,7 +58,14 @@ export const buildOrderV2 = async ( exchangeAddress: string, chainId: number, orderData: OrderDataV2, + sessionSigner?: LocalAccount, ): Promise => { - const ctfExchangeOrderBuilder = new ExchangeOrderBuilderV2(exchangeAddress, chainId, signer); + const ctfExchangeOrderBuilder = new ExchangeOrderBuilderV2( + exchangeAddress, + chainId, + signer, + undefined, + sessionSigner, + ); return ctfExchangeOrderBuilder.buildSignedOrder(orderData); }; diff --git a/src/order-builder/helpers/createMarketOrder.ts b/src/order-builder/helpers/createMarketOrder.ts index 75d6b3e..4c2eb33 100644 --- a/src/order-builder/helpers/createMarketOrder.ts +++ b/src/order-builder/helpers/createMarketOrder.ts @@ -1,3 +1,5 @@ +import type { LocalAccount } from "viem"; + import { getContractConfig } from "../../config.js"; import type { SignedOrderV1, SignedOrderV2 } from "../../order-utils/index.js"; import { SignatureTypeV2 } from "../../order-utils/index.js"; @@ -20,15 +22,19 @@ export const createMarketOrder = async ( userMarketOrder: UserMarketOrderV1 | UserMarketOrderV2, options: CreateOrderOptions, version: number, + sessionSigner?: LocalAccount, ): Promise => { const eoaSignerAddress = await getSignerAddress(eoaSigner); // If funder address is not given, use the signer address const maker = funderAddress === undefined ? eoaSignerAddress : funderAddress; + + // For POLY_1271, both maker and signer in the order are the wallet address + const signerForOrder = signatureType === SignatureTypeV2.POLY_1271 ? maker : eoaSignerAddress; const contractConfig = getContractConfig(chainId); const orderData = await buildMarketOrderCreationArgs( - eoaSignerAddress, + signerForOrder, maker, signatureType, userMarketOrder, @@ -55,5 +61,5 @@ export const createMarketOrder = async ( throw new Error(`unsupported order version ${version}`); } - return buildOrder(eoaSigner, exchangeContract, chainId, orderData, version); + return buildOrder(eoaSigner, exchangeContract, chainId, orderData, version, sessionSigner); }; diff --git a/src/order-builder/helpers/createOrder.ts b/src/order-builder/helpers/createOrder.ts index 8e3ec8c..0ae41e2 100644 --- a/src/order-builder/helpers/createOrder.ts +++ b/src/order-builder/helpers/createOrder.ts @@ -1,3 +1,5 @@ +import type { LocalAccount } from "viem"; + import { getContractConfig } from "../../config.js"; import type { SignedOrderV1, SignedOrderV2 } from "../../order-utils/index.js"; import { SignatureTypeV2 } from "../../order-utils/index.js"; @@ -15,15 +17,19 @@ export const createOrder = async ( userOrder: UserOrderV1 | UserOrderV2, options: CreateOrderOptions, version: number, + sessionSigner?: LocalAccount, ): Promise => { const eoaSignerAddress = await getSignerAddress(eoaSigner); // If funder address is not given, use the signer address const maker = funderAddress === undefined ? eoaSignerAddress : funderAddress; + + // For POLY_1271, both maker and signer in the order are the wallet address + const signerForOrder = signatureType === SignatureTypeV2.POLY_1271 ? maker : eoaSignerAddress; const contractConfig = getContractConfig(chainId); const orderData = await buildOrderCreationArgs( - eoaSignerAddress, + signerForOrder, maker, signatureType, userOrder, @@ -48,5 +54,5 @@ export const createOrder = async ( default: throw new Error(`unsupported order version ${version}`); } - return buildOrder(eoaSigner, exchangeContract, chainId, orderData, version); + return buildOrder(eoaSigner, exchangeContract, chainId, orderData, version, sessionSigner); }; diff --git a/src/order-builder/orderBuilder.ts b/src/order-builder/orderBuilder.ts index e78db13..344d1a6 100644 --- a/src/order-builder/orderBuilder.ts +++ b/src/order-builder/orderBuilder.ts @@ -1,3 +1,5 @@ +import type { LocalAccount } from "viem"; + import { SignatureTypeV2, type SignedOrderV1, type SignedOrderV2 } from "../order-utils/index.js"; import type { ClobSigner } from "../signing/signer.js"; import type { @@ -24,6 +26,8 @@ export class OrderBuilder { // If not provided, funderAddress is the signer address readonly funderAddress?: string; + readonly sessionSigner?: LocalAccount; + /** * Optional function to dynamically resolve the signer. * If provided, this function will be called to obtain a fresh signer instance @@ -39,12 +43,14 @@ export class OrderBuilder { signatureType?: SignatureTypeV2, funderAddress?: string, getSigner?: () => Promise | ClobSigner, + sessionSigner?: LocalAccount, ) { this.signer = signer; this.chainId = chainId; this.signatureType = signatureType === undefined ? SignatureTypeV2.EOA : signatureType; this.funderAddress = funderAddress; this.getSigner = getSigner; + this.sessionSigner = sessionSigner; } /** @@ -64,6 +70,7 @@ export class OrderBuilder { userOrder, options, version, + this.sessionSigner, ); } @@ -84,6 +91,7 @@ export class OrderBuilder { userMarketOrder, options, version, + this.sessionSigner, ); } diff --git a/src/order-utils/exchangeOrderBuilderV2.ts b/src/order-utils/exchangeOrderBuilderV2.ts index a3aefa4..a222833 100644 --- a/src/order-utils/exchangeOrderBuilderV2.ts +++ b/src/order-utils/exchangeOrderBuilderV2.ts @@ -1,6 +1,14 @@ -import { hashTypedData } from "viem"; +import { + type Address, + encodeAbiParameters, + hashTypedData, + keccak256, + type LocalAccount, + toHex, + type WalletClient, +} from "viem"; -import { bytes32Zero } from "../constants.js"; +import { bytes32Zero, SESSION_SIGNER_MAGIC } from "../constants.js"; import { type ClobSigner, getSignerAddress, signTypedDataWithSigner } from "../signing/signer.js"; import { CTF_EXCHANGE_V2_DOMAIN_NAME, @@ -13,19 +21,58 @@ import type { OrderDataV2, OrderV2, SignedOrderV2 } from "./model/orderDataV2.js import { SignatureTypeV2 } from "./model/signatureTypeV2.js"; import { generateOrderSalt } from "./utils.js"; +const ORDER_TYPE_STRING = + "Order(uint256 salt,address maker,address signer,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,uint256 timestamp,bytes32 metadata,bytes32 builder)"; + +const ORDER_TYPE_HASH = keccak256(toHex(ORDER_TYPE_STRING)); +const ORDER_TYPE_HEX = toHex(ORDER_TYPE_STRING).slice(2); +const ORDER_TYPE_LEN_HEX = ORDER_TYPE_STRING.length.toString(16).padStart(4, "0"); + +const DOMAIN_TYPE_HASH = keccak256( + toHex("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), +); + +const SOLADY_TYPE_HASH = keccak256( + toHex( + `TypedDataSign(Order contents,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)${ORDER_TYPE_STRING}`, + ), +); + +const DEPOSIT_WALLET_NAME_HASH = keccak256(toHex("DepositWallet")); +const DEPOSIT_WALLET_VERSION_HASH = keccak256(toHex("1")); +const CTF_EXCHANGE_NAME_HASH = keccak256(toHex(CTF_EXCHANGE_V2_DOMAIN_NAME)); +const CTF_EXCHANGE_VERSION_HASH = keccak256(toHex(CTF_EXCHANGE_V2_DOMAIN_VERSION)); + export class ExchangeOrderBuilderV2 { + private readonly appDomainSep: `0x${string}`; + constructor( private readonly contractAddress: string, private readonly chainId: number, private readonly signer: ClobSigner, private readonly generateSalt = generateOrderSalt, - ) {} + private readonly sessionSigner?: LocalAccount, + ) { + this.appDomainSep = keccak256( + encodeAbiParameters( + [ + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "uint256" }, + { type: "address" }, + ], + [ + DOMAIN_TYPE_HASH, + CTF_EXCHANGE_NAME_HASH, + CTF_EXCHANGE_VERSION_HASH, + BigInt(chainId), + contractAddress as Address, + ], + ), + ); + } - /** - * build an order object including the signature. - * @param orderData - * @returns a SignedOrder object (order + signature) - */ async buildSignedOrder(orderData: OrderDataV2): Promise { const order = await this.buildOrder(orderData); const orderTypedData = this.buildOrderTypedData(order); @@ -37,11 +84,6 @@ export class ExchangeOrderBuilderV2 { } as SignedOrderV2; } - /** - * Creates an Order object from order data. - * @param OrderData - * @returns a Order object (not signed) - */ async buildOrder({ maker, tokenId, @@ -59,9 +101,12 @@ export class ExchangeOrderBuilderV2 { signer = maker; } - const signerAddress = await getSignerAddress(this.signer); - if (signer !== signerAddress) { - throw new Error("signer does not match"); + // For POLY_1271 (deposit wallets), signer is the wallet contract — skip EOA address check + if (signatureType !== SignatureTypeV2.POLY_1271) { + const signerAddress = await getSignerAddress(this.signer); + if (signer !== signerAddress) { + throw new Error("signer does not match"); + } } return { @@ -80,11 +125,6 @@ export class ExchangeOrderBuilderV2 { }; } - /** - * Parses an Order object to EIP712 typed data - * @param order - * @returns a EIP712TypedData object - */ buildOrderTypedData(order: OrderV2): EIP712TypedData { return { primaryType: "Order", @@ -114,29 +154,115 @@ export class ExchangeOrderBuilderV2 { }; } - /** - * Generates order's signature from a EIP712TypedData object + the signer address - * @param typedData - * @returns a OrderSignature that is an string - */ - buildOrderSignature(typedData: EIP712TypedData): Promise { + async buildOrderSignature(typedData: EIP712TypedData): Promise { delete typedData.types.EIP712Domain; - return signTypedDataWithSigner({ - signer: this.signer, - domain: typedData.domain, - types: typedData.types, - value: typedData.message, - primaryType: typedData.primaryType, - }); + + const msg = typedData.message; + + if ((msg.signatureType as number) !== SignatureTypeV2.POLY_1271) { + return signTypedDataWithSigner({ + signer: this.signer, + domain: typedData.domain, + types: typedData.types, + value: typedData.message, + primaryType: typedData.primaryType, + }); + } + + const contentsHash = keccak256( + encodeAbiParameters( + [ + { type: "bytes32" }, + { type: "uint256" }, + { type: "address" }, + { type: "address" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "uint8" }, + { type: "uint8" }, + { type: "uint256" }, + { type: "bytes32" }, + { type: "bytes32" }, + ], + [ + ORDER_TYPE_HASH, + BigInt(msg.salt as string), + msg.maker as Address, + msg.signer as Address, + BigInt(msg.tokenId as string), + BigInt(msg.makerAmount as string), + BigInt(msg.takerAmount as string), + msg.side as number, + msg.signatureType as number, + BigInt(msg.timestamp as string), + msg.metadata as `0x${string}`, + msg.builder as `0x${string}`, + ], + ), + ); + + const typedDataSignStructHash = keccak256( + encodeAbiParameters( + [ + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "uint256" }, + { type: "address" }, + { type: "bytes32" }, + ], + [ + SOLADY_TYPE_HASH, + contentsHash, + DEPOSIT_WALLET_NAME_HASH, + DEPOSIT_WALLET_VERSION_HASH, + BigInt(this.chainId), + msg.signer as Address, + bytes32Zero as `0x${string}`, + ], + ), + ); + + // digest = keccak256(0x1901 || appDomainSep || structHash) + const digest = keccak256( + `0x1901${this.appDomainSep.slice(2)}${typedDataSignStructHash.slice(2)}` as `0x${string}`, + ); + + if (this.sessionSigner) { + if (!this.sessionSigner.sign) { + throw new Error("sessionSigner must support raw hash signing (sign method)"); + } + const innerSig = await this.sessionSigner.sign({ hash: digest }); + const nestedSig = this.buildNestedSig(innerSig, contentsHash); + + // abi.encode(sessionSignerAsBytes32, bytes32(0), nestedSig) || SESSION_SIGNER_MAGIC + const sessionSignerBytes32 = + `0x${this.sessionSigner.address.slice(2).toLowerCase().padStart(64, "0")}` as `0x${string}`; + const encoded = encodeAbiParameters( + [{ type: "bytes32" }, { type: "bytes32" }, { type: "bytes" }], + [sessionSignerBytes32, bytes32Zero as `0x${string}`, nestedSig], + ); + return `${encoded}${SESSION_SIGNER_MAGIC}`; + } + + const localAccount = (this.signer as WalletClient).account as LocalAccount | undefined; + if (!localAccount?.sign) { + throw new Error( + "POLY_1271 requires either a sessionSigner or a WalletClient with a local account", + ); + } + const innerSig = await localAccount.sign({ hash: digest }); + return this.buildNestedSig(innerSig, contentsHash); } - /** - * Generates the hash of the order from a EIP712TypedData object. - * @param orderTypedData - * @returns a OrderHash that is an string - */ buildOrderHash(orderTypedData: EIP712TypedData): OrderHash { - const digest = hashTypedData(orderTypedData); - return digest; + return hashTypedData(orderTypedData); + } + + private buildNestedSig(innerSig: string, contentsHash: string): `0x${string}` { + // innerSig (65) || appDomainSep (32) || contentsHash (32) || contentsType || uint16_BE(len) + return `0x${innerSig.slice(2)}${this.appDomainSep.slice(2)}${contentsHash.slice(2)}${ORDER_TYPE_HEX}${ORDER_TYPE_LEN_HEX}`; } } diff --git a/src/signing/eip712.ts b/src/signing/eip712.ts index da8616e..1deaf4f 100644 --- a/src/signing/eip712.ts +++ b/src/signing/eip712.ts @@ -14,8 +14,9 @@ export const buildClobEip712Signature = async ( chainId: Chain, timestamp: number, nonce: number, + address?: string, ): Promise => { - const address = await getSignerAddress(signer); + const resolvedAddress = address ?? (await getSignerAddress(signer)); const ts = timestamp.toString(); const domain = { @@ -33,7 +34,7 @@ export const buildClobEip712Signature = async ( ], }; const value = { - address, + address: resolvedAddress, timestamp: ts, nonce, message: MSG_TO_SIGN,