← Writing

What Should a Payment Contract Sign? Designing Safe EIP-712 Orders

· By Rizkyy. · 11 min read

What Should a Payment Contract Sign? Designing Safe EIP-712 Orders
On this page

What Should a Payment Contract Sign? Designing Safe EIP-712 Orders

Signing more data does not automatically make a payment contract safer.

It can make the contract less usable, force customers to submit stale execution plans, and turn harmless routing changes into signature failures. Signing too little creates the opposite problem: a caller may be able to change who gets paid, what asset they receive, or how much value reaches them.

The real design question is not:

How do I sign this transaction?

It is:

Which facts define the authorized economic outcome, and which details are only a way to achieve it?

I faced this question while building Mayarin's PaymentRouter, the smart contract that receives a customer's asset, optionally swaps it, and settles a merchant in their chosen stablecoin.

The design we ended up with signs the payment's identity, settlement asset, minimum output, fee, merchant wallet, refund address, and deadline. But it deliberately does not sign the DEX router or swap calldata.

That boundary is what this post explores.

The payment order

The contract accepts an EIP-712 order with seven fields:

SOL
struct Order {
    bytes32 intentId;
    address settlementToken;
    uint256 minOut;
    uint256 fee;
    address merchantSafe;
    address refundTo;
    uint256 deadline;
}

We can write the order as:

O=(i,s,m,f,am,ar,t)O = (i, s, m, f, a_m, a_r, t)

where:

  • ii is the unique payment intent identifier;
  • ss is the settlement token;
  • mm is the minimum acceptable settlement output;
  • ff is the platform fee;
  • ama_m is the merchant wallet;
  • ara_r is the refund destination;
  • tt is the expiry deadline.

These fields describe the economic agreement. They answer:

  • Which payment is this?
  • What asset must settle?
  • What is the least acceptable output?
  • How is that output divided?
  • Who receives each part?
  • How long is this authorization valid?

Notice what they do not answer: which DEX should execute the swap, which pool should be used, or what calldata should be sent to that router.

Those are execution details, not settlement promises.

What EIP-712 actually binds

EIP-712 gives structured data a typed identity. Instead of signing an ambiguous byte string, the signer authorizes a specific type, its fields, and a domain.

The primary type in Mayarin is:

TEXT
Order(bytes32 intentId,address settlementToken,uint256 minOut,uint256 fee,address merchantSafe,address refundTo,uint256 deadline)

Its type hash is:

TO=keccak256(OrderType)T_O = keccak256(\text{OrderType})

The struct hash is:

HO=keccak256(abi.encode(TO,i,s,m,f,am,ar,t))H_O = keccak256(abi.encode(T_O, i, s, m, f, a_m, a_r, t))

The final digest includes the EIP-712 domain separator:

D=keccak256(0x1901HdomainHO)D = keccak256(\texttt{0x1901} \parallel H_{domain} \parallel H_O)

For the payment router, the domain contains:

TEXT
name:              Mayarin PaymentRouter
version:           1
chainId:           current chain
verifyingContract: this PaymentRouter address

The signer signs DD, and the contract recovers the signer from the submitted signature.

Including chainId and verifyingContract matters as much as signing the order fields. The same signed order should not be valid on another chain or on a different contract deployment.

The domain makes the authorization local:

Valid(O)(chain=chainO)(contract=contractO)Valid(O) \Rightarrow (chain = chain_O) \land (contract = contract_O)

Without that domain separation, a valid payment on one deployment could become a replay on another.

Sign the payment identity

intentId is the bridge between the off-chain payment and the on-chain execution.

It is globally unique for the payment and becomes the contract's idempotency key. Before the router calls Permit2, a DEX, or a token contract, it checks and consumes this identifier:

SOL
if (consumed[order.intentId]) revert AlreadyConsumed();
consumed[order.intentId] = true;

Because an EVM revert rolls back the storage write, a failed swap does not permanently consume the intent. But if the transaction succeeds, replaying the same order fails.

The rule is:

successfulExecutions(i)1successfulExecutions(i) \le 1

Signing intentId prevents a caller from taking a valid economic authorization and relabelling it as a different payment. It also gives the emitted PaymentCompleted event a stable identifier that the off-chain indexer can match directly.

Sign the settlement token

The settlement token defines what the merchant is paid in.

Suppose minOut is signed but settlementToken is not. The signature might promise a minimum output of 100,000 units, but those units have no safe meaning without an asset. A malicious caller could replace a legitimate stablecoin with a worthless token whose balance is easy to manufacture and still satisfy the numeric threshold.

The pair must be authorized together:

