Stablecoin payment adapter and integration - #121
Merged
Merged
Conversation
Pull latest changes from main
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.
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.
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.
…adapter Moving contracts from ETH to Stablecoin (whitelisted ERC20) based payment
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
PaymentAdapter.sol: registers currencies, quotes and settles purchases in USD centsIPaymentRegistryinterface for what the adapter reads from the registryDeviceWalletandESIMWalletentirelyRegistry / Wallets
Registry.solgains settlement recording, currency table, and price ceiling in USD cents (was ETH-denominated)PriceCapSlotMigration.t.sol)DeviceWallet,ESIMWallet,LazyWalletRegistryupdated for the token pathTesting
PaymentAdapter.t.sol,PaymentAdapterSettle.t.sol,SettledPurchase.t.sol,DeviceWalletTokens.t.sol,ESIMWalletTokenPurchase.t.sol,DeviceWalletFactoryCreateAccount.t.solPaymentInvariants.t.sol,PaymentHandler.sol)test/fizz/)test/scripts/(50 tests) covering deploy scripts and their failure branches. Run separately withFOUNDRY_PROFILE=scripts forge test --threads 1because these tests share one record file and one process environment, and would corrupt each other under the default parallel runDocs / cleanup
@notice,@return,@inheritdoc) anddocs/regenerated to matchfunding.jsonremovedTest coverage
819 tests across 73 suites, run with
forge test --via-ir(about eight minutes), plus 50 deploy-script tests run separately.forge build --via-ir --sizesis clean. No compiler warnings.