The Facilitator cannot hold, redirect, or steal user tokens. Here is why:
-
Permit target is the Asset contract, not the Facilitator. The EIP-2612 permit is signed with
spender = Asset contract address.Asset._validatePermitenforcesif (spender != address(this)) revert InvalidSpender(). Any attempt by the Facilitator to redirect the permit to its own address would fail on-chain. -
safeTransferFromruns inside the Asset contract. After the permit succeeds,SafeERC20.safeTransferFrom(token, payer, address(this), amount)runs insideAsset._validatePermit. The Asset contract is the recipient. The Facilitator's private key has no role in this transfer. -
Facilitator pays gas only. The Facilitator's signing key appears only as the
msg.senderof the outersubscribe()call, which determines who pays gas. It does not appear as payer, owner, spender, or recipient of any token transfer. -
facilitator_never_payerinvariant is FROZEN. Any code change that places the Facilitator's address aspayerinsubscribe()is blocked by.invariants. This is machine-verifiable.
Fund flow:
User wallet ──[permit + transferFrom]──> Asset contract
Facilitator ──[pays gas]──────────────> Blockchain
The non-custodial design means the Facilitator:
- Never holds user funds, even transiently
- Cannot initiate token transfers independently
- Acts as a transaction relayer, not a payment processor
Under current BaFin interpretation and MiCA Article 3, a pure transaction relayer that does not control or hold assets does not trigger VASP (Virtual Asset Service Provider) licensing requirements. This is not legal advice. Consult counsel before operating commercially in Germany or the EU.
On-chain: EIP-2612 nonces on the token contract are strictly monotonic. A consumed nonce causes permit() to revert with ERC20Permit: invalid signature. A replayed payload cannot succeed on-chain.
Off-chain (pre-broadcast): verify.ts cross-checks payload.permitNonce against the current on-chain nonce before accepting. A nonce that has already been consumed will not pass verification.
Idempotency store: settle.ts records every (payer, permitNonce) result before and after broadcast. A retry returns the cached result without re-broadcasting. This prevents gas waste and avoids race conditions on retry.
| Action | Possible? | Mitigated by |
|---|---|---|
| Steal tokens | No | spender enforced by contract |
| Redirect payment | No | payTo = Asset contract, enforced |
| Censor a payment (block it) | Yes | Operator trust; use a non-custodial Facilitator or run your own |
| Double-settle a nonce | No | Idempotency store + on-chain nonce |
| Front-run a permit | Theoretical | Permit is already targeted to a specific asset/payer/amount; front-running settles the same subscription, not a different one |
| Drain the Facilitator's gas wallet | DoS only | Protect /settle behind auth or rate-limiting in production |
Any rail added to this adapter MUST satisfy the following before shipping. These apply regardless of whether the rail is crypto or fiat.
Complete the decision tree in IPaymentAdapter.md before writing any code. If the rail is custodial, the custody window, failure modes, and legal classification must be documented in the rail's spec doc.
Every rail must define a unique idempotency key that identifies a specific payment attempt. Examples:
| Rail | Idempotency key |
|---|---|
ocr-permit-v1 |
(payer address, EIP-2612 permit nonce) |
| Stripe | (Stripe PaymentIntent ID) |
| EIP-3009 | (from address, nonce) |
The key must be checked in settle() before any on-chain call. A settled key must never be re-broadcast.
verify() must run all checks that can fail before any gas is spent. settle() must re-run verify() before broadcasting. Never broadcast a transaction based solely on the client's claim that verification passed.
Enforced by the facilitator_never_payer invariant in .invariants. This is FROZEN. Any rail that requires the Facilitator to be payer violates the invariant and cannot be merged without a protocol-level decision.
Enforced by the cancel_not_called invariant. The adapter has no cancellation path. Users self-cancel via the contract directly.
Every field in the incoming JSON payload must be validated before use (type, format, range). Zod schemas in the route files are the enforcement point — new rails must add equivalent schemas.
- Replace in-memory idempotency store with Redis or a persistent DB
- Add authentication to
/settle(API key, JWT, or IP allowlist) to prevent gas drain - Rotate the Facilitator private key via a signing service (KMS, Turnkey, etc.)
- Set up gas wallet monitoring and alerting
- Verify the token contract implements EIP-2612 before allowing a new asset
- Pin the
ASSET_REGISTRY_ADDRESSin config — do not accept it from the request