(settlementToken,minOut)(settlementToken, minOut)

Mayarin also requires the settlement token to be on a governance-controlled whitelist.

The signature and whitelist protect different boundaries:

  • The signature says this payment is supposed to settle in this token.
  • The whitelist says the signer may choose only from assets the deployment has admitted.

This bounds the signing key. A compromised signer can create bad orders, but it cannot introduce an arbitrary settlement asset without a separate governance action.

Sign the minimum output

minOut is the contract's hard economic floor.

After the swap, the router does not trust a DEX return value. It measures the actual settlement-token balance difference:

output=Bafter(s)Bbefore(s)output = B_{after}(s) - B_{before}(s)

Then it enforces:

outputminOutoutput \ge minOut

If the route produces less, the entire transaction reverts. The merchant receives nothing, the fee is not paid, and the payer's asset movement is rolled back with the rest of the transaction.

This is the guard that allows the route itself to remain unsigned.

A caller may supply a better route, a newer route, or even broken calldata. But the outcomes are bounded:

  1. It produces at least minOut, and settlement succeeds.
  2. It produces less than minOut, and everything reverts.

The caller can choose the execution attempt. The signature fixes the acceptable result.

Sign the merchant wallet

merchantSafe is the destination of the merchant's net settlement.

Leaving it unsigned would make every other guarantee meaningless. A caller could preserve the token, amount, fee, and deadline while redirecting the merchant's share to another address.

The contract calculates:

merchantAmount=minOutfeemerchantAmount = minOut - fee

and sends that amount to the signed merchant wallet.

The order also requires:

0fee<minOut0 \le fee < minOut

Therefore:

merchantAmount>0merchantAmount > 0

This is a small invariant with an important product meaning: no valid payment can allow the fee to consume the merchant's entire settlement.

Sign the fee

The fee is part of the price agreement, not a calculation the caller should control.

The contract's fee recipient is configuration, but the amount charged for an individual payment comes from the signed order. If fee were caller-supplied, the payer could reduce it to zero or raise it at the merchant's expense.

Binding it to the same signature as minOut makes the split deterministic:

merchant=minOutfeemerchant = minOut - fee treasury=feetreasury = fee

The quote layer and contract agree on the exact raw minor-unit amount. There is no on-chain decimal conversion and no percentage calculation that might round differently from the backend.

Sign the refund address

The refund destination receives value left over after satisfying the settlement floor.

If the route produces more than minOut, the excess is:

refund=outputminOutrefund = output - minOut

That excess belongs at the authorized refundTo address. It must not be left for the transaction sender to replace, because a relayer, checkout contract, or compromised frontend may submit the transaction on the payer's behalf.

The same address also receives unconsumed input residue or native value returned by an exact-output route. Signing it ensures that changing the transaction submitter does not change the economic recipient.

For a connected-wallet payment, refundTo is the payer's address. For the deposit path, where the original payer address may be unknowable, the persisted order uses the treasury destination according to that path's explicit custody policy.

The important principle is consistent in both cases:

The refund recipient is decided when the order is authorized, not when someone submits it.

Sign the deadline

A quote is a statement about a market at a particular time. It should not remain executable forever.

The signed deadline enforces:

block.timestampdeadlineblock.timestamp \le deadline

If the deadline were unsigned, a caller could extend an old quote and exercise it only when market movement became favorable to them and unfavorable to the system.

Signing the deadline turns the order into a bounded option rather than a permanent one.

It also lets the backend reason about signer rotation. Old signatures remain valid only until their individual deadlines; they do not create an indefinite tail of authorizations after the trusted signer changes.

Why the route and calldata are not signed

DEX routes go stale much faster than the commercial payment intent.

Liquidity changes, pools rebalance, gas prices move, and a quote provider may discover a better path between the moment checkout opens and the moment the payer submits. If route calldata were included in the order signature, any change would require a new backend signature.

That would couple two different lifetimes:

  • the lifetime of the economic lock;
  • the lifetime of the execution route.

Mayarin separates them:

TEXT
Signed and stable:
intent + settlement token + minOut + fee + destinations + deadline

Unsigned and refreshed at submission:
DEX router + route calldata

The checkout can request fresh calldata immediately before submission without changing what the merchant is guaranteed to receive.

The route is flexible, but it is not unconstrained. Four protections surround it:

  1. Router whitelist. Only governance-admitted DEX routers may receive the external call.
  2. Exact approval. ERC-20 input approval is limited to the payment amount and reset to zero afterward.
  3. Measured output. The contract trusts its balance delta, not a value returned by arbitrary calldata.
  4. Atomic minOut revert. A route that misses the signed floor moves no value permanently.

