Skip to main content

Quickstart: Protect an Ethereum Transaction (Advanced / Programmatic)

This quickstart shows how to protect an Ethereum transaction using the IntentGuard SDK in a programmatic self-signed flow. It is best suited for advanced users, bots, scripts, and backend integrations that control transaction signing directly. For wallet product integrations, see the Wallet SDK Integration Guide.

IntentGuard wraps your transaction in a protected bundle consisting of a pre-check, the transaction itself, and a post-check. If the final outcome violates your constraints (for example due to MEV or price drift), the entire bundle reverts and nothing is executed onchain.

IntentGuard enforces transaction outcomes, not just execution.

Steps

  1. Create a client with your chain ID
  2. Define constraints describing the token flows that must hold after execution
  3. Call protectAndSend with your account, transaction, and constraints
  4. The SDK handles the rest: nonces, gas, pre/post transactions, signing, and submission

When to Use IntentGuard

IntentGuard is useful whenever the economic outcome of a transaction must be guaranteed.

  • Protecting swaps against price drift or sandwich attacks
  • Preventing wallet draining attacks
  • Enforcing minimum received tokens on any trade
  • Protecting automated trading agents and bots
  • Enforcing balance invariants across multi-step transactions
  • Guaranteeing delivery of funds to a recipient

How It Works

IntentGuard protects your transaction by wrapping it inside an atomic bundle of three transactions:

TransactionPurposeNonce
Pre-checkSnapshots token balances before executionN
Your transactionThe swap, transfer, or contract call you want to protectN+1
Post-checkVerifies balances match your constraints; reverts the bundle if violatedN+2

All three are signed by the same wallet and submitted as an atomic bundle to the IntentGuard RPC. The bundle is submitted privately to block builders and does not enter the public mempool, preventing anyone from including the transaction without IntentGuard protection — which would leave it exposed to front-running and MEV.

Execution Flow

User signs bundle locally
|
IntentGuard RPC
|
Private block builders
|
Pre-check -> User tx -> Post-check
|
Constraint validation (on-chain)
|
Pass: included in block
Fail: entire bundle reverts, no gas consumed

Self-signed mode is the default production mode for developers and automated agents. You sign all three transactions locally, and the IntentGuard enforcement contract verifies your constraints at execution time.

If the outcome deviates from your declared intent, the entire bundle reverts and nothing is executed on-chain.

Why the Pre-check Matters

The pre-check snapshots the user's token balances at execution time — not at signing time. This is critical because the on-chain state may have changed between when you signed the bundle and when it is included in a block. By reading balances inside the same block, the constraints are always evaluated against the actual starting state.

This also protects against concurrent transactions. If two bundles target the same token, each pre-check captures the balance as it stands after any earlier transaction in the same block. Without the pre-check, a constraint could silently evaluate against a stale balance, producing an outcome outside the user's intended range.

Gas Behavior

Gas is only consumed if the bundle is successfully included in a block. If constraints fail, the bundle is simply not included and no gas is spent. The user's nonce is not consumed either — the bundle can be resubmitted or a new transaction can be signed with the same nonce.

Install

Requires Node.js 18+.

npm install @intentguard/sdk
# or
pnpm add @intentguard/sdk

Minimal Example

const result = await client.protectAndSend({
account: wallet,
transaction: { to, data, value },
constraints: [
{ account: wallet.address, token: NATIVE_ETH, maxOutflow: parseEther("1") },
{ account: wallet.address, token: USDC, minInflow: 2_500_000_000n },
],
});

That's the entire integration surface. The rest of this guide explains each part.

Full Example: Protected ETH-to-USDC Swap

The example below protects a swap that sends up to 1 ETH and requires receiving at least 2,500 USDC.

Use NATIVE_ETH to reference the native ETH balance instead of an ERC-20 token.

import { IntentGuardClient, NATIVE_ETH } from "@intentguard/sdk";
import { Wallet, parseEther } from "ethers";

const wallet = new Wallet(process.env.PRIVATE_KEY!);
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

const client = new IntentGuardClient({
chainId: 1, // Ethereum Mainnet (use 11155111 for Sepolia)
});

const result = await client.protectAndSend({
account: wallet,
transaction: {
to: "0xSwapRouterAddress",
data: "0x...", // Your swap calldata
value: parseEther("1"),
},
constraints: [
{
account: wallet.address,
token: NATIVE_ETH,
maxOutflow: parseEther("1"), // Send at most 1 ETH
},
{
account: wallet.address,
token: USDC,
minInflow: 2_500_000_000n, // Receive at least 2,500 USDC (6 decimals)
},
],
waitForReceipt: true,
});

console.log("Confirmed in block:", result.receipt?.blockNumber);

The SDK handles nonce management, gas estimation, pre/post transaction building, signing, and bundle submission. You only describe the outcome you want to protect.

The account field accepts any raw-sign-capable local wallet directly (e.g. ethers.js v6 Wallet). For viem or custom wallets, use the fromViemAccount adapter:

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

