Gasless Intents (Specs 035 + 036)¶
FairWins users can authorize every core action with one off-chain signature — no separate ERC-20 approval, no native gas token. A relayer (spec 036) submits the signed intent; the on-chain effect is always attributed to the signer, never the submitter.
Two gasless rails¶
FairWins has two distinct gasless mechanisms. They serve different account types and actions; both keep a self-submit fallback (never-stranded).
| Rail | For | How it's gasless | Docs |
|---|---|---|---|
| Relayed intents (specs 035 + 036) | EOAs and contract accounts (ERC-1271), for contract actions (create/accept/claim/membership/…) and EIP-3009 stablecoin transfers | The relayer's gas wallet submits a …WithSig/…WithAuthorization meta-tx and pays gas |
this document |
| Sponsored UserOps (spec 050) | Passkey smart accounts (spec 041), for account-native UserOperations — native + USDC transfers, controller changes, first-use deploy | A FairWins-operated verifying paymaster reimburses the bundler from a FairWins-funded deposit; the user needs zero native token | passkey-accounts.md + runbooks/paymaster-operations.md |
Why both: the relayer can't move a smart account's native token — only the EntryPoint can
execute the account — and passkey account-native operations (native transfer, controller
add/remove, first-use deploy) have no …WithSig meta-tx equivalent, so they can only travel the
ERC-4337 (paymaster + bundler) rail. (Native USDC does accept a contract-account ERC-7598
authorization — proven by test/fork/usdc-erc1271-authorization.test.js — but the ERC-7598 bytes
leg isn't yet plumbed through the relayer twins, so passkey USDC moves currently ride UserOps too;
see the passkey-accounts scope note.) The sponsored-paymaster rail is specified in
specs/050-sponsored-paymaster/ and reuses the same relay-gateway policy engine (screening, quotas,
killswitch) to authorize each sponsorship.
How it works¶
wallet ──sign EIP-712 intent (+ EIP-3009 payment leg)──▶ relay gateway ──calldata──▶ OZ Relayer ──tx──▶ chain
│ (policy) (mechanics)
└────────────────────── self-submit fallback (user pays own gas — always available) ──────────────▶ chain
- Intent leg — an EIP-712 struct (e.g.
ClaimPayoutIntent) signed under the verifying contract's domain (FairWins WagerRegistry/FairWins MembershipManager, version1, chainId + proxy address). Binds the acting address, every action parameter, a single-use random 32-byte nonce, and a validity window. - Payment leg (money-in actions only) — an EIP-3009
ReceiveWithAuthorizationsigned under the stablecoin's own domain,to= the consuming contract. Its nonce is stapled into the intent'spaymentNonce, and the contract assertsstakeAuth.value == signed amountandstakeAuth.nonce == paymentNonce— a relayer can censor, but can never substitute, redirect, or resize a payment. - Fee netting (optional, admin-toggled
setFeeNetting) — a second, bounded authorization settled atomically to a segregatedgasFeeRecipient(never the relayer hot key).
Contract architecture: the registry facet split¶
WagerRegistry's implementation sits against the EVM 24 KB code-size limit (24,460 of 24,576 bytes
before this feature), so the intent surface ships as a second implementation facet:
| Piece | Role |
|---|---|
contracts/wagers/WagerRegistryCore.sol |
Abstract: THE storage layout + all internal action bodies (actor-threaded). Both facets inherit it, so layouts cannot drift. Validated by npm run check:storage-layout. |
contracts/wagers/WagerRegistry.sol |
Main facet (the UUPS implementation): every pre-existing external + a fallback() that delegatecalls unknown selectors to the extension. |
contracts/wagers/WagerRegistryIntents.sol |
Extension facet: the …WithSig/…WithAuthorization twins, invalidateNonce(+WithSig), setFeeNetting, and the relocated cold paths batchExpireOpen / autoResolveFromPolymarket / autoResolveFromOracle. |
contracts/upgradeable/SignerIntentBase.sol |
Shared mixin: EIP-712 verify + ERC-7201-namespaced per-signer replay-nonce map + invalidation. Zero sequential storage — safe to add to live proxies. |
Callers see one contract at the proxy address: one ABI (merge WagerRegistry +
WagerRegistryIntents artifacts — see test/helpers/proxy.js#mergeAbis), one event stream, one
EIP-712 domain (under delegatecall address(this) is the proxy). setIntentExtension is gated by
UPGRADER_ROLE because pointing the fallback at new code is equivalent in authority to an upgrade.
MembershipManager (11 KB) hosts its four twins inline — no facet needed. Its EIP-712 domain is
initialized by initializeIntents() (reinitializer(2)) during the in-place upgrade.
Twin invariant¶
Every covered action has two entrypoints with identical checks and effects against the acting
identity (msg.sender for self-submit, the recovered signer for intents): sanctions screen,
membership gate, ownership, freeze — all fail-closed on the signer. The self-submit path is never
removed: it is the guaranteed fallback when no relayer is reachable.
Covered: create / accept / accept-open (money-in) · claim payout / refund / declare & revoke draw / cancel open / decline / declare winner (no-stake) · membership purchase / upgrade / extend (money-in) · voucher redeem (no-stake).
Replay + invalidation¶
- Nonces are client-generated random 32-byte values, single-use per
(contract, signer), usable out of order. State:authorizationState(signer, nonce). - Cancel an unsubmitted intent:
invalidateNonce(nonce)(self) orinvalidateNonceWithSig(signer, nonce, validBefore, sig)(relayed). - Cancel an unsubmitted payment leg: the token's
cancelAuthorization.
Frontend¶
frontend/src/lib/relay/ is the one client every flow uses:
intentTypes.js— re-exports the EIP-712 struct definitions and binds the per-contract domain builders, plus the FR-020 stablecoin-domain pre-sign check (domainVersioninconfig/networks.js: native USDC'2', bridged'1', Mordor USCnull⇒ payment intents unavailable, self-submit only).
The structs and the FairWins domains are NOT defined here. Both live once, in
packages/intent-types (@fairwins/intent-types), imported by this app and by the relay gateway,
and both halves of the EIP-712 digest are gated against the Solidity by
test/intent/TypehashParity.test.js:
INTENT_TYPES/OPEN_ACCEPT_TYPESvs each contract's*_TYPEHASHliteral, in both directions — a struct present in the package but not the contracts fails, and so does one the contracts verify but the package no longer defines.CONTRACT_DOMAINS(+DOMAIN_SOURCES,domainFor) vs each contract's own__EIP712_init(name, version)arguments. A correct struct signed under a wrong domain is just as dead as a wrong struct — the domain separator is half the digest — so it is gated the same way, not by convention.
Do not retype a struct or a { name, version } pair anywhere else; add it to the package.
- intentClient.js — signIntent / relayIntent / pollStatus / probeHealth / makeRelayer.
VITE_RELAYER_URL unset ⇒ makeRelayer returns null ⇒ everything self-submits.
- useIntentAction.js — the never-stranded enforcement point: relayer unset, unhealthy, 429,
503, payment_unsupported_on_chain, or timeout ⇒ transparent fallback to the caller-supplied
selfSubmit(). Status is honest: never confirmed before on-chain inclusion.
- components/intents/IntentStatus.jsx — WCAG 2.1 AA status renderer.
Relayer (spec 036)¶
See services/relay-gateway/README.md (policy gateway: signer recovery, fail-closed sanctions
re-screen, dedup, quotas, kill switch, audit) and services/oz-relayer/README.md (submission
engine: nonce lanes, gas pricing — legacy type-0 on ETC/Mordor — inclusion tracking, KMS-held hot
key). Runbook: docs/runbooks/relayer-operations.md.
Spec 050 adds a second responsibility to this same gateway: a POST /v1/paymaster ERC-7677
endpoint that runs the identical policy pipeline (killswitch → chain → sanctions → quotas, plus a
per-op cost ceiling) and, on grant, signs a sponsorship for the verifying paymaster with a
KMS-held signer key (returns paymasterAndData rather than submitting a tx). The bundler (alto)
still submits. See runbooks/paymaster-operations.md.
Upgrade & rollout¶
npm run check:storage-layout # gating
npx hardhat run scripts/deploy/upgrade-gasless-intents.js --network amoy
npm run sync:frontend-contracts:amoy
# optional: FEE_ENABLED=true FEE_RECIPIENT=0x... FEE_MAX=1000000 \
# npx hardhat run scripts/operations/set-fee-netting.js --network amoy
Rollout: Amoy (full flow) → Mordor (no-stake intents only — USC lacks EIP-3009) →
Polygon (after the 025/027 UUPS migration; the recorded Polygon addresses predate the proxy
migration). Storage deltas: WagerRegistry __gap 48→45 (fee scalars ×2 + intentExtension),
MembershipManager __gap 49→47 (fee scalars ×2); the nonce map is namespaced (zero gap).