Skip to content

Moving contracts from ETH to Stablecoin (whitelisted ERC20) based payment - #120

Merged
ManulParihar merged 76 commits into
devfrom
upgrade/payment-adapter
Sep 1, 2026
Merged

ManulParihar merged 76 commits into
devfrom
upgrade/payment-adapter

Conversation

@ManulParihar

Copy link
Copy Markdown
Member

TL;DR

Adds ERC-20 token payments to the protocol and retires the old ETH purchase path. Includes a payment adapter contract, replay/front-running protection, a stateful fuzz suite, and a large expansion of test coverage.

Motive

eSIM purchases previously moved ETH directly. This branch replaces that with a payment adapter that prices bundles in USD cents and settles in a registered ERC-20 token, closing the door on a class of price-cap and replay issues that came with the old flow.

Changes

Payments

  • New PaymentAdapter.sol: registers currencies, quotes and settles purchases in USD cents
  • IPaymentRegistry interface for what the adapter reads from the registry
  • Payment reference replay protection, plus a fix for a front-running window on the same reference
  • Guards against short transfers from fee-on-transfer tokens
  • ETH purchase path removed from DeviceWallet and ESIMWallet entirely

Registry / Wallets

  • Registry.sol gains settlement recording, currency table, and price ceiling in USD cents (was ETH-denominated)
  • Price cap moved to its own storage slot, with a migration test (PriceCapSlotMigration.t.sol)
  • DeviceWallet, ESIMWallet, LazyWalletRegistry updated for the token path

Testing

  • New unit suites: PaymentAdapter.t.sol, PaymentAdapterSettle.t.sol, SettledPurchase.t.sol, DeviceWalletTokens.t.sol, ESIMWalletTokenPurchase.t.sol, DeviceWalletFactoryCreateAccount.t.sol
  • New invariant handler/suite for payments (PaymentInvariants.t.sol, PaymentHandler.sol)
  • New Echidna/Medusa stateful fuzz suite (test/fizz/)
  • New deploy script test suite at test/scripts/ (50 tests) covering deploy scripts and their failure branches. Run separately with FOUNDRY_PROFILE=scripts forge test --threads 1 because these tests share one record file and one process environment, and would corrupt each other under the default parallel run
  • Deploy script tests wired into CI as their own job

Docs / cleanup

  • NatSpec gaps fixed (missing @notice, @return, @inheritdoc) and docs/ regenerated to match
  • Two event name typos fixed to match their sibling naming
  • Dead debug output, unused imports, and stale comments removed
  • funding.json removed
  • README updated: payment adapter documented, stale doc links fixed, test count corrected

Test coverage

819 tests across 73 suites, run with forge test --via-ir (about eight minutes), plus 50 deploy-script tests run separately.

dev this branch
Tests 573 819 (+246)
Suites 57 73 (+16)

forge build --via-ir --sizes is clean. No compiler warnings.

Covers the cents price cap, the asserted-purchase path on the registry
and the asset table on the payment adapter. Solidity reserves
`reference`, so the payment reference parameter is named in full.
A data bundle price was held in wei, which drifts against the figure the
user was actually charged and cannot express a card payment at all. Prices
are now USD cents in a uint64, and every purchase carries which contract,
if any, saw the money move.

The bundle id moves from string to bytes32. The provider's id fits, and a
string cost a slot plus its data on every history entry while showing up in
an event topic as a hash nothing can read back.

The ETH path keeps its wei price as a parameter and keeps the wei ceiling
that guards it. Nothing onchain relates wei to cents without a rate, so
dropping that ceiling now would leave the ETH path with no overcharge
protection at all. Both go when the ETH path does.
A wallet with no ceiling of its own falls back to the registry, and zero
there reads as no ceiling rather than as unset. Rejecting it at
initialisation is what the wei ceiling beside it already does.
The cents ceiling and the settlement token both have to be set at
initialisation, so they join the resolved config rather than arriving in a
second transaction per chain. The pointer from the registry is part of the
deployment too: without it the registry cannot resolve a currency, so it
refuses to record a settled purchase at all.
The wei ceiling had nothing left to guard once prices moved to cents. It
was measuring a different quantity from the one being recorded, and with no
rate onchain the two could never be reconciled, so carrying both meant
carrying a ceiling that agreed with nothing.

The ETH amount a purchase sends is now the admin's figure taken as given.
That gap closes with the token path, where the amount is derived from the
price rather than stated beside it.
The ETH figure each purchase test was written with stays the ETH argument.
The recorded price becomes a cents value, and the ceiling tests were the
ones that had to change meaning rather than shape: they bound what is
recorded now, not what is sent.