Even manipulated calldata cannot force the contract to settle below the signed minimum. It can only produce an acceptable result or make the transaction fail.

The payer may lose gas on a bad route. The merchant does not receive an underfilled payment.

Why the input asset is also outside the order

The payment order does not sign the input asset or input amount either.

That is intentional. The merchant's promise is denominated in the settlement asset. A customer may pay with native ETH or a supported ERC-20 as long as execution produces the signed settlement outcome.

For ERC-20 payments, Permit2 carries its own payer-signed authorization covering the input token, amount, spender, expiration, and nonce. The two signatures serve different purposes:

  • Permit2 signature: the payer authorizes this contract to pull a specific input asset.
  • Payment order signature: the backend authorizes the settlement outcome.

The router additionally requires the input token to be whitelisted. Native input enters through a separate payEth path.

This separation avoids forcing the backend order to duplicate the payer's asset authorization while preserving both trust boundaries.

Conservation makes the settlement leg auditable

Once the actual output is known, the contract divides it into three destinations:

M=minOutfeeM = minOut - fee F=feeF = fee R=outputminOutR = output - minOut

The conservation equation is:

M+F+R=outputM + F + R = output

Substituting the definitions:

(minOutfee)+fee+(outputminOut)=output(minOut - fee) + fee + (output - minOut) = output

Every unit of measured settlement output is assigned to the merchant, treasury, or refund recipient. None is supposed to remain in the router.

The emitted PaymentCompleted event carries these actual amounts so the indexer and double-entry ledger can reconcile against what happened on-chain, not merely what the quote predicted.

The signer is powerful, so bound it twice

The quote-signing key is custody-adjacent. It does not directly hold funds, but the contract moves value based on what that key authorizes.

That makes signer security part of the payment protocol:

  • Production signing uses an isolated secp256k1 key rather than a general service secret.
  • The private key should remain inside a KMS, HSM, or equivalent policy boundary.
  • Changing the trusted signer is a timelocked contract operation.
  • A separate guardian can pause payments immediately during suspected compromise.
  • Settlement and input asset whitelists limit what even a valid signer can authorize.

The signature proves that the trusted key approved the order. It does not prove that the key was used wisely. Contract-level bounds still matter.

This is defense in depth:

Safety=SignaturePolicyContractInvariantsGovernanceBoundsSafety = SignaturePolicy \cap ContractInvariants \cap GovernanceBounds

No single layer is asked to carry the entire trust model.

Test the boundary from both sides

The most important tests are not only “a valid signature succeeds.” They try to alter one part of the trust boundary at a time:

  • A signature from the wrong key is rejected.
  • A malformed signature is rejected.
  • An expired deadline is rejected.
  • Replaying a consumed intentId is rejected.
  • Changing the signed settlement token breaks verification.
  • A settlement token outside the whitelist is rejected even if signed.
  • A fee greater than or equal to minOut is rejected.
  • Manipulated route output below minOut always reverts.
  • Cross-language hash vectors match between Solidity and TypeScript.
  • The live domain separator uses the expected chain and contract address.
  • Calldata constructed off-chain decodes correctly on-chain.

Cross-language vectors deserve special attention. EIP-712 depends on exact field order, types, domain values, and encoding. A TypeScript signer and Solidity verifier can each look reasonable while producing different hashes.

The same fixed order vector is hashed on both sides. If a field is reordered or a domain value drifts, the test fails before users discover the incompatibility through rejected payments.

A useful design rule

The lesson I would carry into another payment contract is simple:

Sign the outcome that must not change. Validate the execution details that are allowed to change.

For Mayarin, the immutable outcome is:

  • one specific payment intent;
  • one admitted settlement asset;
  • at least one exact minimum amount;
  • one merchant destination;
  • one refund destination;
  • one fee;
  • one expiry.

The execution route can change because the contract independently verifies the result. That flexibility is useful only because the invariant is strong enough to contain it.

Signing everything would freeze stale infrastructure details. Signing too little would leave the economic agreement editable. The safe boundary sits between them.

Closing thought

An EIP-712 signature should not be treated as decoration around a transaction. It is a compact description of authority.

Every signed field answers, “Who is allowed to decide this?” Every unsigned field creates a second question: “What prevents the caller from abusing that freedom?”

In a payment contract, those answers should be visible in the data model:

Signed economic outcome+bounded dynamic executionsafe, fresh routing\text{Signed economic outcome} + \text{bounded dynamic execution} \rightarrow \text{safe, fresh routing}

That is why Mayarin signs the settlement promise but not the swap path used to fulfill it.

Implementation references