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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
21 changes: 20 additions & 1 deletion src/signing/feasibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ 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.
*/
export type FeasibilityIssueType =
| "insufficient_balance"
| "insufficient_allowance"
| "nonce_used";
| "nonce_used"
| "deadline_expired";

/**
* A single issue preventing order feasibility.
Expand Down Expand Up @@ -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([
Expand Down
106 changes: 105 additions & 1 deletion tests/feasibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down