Every purchase draws a fresh payment reference. A reference is spendable
once protocol-wide, so a test buying twice would otherwise fail on replay
instead of on what it was written to check.
A purchase gained a payment reference, so it now writes one cold slot and
makes an external call to spend it: about 27,000 gas more either way it is
funded. The handover got cheaper, since the wallet's ceiling packs beside
the pending owner and the two clear in one write.
Several were three to five lines where two carried the same point, and a
few reached for phrasing like "currency vocabulary" or "a rail the
contracts cannot see" where a plain word was clearer. Two said what the
code already said.
Nothing onchain witnesses a fiat or external wallet payment, so the ceiling
is the only limit on the price the admin can write into a user's history.
It was only being applied on the path that does move ETH.
Past 36 the largest price the protocol can express overflows the conversion,
so the currency could be added and then never priced.
A reference is spent at most once whichever path spends it, and a currency
the table has withdrawn never returns a price.
Two rules for what the design asks for: a withdrawn currency never returns a
price, and a payment reference is marked by one entry point, only for the
registry, and never goes back. Corrects the price cap types the two older
specs still carried from the wei figure.

The 61 gas on buyDataBundle is the ceiling check added to the settled path
growing the wallet's dispatch.
The whole-body baseline had drifted through the cents migration. Most rows move
by tens of gas; the large jumps are test bodies that now deploy the adapter in
setup, and the drops are the smaller purchase struct.
The ceiling now shares a slot with an address on both the registry and the
wallet, and nothing pinned those bytes.
Two owner calls are owed on upgrade. Without them the ceiling reads zero, which
means no ceiling, and the old ceiling slot is read as the adapter address.
An adapter with an empty table prices nothing, so both payment paths revert
until this runs. Decimals come off the settlement token rather than assumed,
since USDC sits at a different address on every chain and the wrong one can
still hold code.
Two errors nothing reverts with, an import left dead by the pricing change, and
quote made external to match the other reads. No gas movement on either
baseline.
One access flag now covers ETH and tokens. A wallet trusted with the ETH
can already drain the owner, so a second flag would limit nothing.
The caller funds the adapter then calls settle, so a swap can be added
later without changing the signature or the wallets.
The adapter works the amount out from the price in cents, so unlike the
ETH path there is no second figure taken on trust.
The settlement token was an address with no code, which the settlement
path cannot use. Adds fee-on-transfer, no-return and callback variants.
Adds two accounting invariants: the adapter holds nothing between calls,
and the vault holds exactly what the purchases came to.
Also warms the vault before the buyDataBundle pair, so the funded and
unfunded figures no longer differ by a cold balance slot.
They were stale from before the cents change, so this picks up the
payment adapter and the settlement records as well as the token path.
The path was a constant, so nothing could read or write anywhere else.
Environment variables outlive a test, so the checks needed a way in that does not
rewrite them. Covers all eight.
Runs Deploy, Configure and TransferOwnership against a scratch record, including
the resume paths and the mismatch guards the fork rehearsal cannot reach.

They need their own profile and one thread. The scripts read the environment and
one record file, and forge runs tests in parallel, so two at once corrupt it.
It wrote into the live file and then removed its own entry on the way out.
Neither had held an ETH purchase test since the path was retired.
ESIMWalletFactorydeployed becomes ESIMWalletFactoryDeployed and its
beacon param gets the leading underscore its sibling event already
uses. UpdatedDeviceWalletassociatedWithESIMWallet gets the missing
capital A. Both are ABI changes: anything decoding these events by
name needs updating separately.
Data bundles settle in an ERC-20 through buyDataBundleWithToken, not
ETH, and canPullFunds now gates only a token pull. Several comments
in contracts and the invariant test harness still described the old
ETH-priced path. Also drops the vendor names Moonpay and Stripe from
Registry's NatSpec in favour of a description that covers both
settlement cases, and spells out that a purchase settles in USDC or
another accepted stablecoin rather than any ERC-20.
DeployerBase's setUp() logged every deployed address on every test's
setUp() and carried a numbered comment above each line restating
what it does; both go. A leftover console.log loop in
LazyWalletRegistry.t.sol printed identifiers the next line already
asserts on. Six mock contracts imported forge-std/Test.sol and
console.sol without using either. MockEntryPoint drops two comments
that only restated the code below them.
Missing space after the triple slash in DeviceWalletFactory's
NatSpec, trailing whitespace on every line of MockNonceManager's
getNonce signature, and a filler comment tightened in the two V2
mocks.
…transfers

