Docs / Networks
EVM binding
One Solidity binding serves every EVM network. It is built against Base first (Base Sepolia for the test network) and runs unchanged on Ethereum, Arbitrum, Optimism, Polygon and Avalanche with each network’s own USDC and Chainlink addresses. Semantics are those of the protocol specification; only the carriers differ from Stellar.
Shared by six networks Base first
At a glance
| Aspect | EVM binding |
|---|---|
| Contracts | Solidity (Foundry): Bond Registry, Claims, six validators; admin is Ownable2Step; reentrancy guard on every token-moving function; USDC moves with SafeERC20 |
| Signatures | secp256k1 over EIP-712 typed data; the service signing key is an ordinary key held by the seller’s server and registered as an address, never the seller’s wallet |
| Hashing | keccak-256; offerHash and receiptHash are EIP-712 digests (_hashTypedDataV4(structHash)); termsHash = keccak256(abi.encode(terms)) |
| Stake token | Native USDC (ERC-20, 6 decimals); the 100 USDC floor is 100_000_000 units |
| P6 oracle | Chainlink AggregatorV3Interface.getRoundData(roundId); the cited round id plays the role of Stellar’s oracle timestamp |
| x402 scheme | exact on eip155:<chainId> with USDC transferWithAuthorization (EIP-3009) |
| Wire form | abi.encode(SignedOffer) and abi.encode(SignedReceipt) as 0x-prefixed hex inside extensions.lopw |
| Indexing | eth_getLogs on the registry and claims addresses from the deployment block, after EVM_CONFIRMATIONS confirmations (default 12 on Base Sepolia) |
Types
Solidity structs; field order is normative because it defines the ABI and the EIP-712 hashes. Field names are the Stellar names in camelCase.
struct OracleTerms { address feed; bytes32 asset; uint32 maxDeviationBps; uint64 maxOracleAge; }
struct Terms {
uint8[] predicates; // 1..6, strictly increasing, no duplicates
uint64 claimWindow;
uint64 maxDataAge;
uint16[] allowedStatus;
uint32 maxResponseBytes;
bool hasOracle;
OracleTerms oracle; // zeroed when hasOracle is false
}
struct Offer {
uint32 version; // 1
bytes32 serviceId;
address registry;
bytes32 termsHash;
uint256 price;
address asset; // USDC
uint256 maxClaim;
uint64 validUntil;
bytes32 offerNonce;
uint32 keyVersion;
}
struct OracleEvidence { address feed; bytes32 asset; uint80 roundId; int256 oraclePrice; int256 reportedValue; }
struct Receipt {
uint32 version;
bytes32 serviceId;
bytes32 offerHash;
bytes32 termsHash;
address buyer;
bytes32 paymentReference; // settlement transaction hash
bytes32 requestHash;
bytes32 responseHash;
uint32 responseBytes;
uint16 status;
uint64 servedAt;
uint64 dataAsOf;
bool hasOracle;
OracleEvidence oracle; // zeroed when hasOracle is false
uint32 keyVersion;
}
struct SignedOffer { Offer offer; bytes signature; } // 65-byte secp256k1 (r, s, v)
struct SignedReceipt { Receipt receipt; bytes signature; }
struct ClaimExtra { bool hasSecond; SignedReceipt second; } // P1 onlytermsHash = keccak256(abi.encode(terms)). Service ids are keccak256(abi.encode(address(registry), uint256(counter))) with the counter starting at 0 per registry. requestHash and responseHash are the same SHA-256 constructions as on Stellar, so a seller’s HTTP layer is chain-agnostic.
Hashing and signing (EIP-712)
Domain: EIP712Domain(name="LOPW", version="1", chainId, verifyingContract=registry). The chain id and the registry address give the same two replay protections as Stellar’s network id and registry address in the hash.
Offer(uint32 version,bytes32 serviceId,address registry,bytes32 termsHash,uint256 price,address asset,uint256 maxClaim,uint64 validUntil,bytes32 offerNonce,uint32 keyVersion)
OracleEvidence(address feed,bytes32 asset,uint80 roundId,int256 oraclePrice,int256 reportedValue)
Receipt(uint32 version,bytes32 serviceId,bytes32 offerHash,bytes32 termsHash,address buyer,bytes32 paymentReference,bytes32 requestHash,bytes32 responseHash,uint32 responseBytes,uint16 status,uint64 servedAt,uint64 dataAsOf,bool hasOracle,OracleEvidence oracle,uint32 keyVersion)offerHashandreceiptHashare the EIP-712 digests_hashTypedDataV4(structHash); nested structs are hashed by their own type hash.- Verification is
ECDSA.recover(digest, signature) == registeredKeywith malleable signatures rejected (OpenZeppelinECDSA). - A receipt is accepted only if
servedAt < retiredAtof its key version, exactly as on Stellar (decision D-006).
Contracts
Functions keep the Stellar names where possible; errors are custom errors with the Stellar names; events carry the same names and fields in CamelCase, so one indexer schema serves every chain.
// Bond Registry
constructor(address admin, IERC20 usdc, ProtocolConfig config, LaunchLimits limits, FeeConfig fees)
setClaimsContract(address) -> uint64 eta // first call immediate; later ones time-locked
applyClaimsContract(); cancelClaimsChange()
setLimits(LaunchLimits) -> uint64 eta // tightening immediate, loosening time-locked
setPaused(bool); setSellerAllowed(address, bool); setFees(FeeConfig) -> uint64 eta
registerService(address signingKey, Terms terms, uint256 minStake) -> bytes32 serviceId
updateTerms(bytes32, Terms) -> bytes32 termsHash; rotateKey(bytes32, address) -> uint32 keyVersion
deposit(bytes32, uint256) // seller must have approved USDC
requestUnbond(bytes32, uint256); cancelUnbond(bytes32); withdraw(bytes32) -> uint256; collectFee(bytes32) -> uint256
slash(bytes32, address buyer, uint256 amount) -> uint256 // onlyClaims
getService / getTerms / getKey / termsHash / getConfig / getLimits / getFees / isPaused / isSellerAllowed / getTotalStake
// Claims
constructor(address admin, address registry, uint64 changeDelay)
setValidator(uint8 predicateId, address) -> uint64 eta; applyValidator(uint8); cancelValidatorChange(uint8)
claim(uint8 predicateId, SignedOffer, SignedReceipt, ClaimExtra) -> (bool paid, uint256 amount, uint8 reason)
getClaim(bytes32 serviceId, bytes32 paymentReference) -> ClaimRecord
// Validators
validate(Terms, Offer, Receipt, ClaimExtra) external view returns (uint8) // 0 proven, 1 NotProven, 2 NotVerifiableThe claim runs the seven checks in the specification’s order and reverts with the Stellar error names (UnknownService, BadSignature, OfferMismatch, WindowClosed, AlreadyClaimed, PredicateNotOffered, NoStake, ValidatorNotSet, KeyRetired, UnsupportedVersion, SlashRateLimited). The window check uses block.timestamp. Outcomes are ClaimPaid(serviceId, predicateId, buyer, amount, paymentReference) and ClaimRejected(serviceId, predicateId, reason, paymentReference) with reason 1 = NotProven and 2 = NotVerifiable. The P1 second receipt is signature-checked by Claims before the validator runs. Fees accrue per second with SECONDS_PER_YEAR = 31_536_000 and basis points over 10,000.
P6 with Chainlink
receipt.oracle.feed == terms.oracle.feed && receipt.oracle.asset == terms.oracle.asset, otherwise proven (terms mismatch).(, int256 answer,, uint256 updatedAt,) = feed.getRoundData(receipt.oracle.roundId); a revert,answer <= 0orupdatedAt == 0is NotVerifiable.updatedAt > servedAtorservedAt − updatedAt > maxOracleAge: proven (stale).|reportedValue − answer| × 10000 > maxDeviationBps × answer: proven, exact integer arithmetic (decision D-009).
Chainlink rounds are immutable once published, so a cited roundId is the citation, exactly as the oracle timestamp is on Stellar. Terms name the feed by its pair label (asset = bytes32("ETH/USD")) and the reference service reports values in the feed’s decimals (8 on Chainlink USD feeds, against 14 on Reflector); the deviation check is decimal-agnostic because both sides come from the same feed.
x402 on EVM
Scheme exact on eip155:<chainId> with USDC transferWithAuthorization (EIP-3009). The seller’s PaymentRequirements.extra carries the asset’s EIP-712 name and version as x402 requires ({ "name": "USDC", "version": "2" } on Base Sepolia, verified on-chain). paymentReference is the settlement transaction hash from PAYMENT-RESPONSE.transaction; buyer is the payer address. The lopw extension is carried exactly as on Stellar; only the signed-offer and signed-receipt encoding changes.
"extensions": {
"lopw": {
"info": {
"version": 1,
"network": "base-sepolia",
"registry": "0x…", // Bond Registry contract address
"service_id": "…64 hex…",
"offer": "0x0000…" // abi.encode(SignedOffer { offer, signature }) as hex
},
"schema": { "type": "object" }
}
}
// PAYMENT-RESPONSE.extensions.lopw.receipt: abi.encode(SignedReceipt { receipt, signature }) as 0x-hexSDK
import { EvmBinding, EVM_NETWORKS } from "@lopw/binding-evm";
const binding = new EvmBinding(EVM_NETWORKS["base-sepolia"]); // chain id 84532, USDC and feed addresses included
// EVM_RPC_URL overrides the public JSON-RPC; LOPW_ORACLE overrides the feed; registry and claims come from deployments/<name>.jsonEvmNetwork field | Meaning |
|---|---|
name | Network name used in API paths, for example base-sepolia |
caip2 | eip155:<chainId>, used by x402 |
chainId | The EIP-712 domain chain id |
rpcUrl | Public JSON-RPC used by default |
usdc, usdcDecimals | Circle’s native USDC contract, 6 decimals; checked on-chain (symbol(), decimals()) before use |
registry, claims | LOPW contract addresses, filled from deployments/<name>.json once a deployment is published |
oracle | The Chainlink feed the reference service measures against, verified on-chain (description(), decimals()) |
explorerTx, explorerContract | Explorer URL builders for the network |
Networks on this binding
| Chain | Test network | Chain id | Production network | Chain id | LOPW deployment |
|---|---|---|---|---|---|
| Base | base-sepolia | 84532 | base | 8453 | None published |
| Ethereum | sepolia | 11155111 | ethereum | 1 | None published |
| Arbitrum | arbitrum-sepolia | 421614 | arbitrum | 42161 | None published |
| Optimism | optimism-sepolia | 11155420 | optimism | 10 | None published |
| Polygon | polygon-amoy | 80002 | polygon | 137 | None published |
| Avalanche | avalanche-fuji | 43113 | avalanche | 43114 | None published |
For agents
For agents
| Item | Value |
|---|---|
| Network name in API paths | base-sepolia, base, sepolia, ethereum, arbitrum-sepolia, arbitrum, optimism-sepolia, optimism, polygon-amoy, polygon, avalanche-fuji, avalanche; a network is served once a deployment is published for it |
| CAIP-2 id | eip155:<chainId> (the chain id is also the EIP-712 domain chain id) |
Wire encoding of extensions.lopw | info.offer = abi.encode(SignedOffer) and receipt = abi.encode(SignedReceipt), 0x-prefixed lowercase hex; JSON Schemas offer-evm.json, receipt-evm.json, terms-evm.json |
| Signature | 65 bytes (r, s, v) over the EIP-712 digest; recover and compare with getKey(serviceId, keyVersion) |
| Amounts | Decimal strings of USDC base units, 6 decimals |