const account = fromViemAccount(viemAccount, walletClient, chain);
await client.protectAndSend({ account, transaction, constraints });

Important: Only local wallets that can sign raw transactions are supported. Browser-injected wallets (e.g. MetaMask via BrowserProvider.getSigner()) may not support signing without broadcasting, and are not compatible with self-signed protection bundles.

Constraints

Constraints define the execution guarantees for your transaction. If any constraint is violated at execution time, the entire bundle reverts.

FieldDescriptionExample
accountAddress to monitorYour wallet address
tokenERC-20 address, or NATIVE_ETH for ETH"0xA0b8...eB48" (USDC)
maxOutflowMaximum amount that may leave (base units). Defaults to 0.parseEther("1") = 1 ETH
minInflowMinimum amount that must arrive (base units). Defaults to 0.2_500_000_000n = 2,500 USDC

Amount fields accept bigint or string. Numbers are also accepted but not recommended for large values due to JavaScript precision limits.

Common Patterns

Block all outflows of a token:

{ account: user, token: USDC, maxOutflow: 0 }

Cap outflow with minimum inflow (swap protection):

{ account: user, token: NATIVE_ETH, maxOutflow: parseEther("1") },
{ account: user, token: USDC, minInflow: 2_500_000_000n },

Monitor a third-party address:

// Ensure a recipient actually receives funds
{ account: recipientAddress, token: USDC, minInflow: 500_000_000n }

Custom Gas and TTL

You can override gas parameters and the validity window:

const result = await client.protectAndSend({
account: wallet,
transaction: { to: router, data: swapCalldata, value: parseEther("1") },
constraints: [
{ account: wallet.address, token: NATIVE_ETH, maxOutflow: parseEther("1") },
{ account: wallet.address, token: USDC, minInflow: 2_500_000_000n },
],
ttlBlocks: 5, // Bundle valid for 5 blocks (~1 minute)
gas: {
maxFeePerGas: 30_000_000_000n,
maxPriorityFeePerGas: 2_000_000_000n,
},
});

Advanced: Manual Bundle Flow

For full control over nonces, gas, and signing, use the lower-level API.

Mid-level: buildProtectedBundle

Build a signed bundle without submitting it:

const bundle = await client.buildProtectedBundle({
account: wallet,
transaction: { to: router, data: swapCalldata, value: parseEther("1") },
constraints: [
{ account: wallet.address, token: NATIVE_ETH, maxOutflow: parseEther("1") },
{ account: wallet.address, token: USDC, minInflow: 2_500_000_000n },
],
});

// Inspect the bundle, then submit when ready
const txHash = await client.submitSelfSignedTransaction(bundle);
const receipt = await client.waitForProtectedTransaction(txHash);

Low-level: manual pre/post transactions

For complete control over every transaction field:

const constraints = [
{ account: userAddress, token: NATIVE_ETH, maxOutflow: "1000000000000000000", minInflow: "0" },
{ account: userAddress, token: USDC, maxOutflow: "0", minInflow: "2500000000" },
];

const currentBlock = await client.getBlockNumber();
const validUntilBlock = currentBlock + 10;

// Targets are inferred from constraints, but you can also provide them manually
const targets = [
{ account: userAddress, token: NATIVE_ETH },
{ account: userAddress, token: USDC },
];

const { preTx, postTx } = await client.prepareSelfSignedProtection(
targets, constraints, validUntilBlock,
);

// Sign pre (nonce N), user tx (nonce N+1), post (nonce N+2) yourself
// Then submit:
const txHash = await client.submitSelfSignedTransaction({
preTx: signedPreTx,
userTx: signedUserTx,
postTx: signedPostTx,
retryUntilBlock: validUntilBlock,
});

Error Handling

The following errors may be returned when submitting or simulating a bundle:

ErrorRetryableMeaning
PROTECTEDYesConstraints would be violated at current chain state. This typically occurs when market conditions temporarily violate the constraint (e.g. price moved, insufficient liquidity, or a conflicting transaction landed in the same block). Resubmit the same signed bundle — it remains valid until expiry.
INVALID_NONCE_SEQUENCENoNonces are not sequential (N, N+1, N+2).
SIGNER_MISMATCH_SELF_SIGNEDNoThe three transactions are signed by different addresses.
REQUEST_EXPIREDNoThe validity window has passed. Re-sign with a new window.
INVALID_CONSTRAINTSNoBad constraint data (invalid addresses, negative amounts, more than 10 constraints).
UNSUPPORTED_ACCOUNTNoThe provided account does not support raw transaction signing required for self-signed bundles.

On a PROTECTED error, you do not need to re-sign. The same signed transactions can be resubmitted because the nonces and validity window haven't changed. Wait for the next block and try again.

Sepolia Testnet

To test on Sepolia, change the chain ID and token addresses:

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

The RPC endpoint (https://rpc.intentguard.xyz) supports both Sepolia and Mainnet — routing is determined by the chainId in your signed transactions.

What's Next