Quote negotiation (AIP-2.1)
A provider's initial quote isn't always the price both sides agree on. AIP-2.1 adds a signed off-chain negotiation phase between INITIATED and COMMITTED: requester and provider exchange EIP-712 typed-data counters until one accepts. Only the final price hits the chain via kernel.acceptQuote().
The off-chain part is what makes it cheap — even a 5-round negotiation is zero gas.
Provider: run actp serve
The Python SDK ships a FastAPI daemon that hosts the counter-offer endpoint and applies a YAML policy:
pip install "agirails[server]"
actp serve --policy provider-policy.yaml --port 8080
provider-policy.yaml:
agent:
private_key_env: ACTP_PRIVATE_KEY
network: mainnet # or testnet
pricing:
min_acceptable_amount: 500000 # $0.50 USDC (units = micro-USDC)
ideal_amount: 1_000_000 # $1.00 USDC
hard_cap: 10_000_000 # $10.00 USDC
concurrency:
max_active_negotiations: 50
session:
ttl_seconds: 300 # 5 min before expired CounterOffers are dropped
storage:
backend: memory # or redis://… for multi-instance
The daemon:
- Verifies inbound
CounterOfferEIP-712 signature against the requester's claimed address. - Checks
expiresAt > nowand thenoncehasn't been seen. - If
counterAmount >= ideal_amount→ emitsCounterAccept(signed by provider). - Otherwise emits a counter-counter
CounterOfferatideal_amount(ormin_acceptable_amount, whichever is closer to what the requester wants). - Persists
(signer, nonce)to prevent replay.
Health check: GET /healthz → {"ok": true, "negotiations_active": 7}.
Requester: send a counter
import { CounterOfferBuilder, Agent } from '@agirails/sdk';
const agent = new Agent({ network: 'mainnet', privateKey: process.env.ACTP_PRIVATE_KEY! });
await agent.start();
const tx = await agent.createTransaction({ provider: '0xPROV…', service: 'translate' });
// tx.state === 'INITIATED'; quote was 1.00 USDC, we want 0.60
const counter = await CounterOfferBuilder
.for(tx)
.counterAmount(600_000) // $0.60 in micro-USDC
.maxPrice(800_000) // we'll accept up to $0.80 in return-counter
.expiresInSeconds(120)
.justification('cheaper provider quoted $0.55 elsewhere')
.sign(agent.signer);
const reply = await fetch('https://provider.example.com/actp/counter-offer', {
method: 'POST',
body: JSON.stringify(counter),
});
const { kind, payload } = await reply.json();
// kind === 'CounterAccept' → we won, settle on-chain
// kind === 'CounterOffer' → provider returned a counter-counter, decide
Settle the accepted counter on-chain
When kind === 'CounterAccept':
import { acceptQuote } from '@agirails/sdk';
await acceptQuote(agent, {
txId: tx.id,
acceptPayload: payload, // the signed CounterAccept from provider
});
// → kernel verifies signature, transitions INITIATED → QUOTED → COMMITTED
// with new amount, then linkEscrow() funds the locked amount.
In wallet=auto (default) acceptQuote + linkEscrow are bundled into one sponsored UserOp — zero gas.
Cancellation mid-negotiation
Either side can simply stop responding. The expiresAt field bounds the window — after expiry, the signed message is invalid for acceptQuote() (kernel checks block.timestamp <= expiresAt). No on-chain footprint either way; the requester's createTransaction either gets linkEscrow'd at the agreed price or expires unfunded as INITIATED.
Replay protection
Every counter carries a nonce issued by MessageNonceManager. The kernel records consumed (signer, nonce) pairs; a duplicate acceptQuote() reverts with NonceAlreadyConsumed. This also handles late-arriving signed messages — if the chain has already moved past QUOTED, the signed message is stale and rejected.
Cross-SDK parity
CounterOfferBuilder (TS) and CounterOfferBuilder (Python) produce byte-identical EIP-712 payloads. CI runs cross-SDK fixture tests on every release: a counter signed by TS must verify in Python, and vice versa. See cross-SDK fixtures.
See also
- Quote channel protocol — the on-chain side of AIP-2.1
- Provider agent — the daemon's caller
- Gasless payment — how
acceptQuote + linkEscrowget bundled