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
- Create a client with your chain ID
- Define constraints describing the token flows that must hold after execution
- Call
protectAndSendwith your account, transaction, and constraints - 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:
| Transaction | Purpose | Nonce |
|---|---|---|
| Pre-check | Snapshots token balances before execution | N |
| Your transaction | The swap, transfer, or contract call you want to protect | N+1 |
| Post-check | Verifies balances match your constraints; reverts the bundle if violated | N+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.
| Field | Description | Example |
|---|---|---|
account | Address to monitor | Your wallet address |
token | ERC-20 address, or NATIVE_ETH for ETH | "0xA0b8...eB48" (USDC) |
maxOutflow | Maximum amount that may leave (base units). Defaults to 0. | parseEther("1") = 1 ETH |
minInflow | Minimum 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:
| Error | Retryable | Meaning |
|---|---|---|
PROTECTED | Yes | Constraints 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_SEQUENCE | No | Nonces are not sequential (N, N+1, N+2). |
SIGNER_MISMATCH_SELF_SIGNED | No | The three transactions are signed by different addresses. |
REQUEST_EXPIRED | No | The validity window has passed. Re-sign with a new window. |
INVALID_CONSTRAINTS | No | Bad constraint data (invalid addresses, negative amounts, more than 10 constraints). |
UNSUPPORTED_ACCOUNT | No | The 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
- SDK Reference — full API documentation
- Wallet Integration Guide — integrating IntentGuard into a wallet product
- Overview — how enforcement works under the hood