diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa7532..257b78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `deadline_expired` check in `checkOrderFeasibility` — detects orders with expired permit deadlines +- `nonce_used` check in `checkOrderFeasibility` — detects orders whose Permit2 nonce has already been consumed + ## [0.4.0] - 2026-02-17 ### Added diff --git a/README.md b/README.md index 68c5d7d..f89f3ca 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,53 @@ const { id } = await txCache.submitOrder(signedOrder); console.log(`Order submitted with ID: ${id}`); ``` +### Preflight Checks + +Before submitting an order, verify it can actually execute on-chain: + +```typescript +import { checkOrderFeasibility } from "@signet-sh/sdk"; +import type { FeasibilityIssue } from "@signet-sh/sdk"; + +const result = await checkOrderFeasibility(publicClient, signedOrder); + +if (!result.feasible) { + for (const issue of result.issues) { + console.warn(`${issue.type}: ${issue.message}`); + } +} +``` + +`checkOrderFeasibility` runs all checks and returns a `FeasibilityResult` with a list of issues: + +| Issue Type | Description | +| ------------------------ | ------------------------------------------------------ | +| `insufficient_balance` | Owner lacks enough tokens for one or more inputs | +| `insufficient_allowance` | Owner has not approved Permit2 for the required amount | +| `nonce_used` | The Permit2 nonce has already been consumed | +| `deadline_expired` | The order deadline is in the past | + +Each `FeasibilityIssue` includes `token`, `required`, and `available` fields where applicable. + +#### Individual Checks + +For more targeted checks, use the lower-level functions directly: + +```typescript +import { hasPermit2Approval, isNonceUsed, randomNonce } from "@signet-sh/sdk"; + +// Check Permit2 allowance for specific tokens +const approved = await hasPermit2Approval(publicClient, ownerAddress, [ + { token: usdcAddress, amount: 1000000n }, +]); + +// Check if a nonce has been consumed +const used = await isNonceUsed(publicClient, ownerAddress, nonce); + +// Generate a random 256-bit nonce +const nonce = randomNonce(); +``` + ### On-Chain Operations The SDK exports ABIs and constants for on-chain operations. Use viem directly for contract interactions: @@ -436,6 +483,9 @@ const balances = await Promise.all( - `OrderEvent` - Parsed args from an `Order` event - `FilledEvent` - Parsed args from a `Filled` event - `SweepEvent` - Parsed args from a `Sweep` event +- `FeasibilityResult` - Result of a preflight feasibility check +- `FeasibilityIssue` - A single issue preventing order execution +- `FeasibilityIssueType` - Issue category: `"insufficient_balance"` | `"insufficient_allowance"` | `"nonce_used"` | `"deadline_expired"` - `Flow` - Entry mechanism type: `"passage"` or `"orders"` - `TokenSymbol` - Supported token symbols @@ -448,6 +498,10 @@ const balances = await Promise.all( - `serializeCallBundle(bundle)` - Serialize call bundle for JSON-RPC - `createTxCacheClient(url)` - Create a tx-cache client for bundle submission - `ensurePermit2Approval(walletClient, publicClient, params)` - Smart Permit2 approval with USDT handling +- `checkOrderFeasibility(client, order)` - Check if an order can execute on-chain +- `hasPermit2Approval(client, owner, tokens)` - Check Permit2 allowance for tokens +- `isNonceUsed(client, owner, nonce)` - Check if a Permit2 nonce has been consumed +- `randomNonce()` - Generate a random 256-bit Permit2 nonce - `getTokenDecimals(symbol, config?)` - Get token decimals with chain-specific overrides - `needsWethWrap(symbol, direction, flow)` - Check if ETH needs wrapping for operation diff --git a/src/signing/feasibility.ts b/src/signing/feasibility.ts index d8b6fd7..941fd7d 100644 --- a/src/signing/feasibility.ts +++ b/src/signing/feasibility.ts @@ -3,6 +3,8 @@ import { erc20Abi } from "viem"; import { PERMIT2_ADDRESS } from "../constants/permit2.js"; import type { SignedOrder } from "../types/order.js"; import type { TokenPermissions } from "../types/primitives.js"; +import { nowSeconds } from "../utils/time.js"; +import { isNonceUsed } from "./nonce.js"; /** * Categories of feasibility issues. @@ -10,7 +12,8 @@ import type { TokenPermissions } from "../types/primitives.js"; export type FeasibilityIssueType = | "insufficient_balance" | "insufficient_allowance" - | "nonce_used"; + | "nonce_used" + | "deadline_expired"; /** * A single issue preventing order feasibility. @@ -65,6 +68,22 @@ export async function checkOrderFeasibility( const owner = order.permit.owner; const tokens = order.permit.permit.permitted; + // Check if deadline has expired + if (order.permit.permit.deadline < nowSeconds()) { + issues.push({ + type: "deadline_expired", + message: "Order permit deadline has expired", + }); + } + + // Check if nonce has been consumed + if (await isNonceUsed(client, owner, order.permit.permit.nonce)) { + issues.push({ + type: "nonce_used", + message: "Order permit nonce has already been used", + }); + } + // Check balance and allowance for each input token const checks = tokens.map(async (token) => { const [balance, allowance] = await Promise.all([ diff --git a/tests/feasibility.test.ts b/tests/feasibility.test.ts index 4ff98c2..b511ee5 100644 --- a/tests/feasibility.test.ts +++ b/tests/feasibility.test.ts @@ -4,7 +4,15 @@ * These tests run against a local Anvil instance forking Parmigiana. * Run with: npm run test:anvil */ -import { describe, expect, it, beforeAll, beforeEach, afterEach } from "vitest"; +import { + describe, + expect, + it, + beforeAll, + beforeEach, + afterEach, + vi, +} from "vitest"; import { createPublicClient, createTestClient, @@ -25,6 +33,15 @@ import { import { PERMIT2_ADDRESS } from "../src/constants/permit2.js"; import { setTokenAllowance, setTokenBalance } from "./testToken.js"; +const mockIsNonceUsed = vi.fn().mockResolvedValue(false); + +vi.mock("../src/signing/nonce.js", async (importOriginal) => { + return { + ...(await importOriginal()), + isNonceUsed: mockIsNonceUsed, + }; +}); + const TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; const ANVIL_URL = "http://127.0.0.1:8545"; @@ -254,6 +271,93 @@ describe("Order feasibility", () => { expect(issueTypes).toContain("insufficient_balance"); expect(issueTypes).toContain("insufficient_allowance"); }); + + it("returns deadline_expired issue when deadline is in the past", async () => { + const inputAmount = parseEther("100"); + + await setTokenBalance( + testClient, + TEST_TOKEN, + account.address, + inputAmount, + publicClient + ); + await setTokenAllowance( + testClient, + TEST_TOKEN, + account.address, + PERMIT2_ADDRESS, + inputAmount, + publicClient + ); + + const deadline = BigInt(Math.floor(Date.now() / 1000) - 3600); + const order = await UnsignedOrder.new() + .withInput(TEST_TOKEN, inputAmount) + .withOutput( + TEST_TOKEN, + parseEther("99"), + account.address, + Number(PARMIGIANA.hostChainId) + ) + .withDeadline(deadline) + .withChain({ + chainId: PARMIGIANA.rollupChainId, + orderContract: PARMIGIANA.rollupOrders, + }) + .sign(walletClient); + + const result = await checkOrderFeasibility(publicClient, order); + + expect(result.feasible).toBe(false); + expect(result.issues.some((i) => i.type === "deadline_expired")).toBe( + true + ); + }); + + it("returns nonce_used issue when nonce has been consumed", async () => { + const inputAmount = parseEther("100"); + + await setTokenBalance( + testClient, + TEST_TOKEN, + account.address, + inputAmount, + publicClient + ); + await setTokenAllowance( + testClient, + TEST_TOKEN, + account.address, + PERMIT2_ADDRESS, + inputAmount, + publicClient + ); + + mockIsNonceUsed.mockResolvedValueOnce(true); + + const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + const order = await UnsignedOrder.new() + .withInput(TEST_TOKEN, inputAmount) + .withOutput( + TEST_TOKEN, + parseEther("99"), + account.address, + Number(PARMIGIANA.hostChainId) + ) + .withDeadline(deadline) + .withChain({ + chainId: PARMIGIANA.rollupChainId, + orderContract: PARMIGIANA.rollupOrders, + }) + .sign(walletClient); + + const result = await checkOrderFeasibility(publicClient, order); + + expect(result.feasible).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].type).toBe("nonce_used"); + }); }); describe("hasPermit2Approval", () => {