Payment references now live on the registry instead of the payment adapter,
keyed per eSIM wallet. Two things follow from that. Rotating the adapter no
longer starts the spent-reference record empty, so a reference already used
cannot be replayed after a routine pointer swap. And because the record is
scoped to a wallet, one customer can no longer spend the raw reference an
admin settlement for an unrelated wallet is about to use, which was reachable
by watching the public mempool and front-running with a cheap purchase.

The adapter keeps its old mapping and its consume function at the same slot so
the live proxies do not shift, but nothing on the purchase paths reads them.

A token-paid purchase now runs the same ordering guard the settled path
already had, so it cannot land ahead of pre-deployment history still being
copied in and leave a wallet's history out of order.

The purchase also forwards the balance it actually holds after pulling from
the device wallet rather than the nominal price. A registered asset that
delivers less than requested used to fail on a bare ERC-20 balance error part
way through; it now reaches the adapter's own funding check and reverts with a
reason naming the shortfall.

Also records on settle that its agreement with quote holds only because
nothing changes an asset entry mid-transaction today, with a test pinning it.
Nothing in the protocol reads the standby flag, but its comment claimed it held
transactions on an eSIM wallet until a transfer settled. Gating spend on it
would brick a wallet its device wallet simply removed, since removeESIMWallet
raises it whether or not a transfer follows. The comment now describes what the
flag is rather than what it never did.

The price ceiling bounds one charge and not total spend, which makes funds
access an open allowance over the device wallet's balance. Noted on
setPriceCapUSDCents.

Tests pin both, plus two properties that already held: a stale registry
association hands a former device wallet nothing once a handover is accepted,
and a second wallet standing at another salt for the same identifier and key
stays unregistered and reaches no protocol function.
deployLazyWalletAndSetESIMIdentifier forwards the caller's deposit into a device
wallet it creates in the same call, and ran straight through a pause. A test
moved 2 ETH into a new wallet with paused set.

Its two siblings stay open under a pause on purpose, since neither moves ETH and
a device left half deployed with no history is worse for the user than one
finished while the money paths are stopped. A test pins that split so the check
is not copied onto them later.
Both answers acceptOwnershipBatch checks come from the target, so a
contract written to give them passes. The event names an address the
caller chose, not proof the protocol took anything.
The ceiling bounds what the admin can charge. The backfill records what
the user already paid, so there is no charge for it to bound, and
capping it would only stop true history from being written.
Second fuzzing engine over the protocol, previously the one testing
gap the x-ray flagged. 73 handlers, 24 properties, both campaigns
clean over 50k+ calls each with no protocol changes.

foundry.toml gets its own fuzz profile: via-IR without the optimizer
for accurate coverage, its own test/out paths so it never touches the
default build.
…andover

dev split the deployment record into a flat address book and a per-chain
detail file, and recorded the ProtocolAdmin handover on the v0.8 Base
Sepolia deployment. This branch had independently added an environment
override so tests and the fork rehearsal never touch the real record.

Reconciled the two: kept the two-file split, extended the override to
both files instead of one, and updated the 50-test deploy script suite
for the new unnested layout. Full test suite and the scripts profile
both green after the merge.
Shortened WebAuthn's verification-exclusions list and dropped
comments in DeviceWalletFactory, RegistryHelper, LazyWalletRegistry
and DeviceWallet that repeated what the next line already said.
DeploymentRecord.isRecorded() treats file existence as "already
deployed" regardless of content, so seeding the scratch record and
address book with {} made Deploy.s.sol refuse to start its own
rehearsal. Remove the files instead of writing empty ones.
vault() and isESIMWalletValid() were missing @return/@PARAM.
switchESIMIdentifierToNewDeviceIdentifier's @return named a variable
that doesn't exist on its unnamed return. Three Registry functions
duplicated their interface's NatSpec instead of using @inheritdoc.
deployLazyWallet's two return values shared one @return tag.
Every constructor had @PARAM or @dev but no @notice. Eight view
getters (upgradeManager, owner, eSIMWalletAdmin, getDeposit, the two
getCurrent*Implementation) returned a value with no @return tag.
Docs section pointed at a doc file for an interface removed earlier
(IOwnableESIMWallet), and was missing PaymentAdapter, IPaymentRegistry
and IRegistryAdmin.
forge test --list --json counts 819 tests across the same 73 suites,
not 804.
@ManulParihar
ManulParihar merged commit 7b70d50 into dev Sep 1, 2026
1 check passed
@ManulParihar
ManulParihar deleted the upgrade/payment-adapter branch September 1, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant