npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@intentguard/sdk

v0.1.8

Published

IntentGuard SDK — submit protected transactions with on-chain constraint enforcement

Readme

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?.

Installation

npm install @intentguard/sdk

No peer dependencies required.

Configuration

Constructor

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

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) | | 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.

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 |

// 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) | | 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.

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

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. |

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.

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 following illustrates the wire format for direct integrations:

{
  "jsonrpc": "2.0",
  "method": "intentguard_sendProtectedTransaction",
  "params": [
    "0x<signedRawTx>",
    [
      {
        "account": "0x...",
        "token": "0x...",
        "maxOutflow": "1000000000",
        "minInflow": "0"
      }
    ],
    "0x<eip712Signature>",
    12345678
  ],
  "id": 1
}

The params are, in order: the signed raw transaction, the constraint array, the EIP-712 constraint signature, and the validUntilBlock expiry. The response follows standard JSON-RPC format with a transaction hash as the result.

Ethereum read methods

The IntentGuard RPC also exposes standard Ethereum read methods:

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:

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.

{
  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.

{
  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.

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.

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.

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.

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

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

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. This is the cheapest execution path — the user signs three transactions with sequential nonces and submits them as an atomic bundle.

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 | | Gas cost | Standard | Lower (no constraint calldata in post tx) | | Use case | Wallets, dApps, external signers | Power users, scripts, bots |

Flow: build, sign, submit

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

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

// Step 1: Build unsigned enforcement transactions
const { preTx, postTx } = await client.prepareSelfSignedProtection(
  constraints,
  validUntilBlock,
);

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

// Step 3: 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 4: 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(constraints, validUntilBlock) | Build unsigned preSelfSigned tx | | buildPostSelfSignedTx() | Build unsigned postSelfSigned tx (no constraints — reads from storage) | | prepareSelfSignedProtection(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:

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

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

// postSelfSigned: just the 4-byte selector (no arguments)
const postData = encodePostSelfSignedCalldata();

Address recovery utility

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

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).
  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:

{
  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.

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;

    // ── 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. | | 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. | | 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. | | 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. |

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:

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.

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:

// 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:

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.

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

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.
  • 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?.

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.

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.

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.

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.

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.