npm package: @intentguard/sdk
Description: IntentGuard SDK — submit protected transactions with on-chain constraint enforcement
Latest version: 0.2.0

# Wallet SDK Integration

IntentGuard is an enforcement layer for transaction outcomes. It allows wallets and applications to declare economic constraints, such as maximum outflows and minimum inflows, and attach them to signed transactions. Transactions are submitted through the IntentGuard RPC, where constraints are verified atomically before inclusion. If the on-chain result would violate any declared constraint, the transaction is not included and no gas is consumed.

For a conceptual overview of Transaction Outcome Enforcement, see [What is IntentGuard?](../intentguard/overview.md).

## Installation

```bash
npm install @intentguard/sdk
```

No peer dependencies required.

## Configuration

### Constructor

The only required parameter is `chainId`. All other parameters have defaults:

```ts
import { IntentGuardClient } from "@intentguard/sdk";

const client = new IntentGuardClient({ chainId: 1 });
```

All parameters:

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `chainId` | `number` | Yes | — | EVM chain ID (e.g. `1` for mainnet, `11155111` for Sepolia) |
| `rpcUrl` | `string` | Yes | `"https://rpc.intentguard.xyz"` | IntentGuard RPC endpoint |
| `enforcerType` | `EnforcerType` | Yes | `"balance"` | Enforcer to use for constraint verification (see [Enforcer types](#enforcer-types)) |
| `defaultValidityBlocks` | `number` | Yes | `5` | Blocks ahead to use as `validUntilBlock` when not provided in `submitProtectedTransaction`. Does not apply to `submitProtectedTransactionWithRetry` (which requires explicit `retryUntilBlock`). |
| `minValidityBlocks` | `number` | Yes | `2` | Minimum acceptable `validUntilBlock - currentBlock`. Requests below this threshold are rejected client-side with `INVALID_VALIDITY_WINDOW`. |
| `timeoutMs` | `number` | Yes | No timeout | Request timeout in milliseconds per RPC call |
| `fetch` | `FetchFn` | Yes | Global `fetch` | Custom fetch implementation |

All JSON-RPC calls go to `rpcUrl`. The SDK attaches an `X-IntentGuard-SDK` header with the package version on every request.

### Environment variables

For Node.js or server-side deployments. In browser environments, pass config via the constructor instead.

```ts
import { IntentGuardClient, fromEnv } from "@intentguard/sdk";

const client = new IntentGuardClient(fromEnv());
```

`fromEnv()` returns an `IntentGuardConfig` object. It throws if `INTENTGUARD_CHAIN_ID` is not set or is not a positive integer.

| Variable | Required | Default | Description |
|---|---|---|---|
| `INTENTGUARD_CHAIN_ID` | Yes | — | Chain ID (integer) |
| `INTENTGUARD_RPC_URL` | Yes | `https://rpc.intentguard.xyz` | IntentGuard RPC endpoint |
| `INTENTGUARD_ENFORCER_TYPE` | Yes | `balance` | Enforcer type for constraint verification |

## Supported chains

| Network | Chain ID | Status |
|---|---|---|
| Ethereum Sepolia | `11155111` | **Available** |
| Ethereum Mainnet | `1` | Planned |

```ts
// Sepolia (available)
const client = new IntentGuardClient({ chainId: 11155111 });

// Mainnet (planned)
const client = new IntentGuardClient({ chainId: 1 });
```

## Transaction submission requirements

Protected transactions must remain private until block inclusion. They must only be submitted through the IntentGuard RPC endpoint.

Do not broadcast protected transactions to public RPC endpoints or the public mempool. Those systems do not execute constraint enforcement checks, which means the raw transaction could be included without protection.

The SDK handles this automatically when you use `submitProtectedTransaction`. If you are building a direct integration, ensure your submission path routes exclusively through the IntentGuard RPC.

## Submission model

### How submission works

Each call to `submitProtectedTransaction` (or `submitProtectedTransactionWithRetry`) triggers a single submission attempt:

1. The backend simulates the transaction with its constraints against the current block's state.
2. If simulation passes, the bundle is submitted to the block-building infrastructure for inclusion.
3. If simulation fails because constraints are violated, the backend returns a `PROTECTED` error immediately — no inclusion is attempted.

The backend does **not** retry across blocks. Each RPC call is a single attempt. `submitProtectedTransactionWithRetry` retries across blocks on `PROTECTED` errors.

### Validation rules

| Condition | Result |
|---|---|
| `validUntilBlock <= current_block` | Rejected with `REQUEST_EXPIRED` |
| `validUntilBlock - current_block < 2` | Rejected with `REQUEST_EXPIRED`: insufficient time for inclusion |
| `validUntilBlock - current_block > 1000` | Rejected with `VALIDITY_WINDOW_EXCEEDED` (see [Validity window cap](#validity-window-cap)) |
| All conditions pass | Accepted |

### Sign once, resubmit without re-signing

Users sign once with a `validUntilBlock` that extends beyond the immediate block. If the first attempt returns `PROTECTED`, the same signed request can be resubmitted without requiring a new signature, as long as `validUntilBlock` has not been reached.

`submitProtectedTransactionWithRetry` handles this automatically. For manual control, wallets can catch `PROTECTED` errors and resubmit. See [Retry on PROTECTED](#retry-on-protected).

### Validity window cap

IntentGuard enforces an upper bound on how far ahead `validUntilBlock` can be set. The default cap is 1000 blocks (~3.3 hours on mainnet at 12s blocks). Requests exceeding this limit are rejected with `VALIDITY_WINDOW_EXCEEDED`.

This cap may be adjusted for use cases that require longer validity windows — for example, perpetual contract entries, options, or limit-order-style constraints where the user wants to enter a position when a price target is hit and the wait may span hours or days. Contact IntentGuard to discuss higher caps for your deployment.

## API reference

### `submitProtectedTransaction`

```ts
const txHash = await client.submitProtectedTransaction(
  rawTx,        // 0x-prefixed signed raw transaction
  constraints,  // ProtectedConstraint[] (1 to 10 constraints)
  signer,       // EthereumSigner with signTypedData() and getAddress()
  options?,     // optional: { validUntilBlock?, enforcerType? }
);
```

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `rawTx` | `string` | Yes | — | `0x`-prefixed signed raw transaction hex |
| `constraints` | `ProtectedConstraint[]` | Yes | — | 1 to 10 balance constraints to enforce |
| `signer` | `EthereumSigner` | Yes | — | Account that signed `rawTx`. Must implement `signTypedData()` and `getAddress()`. **Must be the same account that signed the raw transaction.** |
| `options.validUntilBlock` | `number` | Yes | `currentBlock + defaultValidityBlocks` | Block number after which the request expires. If omitted, the SDK fetches the current block and adds `defaultValidityBlocks` (default: 5). If provided, the SDK checks that `validUntilBlock - currentBlock >= minValidityBlocks` (default: 2). Requests below that threshold are rejected client-side with `INVALID_VALIDITY_WINDOW`. |
| `options.enforcerType` | `EnforcerType` | Yes | Client-level `enforcerType` | Override the enforcer type for this call only. Defaults to `"balance"`. See [Enforcer types](#enforcer-types). |

**Returns:** `Promise<string>` — the transaction hash (`0x`-prefixed).

This method does not retry on `PROTECTED` errors. See `submitProtectedTransactionWithRetry` for automatic retry.

### `submitProtectedTransactionWithRetry`

Convenience wrapper around `submitProtectedTransaction` that retries on `PROTECTED` errors, waiting for the next block between attempts. Suitable for scripts, simple integrations, and fast chains where a small retry window is a short wall-clock wait.

On slower chains (e.g. Ethereum mainnet, ~12s blocks), the wall-clock cost adds up: 10 blocks ≈ 2 minutes. On a 200ms L2, 10 blocks ≈ 2 seconds. Factor block time into the retry window you choose.

For production wallets and services that need full control over retry lifecycle (job queues, cancellation, cross-process coordination), use `submitProtectedTransaction` directly and manage retries in your own infrastructure.

```ts
const txHash = await client.submitProtectedTransactionWithRetry(
  rawTx,        // 0x-prefixed signed raw transaction
  constraints,  // ProtectedConstraint[] (1 to 10 constraints)
  signer,       // EthereumSigner with signTypedData() and getAddress()
  options?,     // optional: RetryOptions
);
```

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `rawTx` | `string` | Yes | — | `0x`-prefixed signed raw transaction hex |
| `constraints` | `ProtectedConstraint[]` | Yes | — | 1 to 10 balance constraints to enforce |
| `signer` | `EthereumSigner` | Yes | — | Account that signed `rawTx`. Must implement `signTypedData()` and `getAddress()`. |
| `options.retryUntilBlock` | `number` | Yes | — | Block at which retries stop. Also sent to the backend as `validUntilBlock`. The SDK does not infer a retry window. |
| `options.enforcerType` | `EnforcerType` | Yes | Client-level `enforcerType` | Override enforcer type for this call. Defaults to `"balance"`. |
| `options.signal` | `AbortSignal` | Yes | No signal | Abort signal for cancellation. Throws with code `ABORTED` when triggered. |
| `options.onRetry` | `function` | Yes | No callback | Called after each `PROTECTED` error, before waiting for the next block. Receives `{ attempt, retryAfterBlock, error }`. |

**Returns:** `Promise<string>` — the transaction hash on success.

**Throws:** `RetryExhaustedError` when the block window expires. Inspect `error.attempts`, `error.errors` (full list), and `error.lastError` for debugging. Non-PROTECTED errors are thrown immediately without retry.

### JSON-RPC method

The SDK calls `intentguard_sendProtectedTransaction` over JSON-RPC. The endpoint supports two modes selected by the `mode` field in the params object.

**Managed mode** (default):

```json
{
  "jsonrpc": "2.0",
  "method": "intentguard_sendProtectedTransaction",
  "params": [{
    "mode": "managed",
    "chainId": 11155111,
    "enforcerType": "balance",
    "rawTx": "0x<signedRawTx>",
    "validUntilBlock": 12345678,
    "constraints": [
      {
        "account": "0x...",
        "token": "0x...",
        "maxOutflow": "1000000000",
        "minInflow": "0"
      }
    ],
    "constraintSignature": "0x<eip712Signature>"
  }],
  "id": 1
}
```

The `mode` field defaults to `"managed"` and can be omitted.

**Self-signed mode**:

```json
{
  "jsonrpc": "2.0",
  "method": "intentguard_sendProtectedTransaction",
  "params": [{
    "mode": "self-signed",
    "chainId": 11155111,
    "preTx": "0x<signedPreTx>",
    "userTx": "0x<signedUserTx>",
    "postTx": "0x<signedPostTx>",
    "retryUntilBlock": 12345678
  }],
  "id": 1
}
```

Both modes return a JSON-RPC response with the user's transaction hash as the result.

### Ethereum read methods

The IntentGuard RPC also exposes standard Ethereum read methods:

```ts
const blockNumber = await client.getBlockNumber();
const balance = await client.getBalance("0xAddress");
const nonce = await client.getTransactionCount("0xAddress");
const gasPrice = await client.getGasPrice();
const receipt = await client.getTransactionReceipt("0xTxHash");
```

Available: `getBlockNumber`, `getChainId`, `getBalance`, `getTransactionCount`, `getCode`, `getGasPrice`, `getMaxPriorityFeePerGas`, `getFeeHistory`, `estimateGas`, `call`, `getBlockByNumber`, `getBlockByHash`, `getSyncing`, `getTransactionByHash`, `getTransactionReceipt`, `getLogs`.


## Constraint model (balance enforcer)

Constraints define what the on-chain enforcer contract verifies before allowing a transaction to be included. The constraint structure depends on the enforcer type. The `"balance"` enforcer — the current default — tracks token inflows and outflows per account. Each constraint declares an enforcement rule for a specific account and asset:

```ts
interface ProtectedConstraint {
  /** Ethereum address whose balance is tracked */
  account: string;

  /** ERC-20 token address, or NATIVE_ETH for native ETH */
  token: string;

  /**
   * Maximum amount that may leave the account.
   * Decimal string in the token's base units (amount × 10^decimals).
   * Use "0" to block all outflows.
   */
  maxOutflow: string;

  /**
   * Minimum amount that must arrive at the account.
   * Decimal string in the token's base units (amount × 10^decimals).
   * Use "0" if no minimum inflow is required.
   */
  minInflow: string;
}
```

1 to 10 constraints per request. Validation happens client-side before any network call. The enforcer contract address is resolved automatically from the enforcer type via `getEnforcerConfig()` and used as the EIP-712 `verifyingContract` for constraint signing.

### Block unauthorized outflows

Set `maxOutflow: "0"` to block any amount of the asset from leaving the account. If any outflow occurs, the transaction is never included on-chain.

```ts
{
  account: "0xCustodyWallet",
  token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  maxOutflow: "0",
  minInflow: "0",
}
```

### Cap outflows

Set `maxOutflow` to the expected amount. If the actual outflow exceeds it, the transaction is never included on-chain. No gas is consumed.

```ts
{
  account: "0xUserWallet",
  token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  maxOutflow: "500000000",  // 500 USDC (6 decimals)
  minInflow: "0",
}
```

### Enforce expected outcomes

Set `minInflow` to require a minimum amount to arrive at the account. This is applicable to swaps and other operations where a return amount is expected.

```ts
const constraints = [
  {
    account: "0xUserWallet",
    token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
    maxOutflow: "1000000000",  // max 1,000 USDC out
    minInflow: "0",
  },
  {
    account: "0xUserWallet",
    token: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH
    maxOutflow: "0",
    minInflow: "500000000000000000",  // at least 0.5 WETH in
  },
];
```


### Native ETH constraints

To constrain native ETH (not WETH), use the `NATIVE_ETH` constant as the `token` field. The on-chain enforcer reads `address.balance` directly instead of calling `balanceOf`.

```ts
import { NATIVE_ETH } from "@intentguard/sdk";

// Cap ETH outflow at 2 ETH and require at least 5940 DAI in return
const constraints = [
  {
    account: "0xUserWallet",
    token: NATIVE_ETH,
    maxOutflow: "2000000000000000000",  // 2 ETH (18 decimals)
    minInflow: "0",
  },
  {
    account: "0xUserWallet",
    token: "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI
    maxOutflow: "0",
    minInflow: "5940000000000000000000",  // 5940 DAI (18 decimals)
  },
];
```

`NATIVE_ETH` is the address `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` (EIP-7528 convention). You can also use the raw address string directly if you prefer.

## Full example: Uniswap swap protection

In this example, a user swaps 1,000 USDC for WETH on Uniswap and requires a minimum of 0.5 WETH in return. If the execution price deviates beyond the declared bounds due to slippage or a sandwich attack, the transaction is not included on-chain.

```ts
import { IntentGuardClient } from "@intentguard/sdk";

const client = new IntentGuardClient({ chainId: 1 });

const user = "0xYourWalletAddress";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";

// signer: EthereumSigner (signTypedData + getAddress)
// Build and sign the swap transaction
const rawTx = await buildAndSignSwapTx(user, USDC, WETH, "1000000000");

// Define constraints
// Amounts are in the token's base units: amount × 10^decimals.
// USDC has 6 decimals: 1,000 USDC = 1000 × 10^6 = "1000000000"
// WETH has 18 decimals: 0.5 WETH = 0.5 × 10^18 = "500000000000000000"
const constraints = [
  {
    account: user,
    token: USDC,
    maxOutflow: "1000000000",             // 1,000 × 10^6 — max 1,000 USDC out
    minInflow: "0",
  },
  {
    account: user,
    token: WETH,
    maxOutflow: "0",                      // no WETH should leave
    minInflow: "500000000000000000",      // 0.5 × 10^18 — at least 0.5 WETH must arrive
  },
];

// Submit with enforcement
const txHash = await client.submitProtectedTransaction(
  rawTx,
  constraints,
  signer,
);
```

This enforces that the user spends at most 1,000 USDC and receives at least 0.5 WETH. If the swap settles outside these bounds for any reason, including MEV extraction, slippage, or sandwich attacks, the transaction is not included on-chain and no gas is consumed.


## Pre-signed flow: separate signing from submission

When the signer is external — a hardware wallet, browser extension, custody provider, or a different service — the private key is not available to the SDK. Use `buildConstraintMessage` to produce the EIP-712 typed data, sign it externally, then submit with `submitPreSignedTransaction`.

This is the recommended flow for production wallet integrations and any architecture where the transaction is signed at a different time or place than the constraints.

```ts
import { IntentGuardClient } from "@intentguard/sdk";

const client = new IntentGuardClient({ chainId: 1 });

const user = "0xYourWalletAddress";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";

// Step 1: Build and sign the raw transaction (your existing flow)
const rawTx = await buildAndSignSwapTx(user, USDC, WETH, "1000000000");

// Step 2: Define constraints
const constraints = [
  { account: user, token: USDC, maxOutflow: "1000000000", minInflow: "0" },
  { account: user, token: WETH, maxOutflow: "0", minInflow: "500000000000000000" },
];

// Step 3: Build the EIP-712 message
// This resolves the enforcer contract and validity window automatically.
const msg = await client.buildConstraintMessage(rawTx, constraints, user);

// Step 4: Sign externally — hardware wallet, browser extension, custody API
// The msg contains domain, types, value ready for eth_signTypedData_v4.
const constraintSignature = await wallet.signTypedData(
  msg.domain,
  msg.types,
  msg.value,
);

// Step 5: Submit with the pre-signed constraint signature
// The SDK never sees the private key.
const txHash = await client.submitPreSignedTransaction(
  rawTx,
  constraints,
  constraintSignature,
  { validUntilBlock: msg.validUntilBlock },
);
```

### `buildConstraintMessage`

```ts
const msg = await client.buildConstraintMessage(
  rawTx,        // 0x-prefixed signed raw transaction
  constraints,  // ProtectedConstraint[] (1 to 10 constraints)
  userAddress,  // address of the transaction sender
  options?,     // optional: { validUntilBlock?, enforcerType? }
);
```

Returns:
- `domain` — EIP-712 domain (name, version, chainId, verifyingContract)
- `types` — EIP-712 type definitions
- `value` — EIP-712 message value (user, txHash, validUntilBlock, constraintsHash)
- `primaryType` — `"SignedConstraints"`
- `validUntilBlock` — the resolved block number (pass this to `submitPreSignedTransaction`)

### `submitPreSignedTransaction`

```ts
const txHash = await client.submitPreSignedTransaction(
  rawTx,                // 0x-prefixed signed raw transaction
  constraints,          // ProtectedConstraint[] (same as passed to buildConstraintMessage)
  constraintSignature,  // EIP-712 signature from external signer
  { validUntilBlock },  // must match what was signed
);
```

**Returns:** `Promise<string>` — the transaction hash.


## Self-signed mode

Self-signed mode allows users to sign all enforcement transactions directly, without relying on the IntentGuard backend to sign pre/post transactions. The user signs three transactions with sequential nonces and submits them as an atomic bundle. This gives full control over the entire bundle — no backend signing, no trust assumptions.

### When to use self-signed vs managed mode

| | Managed mode | Self-signed mode |
|---|---|---|
| **Who signs enforcement txs** | Backend bundler | User |
| **Authorization** | EIP-712 signature | `msg.sender` |
| **User gas cost** | Own transaction only | All 3 transactions (pre + user + post) |
| **Trust model** | Backend signs enforcement txs on user's behalf | Fully trustless — user controls the entire bundle |
| **Use case** | Wallets, dApps, external signers | Power users, scripts, bots |

### Targets and constraints

Self-signed mode separates **targets** (what to snapshot) from **constraints** (what to verify).

The `preSelfSigned` contract call receives lightweight **targets** — account/token pairs whose balances are snapshotted before the user's transaction executes:

```ts
interface BalanceTarget {
  /** Ethereum address whose balance is snapshotted */
  account: string;
  /** ERC-20 token address, or NATIVE_ETH for native ETH */
  token: string;
}
```

The `postSelfSigned` contract call receives the full **constraints** (same `ProtectedConstraint` type as managed mode) and verifies them against the snapshots.

### Flow: build, sign, submit

```ts
import { IntentGuardClient, type BalanceTarget, type ProtectedConstraint } from "@intentguard/sdk";

const client = new IntentGuardClient({ chainId: 11155111 });
const currentBlock = await client.getBlockNumber();
const validUntilBlock = currentBlock + 10;

// Step 1: Define targets (for pre) and constraints (for post)
const targets: BalanceTarget[] = [
  { account: userAddress, token: USDC },
  { account: userAddress, token: WETH },
];

const constraints: ProtectedConstraint[] = [
  { account: userAddress, token: USDC, maxOutflow: "1000000000", minInflow: "0" },
  { account: userAddress, token: WETH, maxOutflow: "0", minInflow: "500000000000000000" },
];

// Step 2: Build unsigned enforcement transactions
const { preTx, postTx } = await client.prepareSelfSignedProtection(
  targets,       // account/token pairs → snapshotted in pre
  constraints,   // full constraints → verified in post
  validUntilBlock,
);

// Step 3: Get the base nonce
const nonce = parseInt(
  await client.getTransactionCount(userAddress, "pending"),
  16,
);

// Step 4: Sign all three transactions with sequential nonces
//   nonce     → preSelfSigned()
//   nonce + 1 → user's main transaction
//   nonce + 2 → postSelfSigned()
const signedPreTx = await wallet.signTransaction({
  ...preTx,
  nonce,
  gasLimit: 200000,
  maxFeePerGas,
  maxPriorityFeePerGas,
  chainId: 11155111,
  type: 2,
});
const signedUserTx = await wallet.signTransaction(userTransaction);
const signedPostTx = await wallet.signTransaction({
  ...postTx,
  nonce: nonce + 2,
  gasLimit: 200000,
  maxFeePerGas,
  maxPriorityFeePerGas,
  chainId: 11155111,
  type: 2,
});

// Step 5: Submit the self-signed bundle
const txHash = await client.submitSelfSignedTransaction({
  preTx: signedPreTx,
  userTx: signedUserTx,
  postTx: signedPostTx,
  retryUntilBlock: validUntilBlock,
});
```

### Client-side validation

`submitSelfSignedTransaction` performs client-side validation before sending:
1. All three transaction strings must be non-empty and 0x-prefixed
2. All three transactions must be signed by the same address (recovered via ECDSA)
3. Only EIP-1559 (type 2) transactions are supported

### API

| Method | Description |
|---|---|
| `buildPreSelfSignedTx(targets, validUntilBlock)` | Build unsigned `preSelfSigned` tx from `BalanceTarget[]` |
| `buildPostSelfSignedTx(constraints)` | Build unsigned `postSelfSigned` tx from `ProtectedConstraint[]` |
| `prepareSelfSignedProtection(targets, constraints, validUntilBlock)` | Build both unsigned txs |
| `submitSelfSignedTransaction({ preTx, userTx, postTx, retryUntilBlock })` | Submit the signed bundle |

### Low-level calldata encoding

For integrations that build transactions outside the SDK:

```ts
import {
  encodePreSelfSignedCalldata,
  encodePostSelfSignedCalldata,
  PRE_SELF_SIGNED_SELECTOR,
  POST_SELF_SIGNED_SELECTOR,
} from "@intentguard/sdk";

// preSelfSigned: selector + ABI-encoded (targets[], validUntilBlock)
const preData = encodePreSelfSignedCalldata(targets, validUntilBlock);

// postSelfSigned: selector + ABI-encoded (constraints[])
const postData = encodePostSelfSignedCalldata(constraints);
```

### Address recovery utility

The SDK exports `recoverTxSender` for recovering the signer address from a signed EIP-1559 transaction:

```ts
import { recoverTxSender } from "@intentguard/sdk";

const signer = recoverTxSender(signedRawTx); // checksummed address
```

## Wallet integration flow

For wallet teams, here is the expected integration path:

1. **Wallet receives transaction** from the dApp
2. **Wallet computes constraints** based on the expected outcome (e.g. token amounts, slippage tolerance)
3. **User reviews and approves** the transaction and its constraints
4. **Wallet signs both** the raw transaction and the EIP-712 constraints with the same key. Use `buildConstraintMessage` to produce the typed data for external signing, or `signConstraints` for in-process signing (see [Advanced signing API](#advanced-signing-api)).
5. **Wallet submits** using `submitPreSignedTransaction` (with external signature) or `submitProtectedTransaction` (with in-process signer)

For hardware wallets and custody providers, `buildConstraintMessage` + `submitPreSignedTransaction` is the correct path — the SDK never needs the private key.


## Security model

- Constraint enforcement is atomic. Constraints are verified as part of the transaction execution flow. If any constraint fails, the transaction is not included on-chain and no gas is consumed.
- Constraint authenticity is verified on-chain via EIP-712 signature verification.
- Constraint integrity is enforced via deterministic hashing of the constraint set.
- The constraint signature **must** be produced by the same account that signed the transaction. Mismatches are rejected.
- On-chain EIP-712 verification makes enforcement trustless. Both wallets and users can audit constraint validation on-chain. No trust assumption on IntentGuard infrastructure is required for enforcement correctness.
- Constraint signatures include a `validUntilBlock` expiry. Expired requests are rejected before execution.
- Signatures are domain-separated by `chainId` and `verifyingContract` to prevent cross-chain or cross-contract replay.
- IntentGuard does not custody funds. It cannot modify the transaction or alter the constraints chosen by the user or wallet.

### Third-party accounts

Constraints are not limited to the transaction sender. Any account can be monitored:

```ts
{
  account: "0xTreasuryMultisig",
  token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  maxOutflow: "0",
  minInflow: "0",
}
```


## Error handling

All errors extend `IntentGuardError` with a stable `code` property. Use `isIntentGuardError` for detection and `IntentGuardErrorCode` for exhaustive `switch` handling.

```ts
import { isIntentGuardError, IntentGuardErrorCode } from "@intentguard/sdk";

try {
  const txHash = await client.submitProtectedTransaction(rawTx, constraints, signer);
} catch (err) {
  if (!isIntentGuardError(err)) throw err;

  switch (err.code) {
    // ── Client-side (fix locally) ──────────────────────────────
    case IntentGuardErrorCode.INVALID_CONSTRAINTS:
    case IntentGuardErrorCode.INVALID_VALIDITY_WINDOW:
      break;

    // ── Transaction layer (fix the raw tx) ─────────────────────
    case IntentGuardErrorCode.INVALID_TRANSACTION:
    case IntentGuardErrorCode.CONSTRAINTS_VALIDATION_ERROR:
    case IntentGuardErrorCode.INVALID_USER_TX:
    case IntentGuardErrorCode.NONCE_TOO_HIGH:
      break;

    // ── Constraint layer (fix constraint fields) ───────────────
    case IntentGuardErrorCode.CONSTRAINTS_EMPTY:
    case IntentGuardErrorCode.CONSTRAINTS_LIMIT_EXCEEDED:
    case IntentGuardErrorCode.INVALID_TOKEN_ADDRESS:
    case IntentGuardErrorCode.INVALID_ACCOUNT_ADDRESS:
      break;

    // ── EIP-712 signature layer (fix signing) ──────────────────
    case IntentGuardErrorCode.INVALID_CONSTRAINT_SIGNATURE:
    case IntentGuardErrorCode.SIGNER_MISMATCH:
    case IntentGuardErrorCode.CHAIN_MISMATCH:
    case IntentGuardErrorCode.REQUEST_EXPIRED:
    case IntentGuardErrorCode.VALIDITY_WINDOW_EXCEEDED:
      break;

    // ── Simulation layer (state-dependent) ─────────────────────
    case IntentGuardErrorCode.PROTECTED:
      // Constraints violated — state may change next block. See "Retry on PROTECTED" below.
      break;

    // ── Submission layer (retryable with backoff) ──────────────
    case IntentGuardErrorCode.NETWORK_ERROR:
    case IntentGuardErrorCode.SUBMISSION_FAILED:
    case IntentGuardErrorCode.RPC_ERROR:
    case IntentGuardErrorCode.SERVICE_UNAVAILABLE:
      break;

    // ── Configuration layer (fix enforcer config) ────────────
    case IntentGuardErrorCode.UNKNOWN_ENFORCER_TYPE:
      break;

    // ── Self-signed layer (fix bundle) ─────────────────────────
    case IntentGuardErrorCode.MISSING_SIGNED_TX:
    case IntentGuardErrorCode.SIGNER_MISMATCH_SELF_SIGNED:
      break;

    // ── Client-side control flow ───────────────────────────────
    case IntentGuardErrorCode.TIMEOUT:
    case IntentGuardErrorCode.ABORTED:
    case IntentGuardErrorCode.RETRY_EXHAUSTED:
      break;

    default:
      throw err;
  }
}
```

| Code | Layer | Retryable | Description |
|---|---|---|---|
| **Client-side** | | | |
| `INVALID_CONSTRAINTS` | Client | No | Invalid addresses, negative amounts, or exceeds the 1–10 limit. |
| `INVALID_VALIDITY_WINDOW` | Client | No | `validUntilBlock` is in the past or too close to the current block. |
| `NETWORK_ERROR` | Client | Yes | RPC endpoint unreachable. |
| `TIMEOUT` | Client | No | Operation timed out (e.g. block-wait deadline exceeded during retry). |
| `ABORTED` | Client | No | Operation was cancelled via `AbortSignal`. |
| `RETRY_EXHAUSTED` | Client | No | Retry loop exhausted all attempts. See [Retry on PROTECTED](#retry-on-protected). |
| **Transaction layer** | | | |
| `INVALID_TRANSACTION` | Server | No | Raw transaction is malformed, empty, or cannot be decoded. |
| `CONSTRAINTS_VALIDATION_ERROR` | Server | No | Constraint verification failed. Returned when the server cannot determine a more specific cause; otherwise one of `CHAIN_MISMATCH`, `SIGNER_MISMATCH`, `REQUEST_EXPIRED`, or `INVALID_CONSTRAINT_SIGNATURE` is returned instead. |
| `INVALID_USER_TX` | Server | No | The user-submitted transaction is invalid. |
| `NONCE_TOO_HIGH` | Server | No | Transaction nonce too high; pending transactions must confirm first. |
| **Constraint layer** | | | |
| `CONSTRAINTS_EMPTY` | Server | No | No constraints provided. |
| `CONSTRAINTS_LIMIT_EXCEEDED` | Server | No | More than 10 constraints provided. |
| `INVALID_TOKEN_ADDRESS` | Server | No | Invalid token address in a constraint. |
| `INVALID_ACCOUNT_ADDRESS` | Server | No | Invalid account address in a constraint (e.g. zero address). |
| **EIP-712 signature layer** | | | |
| `INVALID_CONSTRAINT_SIGNATURE` | Server | No | EIP-712 constraint signature is malformed or cannot recover a signer. |
| `SIGNER_MISMATCH` | Server | No | Recovered EIP-712 signer does not match the transaction sender. |
| `CHAIN_MISMATCH` | Server | No | Chain ID in the EIP-712 domain does not match the transaction chain ID. |
| `REQUEST_EXPIRED` | Server | No | `validUntilBlock` has been reached. Resubmit with a fresh validity window. |
| `VALIDITY_WINDOW_EXCEEDED` | Server | No | `validUntilBlock` is too far ahead of the current block. Reduce the validity window. See [Validity window cap](#validity-window-cap). |
| **Simulation layer** | | | |
| `PROTECTED` | Server | Yes (state-dependent) | Constraints were violated for the current block's state. Not included on-chain. No gas consumed. May succeed next block — see [Retry on PROTECTED](#retry-on-protected). |
| **Submission layer** | | | |
| `SUBMISSION_FAILED` | Server | Yes | Transaction could not be submitted to the network. |
| `RPC_ERROR` | Server | Yes | Upstream RPC endpoint returned an error. |
| `SERVICE_UNAVAILABLE` | Server | Yes | Service temporarily unavailable. Retry with backoff. |
| **Configuration layer** | | | |
| `UNKNOWN_ENFORCER_TYPE` | Server | No | Unknown enforcer type passed to `intentguard_getEnforcerConfig`. Check `supportedTypes` in the error details. |
| **Self-signed layer** | | | |
| `MISSING_SIGNED_TX` | Client/Server | No | A required signed transaction (`preTx`, `userTx`, or `postTx`) is missing or empty. |
| `SIGNER_MISMATCH_SELF_SIGNED` | Client | No | The three self-signed transactions are not all signed by the same address. |
| `INVALID_TX_ORDERING` | Server | No | Self-signed transactions have incorrect nonce ordering (must be sequential: N, N+1, N+2). |
| `INVALID_NONCE_SEQUENCE` | Server | No | Nonces are not consecutive or do not match expected sequence. |
| `WRONG_ENFORCEMENT_CONTRACT` | Server | No | Pre or post transaction does not target the expected enforcement contract. |

Passthrough RPC failures (where the backend cannot classify the error) are reported as `RPC_ERROR_<code>` (e.g. `"RPC_ERROR_-32603"`).

### Retry on PROTECTED

A `PROTECTED` error means the declared constraints were violated during simulation. The transaction was **not** included on-chain and no gas was consumed.

However, this does not mean the transaction is permanently invalid. Simulation is state-dependent. A transaction that violates constraints on the current block may pass on the next block if on-chain state changes (e.g. liquidity updates, other trades executing, oracle updates). For this reason, wallets may retry the same signed request on later blocks until `retryUntilBlock` is reached.

The PROTECTED error data contains `{ "code": "PROTECTED" }`. The SDK handles retry logic internally — wallets do not need to parse error metadata.

**Using `submitProtectedTransactionWithRetry`**

The SDK waits for the next block and re-submits automatically until `retryUntilBlock` is exceeded:

```ts
import {
  IntentGuardClient,
  RetryExhaustedError,
  isIntentGuardError,
} from "@intentguard/sdk";

const client = new IntentGuardClient({ chainId: 1 });
const currentBlock = await client.getBlockNumber();

try {
  const txHash = await client.submitProtectedTransactionWithRetry(
    rawTx,       // signed raw transaction
    constraints, // ProtectedConstraint[]
    signer,      // EthereumSigner
    {
      retryUntilBlock: currentBlock + 10, // retry for ~10 blocks (~2 min on mainnet)
      onRetry: ({ attempt, retryAfterBlock, error }) => {
        console.log(`Attempt ${attempt}: ${error.code} — retrying at block ${retryAfterBlock}`);
      },
    }
  );
  console.log("Included:", txHash);
} catch (err) {
  if (err instanceof RetryExhaustedError) {
    // All retry attempts exhausted. Inspect err.errors for the full list.
    console.error(`Gave up after ${err.attempts} attempt(s): ${err.lastError.message}`);
  } else {
    // Non-retryable error (INVALID_TRANSACTION, SIGNER_MISMATCH, etc.) — do not swallow
    throw err;
  }
}
```

The retry loop stops when:
- The transaction succeeds (returns the tx hash)
- A non-PROTECTED error is thrown (e.g. `INVALID_TRANSACTION`) — thrown immediately
- The next block exceeds `retryUntilBlock` — throws `RetryExhaustedError`
- The `signal` is aborted — throws with code `ABORTED`

No re-signing is required between attempts. The same constraint signature remains valid until `validUntilBlock`.

**Using `submitProtectedTransaction` directly**

For production wallets managing their own retry lifecycle, use `submitProtectedTransaction` in your own loop. Wait for a new block before each retry — retrying on a fixed timer without advancing the block will result in the same simulation state and the same `PROTECTED` outcome. No re-signing is needed between attempts as long as `validUntilBlock` has not been reached.

```ts
import {
  IntentGuardClient,
  IntentGuardErrorCode,
  isIntentGuardError,
} from "@intentguard/sdk";

// client initialized as shown in Configuration
const currentBlock = await client.getBlockNumber();
const validUntilBlock = currentBlock + 10;

let included = false;
let lastBlock = currentBlock;
while (lastBlock < validUntilBlock) {
  try {
    const txHash = await client.submitProtectedTransaction(
      rawTx, constraints, signer, { validUntilBlock }
    );
    console.log("Included:", txHash);
    included = true;
    break;
  } catch (err) {
    if (!isIntentGuardError(err) || err.code !== IntentGuardErrorCode.PROTECTED) throw err;
    // Poll until a new block arrives — do not retry on the same block
    let newBlock = lastBlock;
    while (newBlock <= lastBlock) {
      await new Promise((r) => setTimeout(r, 2_000)); // ~2s for mainnet; use ~200ms for fast L2s
      newBlock = await client.getBlockNumber();
    }
    lastBlock = newBlock;
  }
}
if (!included) {
  throw new Error(`Transaction not included by block ${validUntilBlock}`);
}
```

This gives full control over scheduling, cancellation, and coordination across processes or job queues.


## Enforcer types

IntentGuard supports pluggable on-chain enforcement logic. Each enforcer type maps to a verification contract resolved automatically by the backend. No contract address needs to be specified manually.

The current enforcer type is `"balance"`, which checks account balances before and after execution. Additional enforcement strategies, including storage slot verification and custom financial checks, will be introduced in future releases.

`"balance"` is the default. If you do not set `enforcerType`, the SDK uses it:

```ts
// These are equivalent:
const client = new IntentGuardClient({ chainId: 1 });
const client = new IntentGuardClient({ chainId: 1, enforcerType: "balance" });
```

The enforcer type can be overridden per call without creating a new client:

```ts
const txHash = await client.submitProtectedTransaction(rawTx, constraints, signer, {
  enforcerType: "balance",
});
```

| Type | Description |
|---|---|
| `"balance"` | Verifies token inflows and outflows per account against declared constraints. **Default.** |

The enforcer config is fetched once per type and cached for the lifetime of the client. The resolved contract address is used as the EIP-712 `verifyingContract` for constraint signing.


## Advanced signing API

For most integrations, use `client.buildConstraintMessage()` — it resolves the enforcer contract, computes the tx hash, and returns ready-to-sign EIP-712 typed data. See [Pre-signed flow](#pre-signed-flow-separate-signing-from-submission).

For low-level access to the signing primitives (e.g. custom hashing, direct contract interaction), the underlying functions are also exported:

```ts
import {
  signConstraints,
  computeConstraintsHash,
  buildConstraintsTypedData,
  IntentGuardClient,
} from "@intentguard/sdk";

const client = new IntentGuardClient({ chainId });
const { contractAddress } = await client.getEnforcerConfig();

// Compute txHash manually (keccak256 of the signed raw transaction bytes)
import { keccak256 } from "viem";
const txHash = keccak256(rawTx as `0x${string}`);

// Build the EIP-712 typed data from primitives
const constraintsHash = computeConstraintsHash(constraints);
const { domain, types, value } = buildConstraintsTypedData(
  userAddress, txHash, validUntilBlock, constraintsHash, chainId, contractAddress,
);

// Or sign directly with an in-process signer
const signature = await signConstraints(
  signer, userAddress, txHash, validUntilBlock,
  constraints, chainId, contractAddress,
);
```

### EIP-712 domain

| Field | Value |
|---|---|
| `name` | `"IntentGuard"` |
| `version` | `"1"` |
| `chainId` | Network chain ID |
| `verifyingContract` | Resolved from enforcer type via `getEnforcerConfig()` |


## Limitations

- **EOA only.** IntentGuard currently requires the transaction sender to be an Externally Owned Account (EOA). Smart Account and Account Abstraction (ERC-4337) support is planned for a future release.


## Operational notes

- `submitProtectedTransactionWithRetry` handles `PROTECTED` retries automatically, waiting for the next block between attempts. See [Retry on PROTECTED](#retry-on-protected).
- Set `timeoutMs` for deterministic request timeouts. If omitted, OS and runtime defaults apply.
- The SDK enforces a minimum validity window of 2 blocks by default (`DEFAULT_MIN_VALIDITY_BLOCKS`). If `validUntilBlock - currentBlock` is below this threshold, the request is rejected client-side with `INVALID_VALIDITY_WINDOW`. This constant is exported from `@intentguard/sdk` and can be overridden via `minValidityBlocks` in the constructor config.
- The `fetch` option can be used to route traffic through proxies, add mTLS, or inject observability middleware.
- Supported runtimes: Node.js 22+ or a browser environment with native `fetch` support. The SDK uses ES2022+ features and does not support Node.js 18 or 20.


## FAQ

For conceptual questions about enforcement guarantees, private routing, and how IntentGuard differs from simulation, see [What is IntentGuard?](../intentguard/overview.md).

<details>
<summary>Can transactions still be sent through a standard RPC endpoint?</summary>

Yes. IntentGuard does not modify transaction semantics. A transaction can always be submitted through a standard RPC endpoint. However, doing so bypasses IntentGuard protection. In that case, constraint enforcement does not occur and the transaction behaves as a normal Ethereum transaction.

</details>

<details>
<summary>What happens if IntentGuard infrastructure is temporarily unavailable?</summary>

If IntentGuard infrastructure is unavailable, the transaction can still be submitted through a standard RPC endpoint. In that scenario, the transaction executes normally without outcome enforcement. IntentGuard provides optional protection and does not introduce a dependency for transaction execution.

</details>

<details>
<summary>How can constraint verification be audited?</summary>

Constraint verification is performed by an on-chain enforcement contract. The contract address is exposed through the SDK configuration and is used as the `verifyingContract` in the EIP-712 signature domain. The contract can be inspected by anyone, and the enforcement logic can be independently verified.

</details>

<details>
<summary>Why is a second signature required?</summary>

The transaction signature authorizes the on-chain action. The constraint signature serves two purposes:

1. **Authentication**: it proves that the constraints were declared by the transaction sender, not injected or modified by a third party.
2. **Binding**: the EIP-712 typed data includes the transaction hash, which cryptographically binds the constraints to the exact transaction they were submitted with. This prevents anyone from detaching constraints from one transaction and attaching them to another.

Both signatures must come from the same account.

</details>

<details>
<summary>What happens if a submission returns PROTECTED?</summary>

A `PROTECTED` error means constraints were violated for the current block's state. The transaction was not included and no gas was consumed. On-chain state changes every block, so the same transaction may succeed on the next block. Use `submitProtectedTransactionWithRetry` for automatic retry, or handle resubmission manually. No re-signing is needed. See [Retry on PROTECTED](#retry-on-protected).

</details>