From e33b156110d1ddf18b7357c1da1175abf7a0c3ef Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 13:52:02 +0530 Subject: [PATCH 01/75] Add the custom errors for USD pricing and settlement records 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. --- contracts/Errors.sol | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/contracts/Errors.sol b/contracts/Errors.sol index 4df2fc92..f9dcb0f8 100644 --- a/contracts/Errors.sol +++ b/contracts/Errors.sol @@ -102,6 +102,8 @@ interface Errors { error NotADeviceWallet(address account); error OnlyRequestedOwner(address newRequestedOwner); error UseAcceptOwnershipTransfer(); + error ZeroDataBundlePriceCents(); + error DataBundlePriceAboveCentsCap(uint64 priceUSDCents, uint64 cap); // DeviceWallet error UnknownESIMWallet(address eSIMWallet); @@ -115,4 +117,27 @@ interface Errors { error OnlyESIMWalletAdminOrRegistry(); error OnlyAssociatedESIMWallets(); error OnlyESIMWalletAdmin(); + + // Registry, on the path that records a purchase the contracts did not witness + error PaymentAdapterNotSet(); + error PaymentAdapterUnchanged(address paymentAdapter); + // buyDataBundle is the only caller allowed to claim the protocol saw the money move + error SettlementNotAsserted(); + // The lazy registry still owes this eSIM history, so a new entry would land out of order + error HistoryNotFullyCopied(string eSIMIdentifier, uint256 outstanding); + + // PaymentAdapter + error EmptyAssetSymbol(); + error EmptyPaymentReference(); + error AssetNotAllowed(bytes32 asset); + error AssetAlreadyRegistered(bytes32 asset); + error AssetNotRegistered(bytes32 asset); + // quote scales cents by 10**decimals and divides by 100, so anything under two truncates + error AssetDecimalsTooLow(bytes32 asset, uint8 decimals); + // No oracle, so a price only becomes a token amount for an asset already denominated in dollars + error AssetNeedsSwap(bytes32 asset); + // Record-only assets, fiat and non-EVM, carry no token address to move + error AssetNotSettleable(bytes32 asset); + error PaymentReferenceAlreadyUsed(bytes32 paymentReference); + error SettlementNotAvailable(); } From dfd09c09868fe390c881a559b8e751c3c1750de5 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 13:56:07 +0530 Subject: [PATCH 02/75] Price data bundles in USD cents and record how each one was paid for 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. --- contracts/CustomStructs.sol | 19 ++- contracts/LazyWalletRegistry.sol | 32 +++- contracts/Registry.sol | 145 ++++++++++++++++ contracts/RegistryHelper.sol | 26 ++- contracts/esim-wallet/ESIMWallet.sol | 136 ++++++++++++--- contracts/payments/PaymentAdapter.sol | 235 ++++++++++++++++++++++++++ 6 files changed, 560 insertions(+), 33 deletions(-) create mode 100644 contracts/payments/PaymentAdapter.sol diff --git a/contracts/CustomStructs.sol b/contracts/CustomStructs.sol index 36535614..d7681483 100644 --- a/contracts/CustomStructs.sol +++ b/contracts/CustomStructs.sol @@ -1,10 +1,25 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.36; +/// @notice Which contract, if any, saw the money for a data bundle move +/// @dev `DeviceWallet` is the only value the protocol can prove. The other two are the admin +/// stating what happened on a rail the contracts cannot see, so they are assertions and the +/// price cap is the only guard on them. +enum Settlement { + DeviceWallet, + ExternalWallet, + Fiat +} + /// @notice Data Bundle related details stored in the eSIM wallet +/// @dev Two slots: `id` fills the first, then `priceUSDCents` and `settlement` pack into the +/// second. The provider's bundle id fits in 32 bytes, so a `string` would have paid for a +/// general case this protocol does not have. No purchase timestamp: the event log carries the +/// block timestamp already. struct DataBundleDetails { - string dataBundleID; - uint256 dataBundlePrice; + bytes32 id; + uint64 priceUSDCents; // 123456 reads as $1234.56 + Settlement settlement; } /// @notice Object returned when a new device and eSIM wallet is deployed diff --git a/contracts/LazyWalletRegistry.sol b/contracts/LazyWalletRegistry.sol index 0f2e7532..8a9adb54 100644 --- a/contracts/LazyWalletRegistry.sol +++ b/contracts/LazyWalletRegistry.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.36; import {Errors} from "./Errors.sol"; // Types -import {DataBundleDetails} from "./CustomStructs.sol"; +import {DataBundleDetails, Settlement} from "./CustomStructs.sol"; // Contracts import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -574,12 +574,14 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra } } - DataBundleDetails[] storage dataBundleDetails = deviceIdentifierToESIMDetails[_deviceUniqueIdentifier][eSIMUniqueIdentifier]; - // Manually add a new struct to history and then set its fields - dataBundleDetails.push(); // Increase the array length by one - DataBundleDetails storage newDataBundleDetail = dataBundleDetails[dataBundleDetails.length - 1]; - newDataBundleDetail.dataBundleID = _dataBundleDetails[i].dataBundleID; - newDataBundleDetail.dataBundlePrice = _dataBundleDetails[i].dataBundlePrice; + // These entries predate the wallet, so no contract in this protocol can have seen the + // money move. Letting the admin claim otherwise would make the settlement field mean + // nothing on the one path where it is the only evidence there is. + if(_dataBundleDetails[i].settlement == Settlement.DeviceWallet) { + revert Errors.SettlementNotAsserted(); + } + + deviceIdentifierToESIMDetails[_deviceUniqueIdentifier][eSIMUniqueIdentifier].push(_dataBundleDetails[i]); } emit DataUpdatedForDevice(_deviceUniqueIdentifier, _eSIMUniqueIdentifiers, _dataBundleDetails); @@ -724,6 +726,22 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra return owner(); } + /// @notice How many stored history entries this eSIM still owes its deployed wallet + /// @dev The public getter on `deviceIdentifierToESIMDetails` takes an index rather than + /// returning a length, so the count cannot be worked out from outside this contract. + /// + /// Zero for an eSIM this contract never deployed a wallet for, which is the safe answer: + /// such a wallet has no stored history waiting to land after a new entry. + /// @param _eSIMIdentifier eSIM being asked about + /// @return Entries still waiting to be copied + function outstandingHistoryEntries(string calldata _eSIMIdentifier) external view returns (uint256) { + string memory deviceIdentifier = eSIMIdentifierToDeviceIdentifier[_eSIMIdentifier]; + if(bytes(deviceIdentifier).length == 0) return 0; + + return deviceIdentifierToESIMDetails[deviceIdentifier][_eSIMIdentifier].length + - historyEntriesCopied[_eSIMIdentifier]; + } + /// @notice Whether a device identifier has purchases recorded against it here /// @dev The ordinary deployment route asks this before taking an identifier, since a wallet /// created under a reserved one strands every eSIM bound to it: the deploy, the history diff --git a/contracts/Registry.sol b/contracts/Registry.sol index f52b8785..ba14b0dc 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -16,8 +16,12 @@ import {DeviceWalletFactory} from "./device-wallet/DeviceWalletFactory.sol"; import {DeviceWallet} from "./device-wallet/DeviceWallet.sol"; import {ESIMWalletFactory} from "./esim-wallet/ESIMWalletFactory.sol"; import {ESIMWallet} from "./esim-wallet/ESIMWallet.sol"; +import {PaymentAdapter, Asset} from "./payments/PaymentAdapter.sol"; import {Errors} from "./Errors.sol"; +// Types +import {DataBundleDetails, Settlement} from "./CustomStructs.sol"; + /// @notice Single source of truth for who is who in the protocol, and the switchboard the wallets /// read on every guarded path /// @dev Holds the admin address, the vault, the pause flag and the price ceiling in one place. @@ -94,6 +98,26 @@ contract Registry is /// cap reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`. uint256 public defaultDataBundlePriceCap; + /// @notice Most an eSIM wallet may be charged for one data bundle in USD cents, unless it sets + /// its own limit + /// @dev The cents counterpart of the ceiling above, and the one that applies to every price the + /// protocol records. Held here for the same reason: wallets are beacon proxies with no + /// enumerable list, so one write here is how a change reaches all of them. + uint64 public defaultPriceCapUSDCents; + + /// @notice The protocol's authority on how a data bundle was paid for + /// @dev Holds the currency vocabulary and the spent payment references. This registry keeps a + /// pointer and a setter, the same shape it already uses for the lazy wallet registry. + address public paymentAdapter; + + /// @notice Restricts a call to an eSIM wallet this registry has recorded + modifier onlyESIMWallet() { + if(isESIMWalletValid[msg.sender] == address(0)) { + revert Errors.NotAProtocolESIMWallet(msg.sender); + } + _; + } + /// @notice Restricts a call to a device wallet this registry has recorded modifier onlyDeviceWallet() { if(!isDeviceWalletValid[msg.sender]) revert Errors.OnlyDeviceWallet(); @@ -339,6 +363,127 @@ contract Registry is emit DefaultDataBundlePriceCapUpdated(_cap); } + /// @notice Sets the cents price ceiling eSIM wallets fall back to when they hold none of their + /// own + /// @dev Owner and not admin, for the same reason as the wei ceiling above. Zero is refused: it + /// reads as "no ceiling" on any wallet that has not set its own. + /// @param _cap Maximum price in USD cents, non-zero + function setDefaultPriceCapUSDCents(uint64 _cap) external onlyOwner { + if(_cap == 0) revert Errors.ZeroDataBundlePriceCents(); + + defaultPriceCapUSDCents = _cap; + emit DefaultPriceCapUSDCentsUpdated(_cap); + } + + /// @notice Points this registry at the contract that owns the currency vocabulary + /// @dev Owner and not admin. The adapter decides what may be paid in and holds the spent + /// payment references, so an admin able to re-point it could hand itself an empty set of + /// spent references and record every purchase again. + /// @param _paymentAdapter Address of the payment adapter + function setPaymentAdapter(address _paymentAdapter) external onlyOwner { + if(_paymentAdapter == address(0)) revert Errors.ZeroAddress("_paymentAdapter"); + if(_paymentAdapter == paymentAdapter) revert Errors.PaymentAdapterUnchanged(_paymentAdapter); + + paymentAdapter = _paymentAdapter; + emit PaymentAdapterUpdated(_paymentAdapter); + } + + // --------------------------------------------------------------------------------------------- + // Purchases settled off the protocol's own rails + // --------------------------------------------------------------------------------------------- + + /// @notice Spends a payment reference on behalf of an eSIM wallet paying through its own vault + /// @dev The adapter accepts this from the registry alone, so routing the wallet's call through + /// here keeps one reference from being spent once on each path. + /// @param _paymentReference Hash tying the purchase to the offchain order behind it + function consumePaymentReference(bytes32 _paymentReference) external onlyESIMWallet { + _consumePaymentReference(_paymentReference); + } + + /// @notice Records a data bundle the user paid for somewhere the protocol cannot see + /// @dev The counterpart to `buyDataBundle`, for money that moved through Moonpay, a card or an + /// external wallet. No transfer happens here. What the admin asserts is bounded by the + /// price ceiling, by the payment reference being spendable once, and by the settlement + /// being anything other than `DeviceWallet`, which only `buyDataBundle` may claim. + /// + /// Written through this contract rather than by the adapter, because eSIM wallets accept + /// history from this address and nothing else. Giving them a second writer to trust is + /// what the routing in `populateLazyHistory` already exists to avoid. + /// @param _eSIMWallet Wallet the purchase belongs to + /// @param _dataBundleDetail The purchase, priced in USD cents like everywhere else + /// @param _asset Symbol of the currency the user paid in + /// @param _tokenAmount What the admin observed the user pay, in that currency's smallest unit. + /// Carried for reconciliation and never validated: it and the price both come from the + /// admin, so checking one against the other would be the admin checking itself. + /// @param _paymentReference Hash of the Moonpay charge or the Stripe payment intent + function recordSettledPurchase( + address _eSIMWallet, + DataBundleDetails calldata _dataBundleDetail, + bytes32 _asset, + uint256 _tokenAmount, + bytes32 _paymentReference + ) external onlyESIMWalletAdmin { + if(paused) revert Errors.ProtocolPaused(); + if(isESIMWalletValid[_eSIMWallet] == address(0)) { + revert Errors.NotAProtocolESIMWallet(_eSIMWallet); + } + if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); + if(_dataBundleDetail.priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); + + // Only the contract that saw the money reach the vault may say it did, and that contract is + // never this one. Without this the settlement field stops carrying the one thing it is for. + if(_dataBundleDetail.settlement == Settlement.DeviceWallet) { + revert Errors.SettlementNotAsserted(); + } + + _requireLazyHistoryCopied(_eSIMWallet); + + Asset memory asset = PaymentAdapter(_requirePaymentAdapter()).resolveAsset(_asset); + _consumePaymentReference(_paymentReference); + + ESIMWallet(payable(_eSIMWallet)).recordSettledPurchase(_dataBundleDetail); + + emit DataBundleSettled( + _eSIMWallet, + _dataBundleDetail.id, + _dataBundleDetail.priceUSDCents, + _dataBundleDetail.settlement, + _asset, + asset.token, + _tokenAmount, + _paymentReference + ); + } + + /// @notice Refuses to append to a wallet the lazy registry has not finished copying history to + /// @dev Turns "call these three in this order" into something the contracts enforce. The + /// backend retries the whole onchain step on any failure, so an ordering rule that lives + /// only in an operational runbook does get violated, and the result is a transaction + /// history that is no longer in chronological order. + /// @param _eSIMWallet Wallet being appended to + function _requireLazyHistoryCopied(address _eSIMWallet) private view { + if(lazyWalletRegistry == address(0)) return; + + string memory eSIMIdentifier = ESIMWallet(payable(_eSIMWallet)).eSIMUniqueIdentifier(); + if(bytes(eSIMIdentifier).length == 0) return; + + uint256 outstanding = LazyWalletRegistry(lazyWalletRegistry).outstandingHistoryEntries(eSIMIdentifier); + if(outstanding != 0) revert Errors.HistoryNotFullyCopied(eSIMIdentifier, outstanding); + } + + /// @notice Spends a payment reference through the adapter + function _consumePaymentReference(bytes32 _paymentReference) private { + PaymentAdapter(_requirePaymentAdapter()).consumePaymentReference(_paymentReference); + } + + /// @notice The payment adapter, refusing to act while none is set + function _requirePaymentAdapter() private view returns (address) { + address adapter = paymentAdapter; + if(adapter == address(0)) revert Errors.PaymentAdapterNotSet(); + + return adapter; + } + // --------------------------------------------------------------------------------------------- // Device wallet registration // --------------------------------------------------------------------------------------------- diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index 30d369df..d6fac45c 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.36; import {Errors} from "./Errors.sol"; // Types -import {DataBundleDetails, Wallets} from "./CustomStructs.sol"; +import {DataBundleDetails, Settlement, Wallets} from "./CustomStructs.sol"; // Contracts import {DeviceWalletFactory} from "./device-wallet/DeviceWalletFactory.sol"; @@ -158,9 +158,31 @@ contract RegistryHelper { /// @notice Emitted when the owner releases the pause event Unpaused(address indexed _owner); - /// @notice Emitted when the owner changes the price ceiling eSIM wallets fall back to + /// @notice Emitted when the owner changes the wei price ceiling eSIM wallets fall back to event DefaultDataBundlePriceCapUpdated(uint256 _cap); + /// @notice Emitted when the owner changes the cents price ceiling eSIM wallets fall back to + event DefaultPriceCapUSDCentsUpdated(uint64 _cap); + + /// @notice Emitted when the owner points the registry at a payment adapter + event PaymentAdapterUpdated(address indexed _paymentAdapter); + + /// @notice Emitted for a purchase settled on a rail the protocol's contracts cannot see + /// @dev Emitted here rather than on the wallet so an indexer has one address to follow instead + /// of one per beacon proxy. `_tokenAmount` is what the admin observed the user pay, in + /// that currency's own smallest unit. Nothing validates it: both it and the price come + /// from the admin, so the price ceiling is the guard, not a comparison between the two. + event DataBundleSettled( + address indexed _eSIMWallet, + bytes32 _dataBundleID, + uint64 _priceUSDCents, + Settlement _settlement, + bytes32 indexed _asset, + address _token, + uint256 _tokenAmount, + bytes32 indexed _paymentReference + ); + /// @notice Emitted when an eSIM wallet's outstanding transfer is raised or settled event ESIMWalletSetOnStandby( address indexed _eSIMWalletAddress, diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 263a1cdb..981cbe31 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -6,7 +6,7 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Errors} from "../Errors.sol"; // Types -import {DataBundleDetails} from "../CustomStructs.sol"; +import {DataBundleDetails, Settlement} from "../CustomStructs.sol"; // Contracts import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -38,10 +38,19 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Address of the owner (device wallet) that becomes the new owner address public newRequestedOwner; - /// @notice Most this wallet may be charged for one data bundle, or zero to follow the registry - /// @dev Appended, and this contract is a leaf, so the slot lands past everything a live proxy - /// already holds and reads zero there. Zero has to keep meaning "no limit of my own" for - /// that reason, which is why the fallback lives on the registry rather than here. + /// @notice Most this wallet may be charged for one data bundle in USD cents, or zero to follow + /// the registry + /// @dev Declared here so it packs into the 12 spare bytes beside `newRequestedOwner`. Both are + /// cleared together on a handover, which makes that one SSTORE instead of two. Solidity + /// packs in declaration order, so moving this line costs the slot. + uint64 public priceCapUSDCents; + + /// @notice Most this wallet may be charged for one data bundle in wei, or zero to follow the + /// registry + /// @dev The ETH path still names its price in wei and nothing onchain relates that to the cents + /// figure recorded beside it, so this ceiling is what keeps the existing overcharge + /// protection alive. It goes when the ETH path does. Zero has to keep meaning "no limit of + /// my own", which is why the fallback lives on the registry rather than here. uint256 public dataBundlePriceCap; /// @notice Emitted when the eSIM wallet is deployed @@ -53,9 +62,20 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Emitted when the payment for a data bundle is made event DataBundleBought( - string _dataBundleID, - uint256 _dataBundlePrice, - uint256 _ethFromUser + bytes32 _dataBundleID, + uint64 _priceUSDCents, + uint256 _priceWei, + uint256 _ethFromUser, + bytes32 indexed _paymentReference + ); + + /// @notice Emitted when a purchase settled somewhere the contracts cannot see is recorded here + /// @dev The registry emits the full record. This one exists so an indexer following a single + /// wallet sees the same entry the wallet's own history now holds. + event DataBundleSettlementRecorded( + bytes32 _dataBundleID, + uint64 _priceUSDCents, + Settlement _settlement ); /// @notice Emitted when the eSIM unique identifier is initialised @@ -75,9 +95,12 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Emitted when the current owner revoked the ownership transfer request event OwnershipTransferRevoked(address indexed _currentOwner, address indexed _revokedOwner); - /// @notice Emitted when the owner sets this wallet's own price ceiling + /// @notice Emitted when the owner sets this wallet's own wei price ceiling event DataBundlePriceCapUpdated(uint256 _cap); + /// @notice Emitted when the owner sets this wallet's own cents price ceiling + event PriceCapUSDCentsUpdated(uint64 _cap); + /// @notice Restricts a call to the device wallet that owns this eSIM wallet /// @dev Reaching this means the owner signed for it, since a device wallet only calls out /// through `execute`. @@ -176,6 +199,16 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade emit DataBundlePriceCapUpdated(_cap); } + /// @notice Sets the most this wallet may be charged for one data bundle, in USD cents + /// @dev Same trust model as the wei ceiling above: only the owning device wallet, which means a + /// signature. This is the ceiling that applies to every price the protocol records, both + /// the payments it witnesses and the ones the admin asserts. + /// @param _cap Maximum price in USD cents, or zero to follow the registry + function setPriceCapUSDCents(uint64 _cap) external onlyDeviceWallet { + priceCapUSDCents = _cap; + emit PriceCapUSDCentsUpdated(_cap); + } + /// @notice Appends pre-deployment purchase history, one batch at a time, on behalf of the lazy /// wallet registry /// @dev The registry carries the cursor that says how much of an eSIM's history has already been @@ -273,29 +306,46 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade return _amount; } - /// @notice Pays the vault for one data bundle and records the purchase + /// @notice Pays the vault for one data bundle in ETH and records the purchase /// @dev Callable by the owning device wallet or by the admin, since the admin is the party that /// knows the price. Any shortfall is pulled from the device wallet, which is why the price /// is checked against a ceiling the admin cannot raise. - /// @param _dataBundleDetail Details of the data bundle being bought. (dataBundleID, dataBundlePrice) + /// + /// The recorded price is in cents and the money that moves is in wei, and no contract here + /// relates the two. `_priceWei` is therefore checked against its own ceiling rather than + /// derived from the cents figure. Both it and that ceiling go when the ETH path does. + /// @param _dataBundleDetail Data bundle being bought. Its settlement field is ignored: this + /// function is the only one that can prove the money moved, so it fills that in itself. + /// @param _priceWei ETH actually being sent to the vault + /// @param _paymentReference Ties this purchase to the offchain order, and is spent once, so a + /// retry of a call that already landed cannot charge the user twice /// @return True if the transaction is successful function buyDataBundle( - DataBundleDetails memory _dataBundleDetail + DataBundleDetails memory _dataBundleDetail, + uint256 _priceWei, + bytes32 _paymentReference ) public payable onlyDeviceWalletOrESIMWalletAdmin nonReentrant returns (bool) { Registry registry = deviceWallet.registry(); registry.requireNotPaused(); - if(bytes(_dataBundleDetail.dataBundleID).length == 0) revert Errors.EmptyDataBundleID(); - if(_dataBundleDetail.dataBundlePrice == 0) revert Errors.ZeroDataBundlePrice(); - _requirePriceWithinCap(_dataBundleDetail.dataBundlePrice, registry); + if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); + if(_dataBundleDetail.priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); + if(_priceWei == 0) revert Errors.ZeroDataBundlePrice(); + _requirePriceWithinCap(_priceWei, registry); + _requireCentsPriceWithinCap(_dataBundleDetail.priceUSDCents, registry); + + // The one path where the protocol sees the money reach the vault, so it is the one path + // allowed to say so. Overwritten rather than validated: the caller has no say either way. + _dataBundleDetail.settlement = Settlement.DeviceWallet; + + registry.consumePaymentReference(_paymentReference); // 1. msg.value is received by contract - // 2. if wallet balance is less than dataBundlePrice, pull ETH from device wallet - // 3. send dataBundlePrice amount of ETH to vault + // 2. if wallet balance is less than the price, pull ETH from device wallet + // 3. send the price to the vault uint256 walletBalance = address(this).balance; - if (walletBalance < _dataBundleDetail.dataBundlePrice) { - uint256 remainingETH = _dataBundleDetail.dataBundlePrice - walletBalance; - deviceWallet.pullETH(remainingETH); + if (walletBalance < _priceWei) { + deviceWallet.pullETH(_priceWei - walletBalance); } address vault = deviceWallet.getVaultAddress(); @@ -304,13 +354,34 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade // that is not yet in the history it would be reading. transactionHistory.push(_dataBundleDetail); - _transferETH(vault, _dataBundleDetail.dataBundlePrice); + _transferETH(vault, _priceWei); - emit DataBundleBought(_dataBundleDetail.dataBundleID, _dataBundleDetail.dataBundlePrice, msg.value); + emit DataBundleBought( + _dataBundleDetail.id, + _dataBundleDetail.priceUSDCents, + _priceWei, + msg.value, + _paymentReference + ); return true; } + /// @notice Appends a purchase the registry has already validated and settled elsewhere + /// @dev No money moves here. Everything that guards this entry, the price ceiling, the payment + /// reference and the rule that the settlement cannot claim to be witnessed, is checked on + /// the registry before the call arrives. + /// @param _dataBundleDetail The purchase to record + function recordSettledPurchase(DataBundleDetails calldata _dataBundleDetail) external onlyRegistry { + transactionHistory.push(_dataBundleDetail); + + emit DataBundleSettlementRecorded( + _dataBundleDetail.id, + _dataBundleDetail.priceUSDCents, + _dataBundleDetail.settlement + ); + } + // --------------------------------------------------------------------------------------------- // Closed ownership routes // --------------------------------------------------------------------------------------------- @@ -348,6 +419,11 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade emit DataBundlePriceCapUpdated(0); } + if(priceCapUSDCents != 0) { + priceCapUSDCents = 0; + emit PriceCapUSDCentsUpdated(0); + } + // Transfer ownership to the request address // _transferOwnership emits OwnershipTransferred, so this function must not emit it again _transferOwnership(newOwner); @@ -391,6 +467,22 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade } } + /// @notice Rejects a cents price above whichever ceiling applies to this wallet + /// @dev Same fallback rule as the wei check above. Zero on the wallet means "follow the + /// registry", and the registry default is guaranteed non-zero by its own setters. + /// @param _priceUSDCents Price being charged, in USD cents + /// @param _registry Registry holding the fallback ceiling + function _requireCentsPriceWithinCap(uint64 _priceUSDCents, Registry _registry) private view { + uint64 cap = priceCapUSDCents; + if (cap == 0) { + cap = _registry.defaultPriceCapUSDCents(); + } + + if (cap != 0 && _priceUSDCents > cap) { + revert Errors.DataBundlePriceAboveCentsCap(_priceUSDCents, cap); + } + } + /// @notice The device wallet that owns this eSIM wallet /// @dev Declared so subclasses and mocks have one place to override. function owner() public view override returns (address) { diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol new file mode 100644 index 00000000..f7ec9069 --- /dev/null +++ b/contracts/payments/PaymentAdapter.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +// Libraries +import {Errors} from "../Errors.sol"; + +// Contracts +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; + +/// @notice One currency the protocol will price a data bundle in +/// @dev 23 bytes, so the whole struct is one slot with nine to spare. Fields may be added inside +/// those nine bytes. Growing past one slot changes the slot stride of every entry in the +/// mapping, and a live table has no migration path for that. +struct Asset { + bool allowed; + bool isDollarUnit; // USDC, USDT, DAI and USD are true. ETH, TON and ZEC are not + uint8 decimals; // USDC 6, ETH 18, TON 9, ZEC 8, USD 2 + address token; // ERC-20 address, or zero for fiat and non-EVM assets +} + +/// @notice The protocol's authority on how a data bundle was paid for +/// @dev Holds the currency vocabulary, turns a cent price into a token amount, and spends the +/// payment references that make a purchase impossible to record twice. It holds no purchase +/// records: those stay on the eSIM wallet that made them. +/// +/// Prices cross every contract boundary in USD cents and nowhere else. This contract is the +/// only place a cent figure becomes a token amount, which is what makes a decimal mismatch +/// impossible rather than merely something to check for. There are no price feeds anywhere in +/// the protocol, so an asset that is not already denominated in dollars has no answer here. +/// +/// UUPS rather than a contract the registry can be re-pointed at. `usedReferences` is replay +/// protection, and swapping it out for an empty one re-opens every reference already spent. +contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { + + /// @notice Cents per dollar, the divisor that takes a cent price down to whole units + uint256 private constant CENTS_PER_DOLLAR = 100; + + /// @notice Fewest decimals an asset may have before `quote` starts truncating + /// @dev The scale is `10 ** decimals / 100`, so anything under two decimals loses the cents. + uint8 private constant MIN_ASSET_DECIMALS = 2; + + /// @notice Registry contract address, the only caller allowed to spend a payment reference + address public registry; + + /// @notice Currency the vault is meant to end up holding + /// @dev Read by nothing today. It is set at initialisation anyway so that adding the swap path + /// later is an implementation change rather than a migration transaction on every chain. + address public settlementToken; + + /// @notice Every currency the protocol will accept or record a payment in + mapping(bytes32 symbol => Asset asset) public assets; + + /// @notice Payment references already spent, protocol-wide + /// @dev One reference is one offchain payment. Spending it is what makes recording a purchase + /// safe to retry: the backend retries the whole onchain step on any failure, so without + /// this a retry of a call that already landed writes the purchase a second time. + mapping(bytes32 paymentReference => bool used) public usedReferences; + + /// @notice Emitted when this contract is wired up + event PaymentAdapterInitialized(address indexed _registry, address indexed _settlementToken); + + /// @notice Emitted when a currency enters the vocabulary or its entry changes + event AssetUpdated( + bytes32 indexed _symbol, + bool _allowed, + bool _isDollarUnit, + uint8 _decimals, + address indexed _token + ); + + /// @notice Emitted when a payment reference is spent + event PaymentReferenceConsumed(bytes32 indexed _paymentReference); + + /// @notice Restricts a call to the registry + modifier onlyRegistry() { + if(msg.sender != registry) revert Errors.OnlyRegistry(); + _; + } + + // --------------------------------------------------------------------------------------------- + // Initialisation + // --------------------------------------------------------------------------------------------- + + /// @dev Locks the implementation contract itself, so nobody can initialise and own it directly. + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Wires the adapter to the registry and names the currency the vault should hold + /// @param _registry Registry contract address + /// @param _settlementToken Token the vault is meant to end up holding, normally USDC + /// @param _upgradeManager Address that owns this contract and authorises its upgrades + function initialize( + address _registry, + address _settlementToken, + address _upgradeManager + ) external initializer { + if(_registry == address(0)) revert Errors.ZeroAddress("_registry"); + if(_settlementToken == address(0)) revert Errors.ZeroAddress("_settlementToken"); + if(_upgradeManager == address(0)) revert Errors.ZeroAddress("_upgradeManager"); + + registry = _registry; + settlementToken = _settlementToken; + + __Ownable2Step_init(); + __Ownable_init(_upgradeManager); + + emit PaymentAdapterInitialized(_registry, _settlementToken); + } + + // --------------------------------------------------------------------------------------------- + // The currency vocabulary + // --------------------------------------------------------------------------------------------- + + /// @notice Adds a currency the protocol will accept or record a payment in + /// @dev Owner and not admin. The admin names the price on every purchase, so letting it also + /// name the currencies would let it invent a token address to be paid in. + /// @param _symbol Short ASCII symbol, "USDC" or "USD" or "TON" + /// @param _asset The entry to write + function registerAsset(bytes32 _symbol, Asset calldata _asset) external onlyOwner { + if(assets[_symbol].decimals != 0) revert Errors.AssetAlreadyRegistered(_symbol); + + _writeAsset(_symbol, _asset); + } + + /// @notice Changes an existing currency entry, including withdrawing it + /// @dev Separate from `registerAsset` so a typo in a new symbol cannot silently overwrite a + /// currency already in use. Set `allowed` to false to withdraw one. + /// @param _symbol Symbol of the currency being changed + /// @param _asset The entry to write in its place + function updateAsset(bytes32 _symbol, Asset calldata _asset) external onlyOwner { + if(assets[_symbol].decimals == 0) revert Errors.AssetNotRegistered(_symbol); + + _writeAsset(_symbol, _asset); + } + + // --------------------------------------------------------------------------------------------- + // Pricing + // --------------------------------------------------------------------------------------------- + + /// @notice Turns a price in USD cents into an amount of one currency's own smallest unit + /// @dev The only place in the protocol that does this. No caller ever states a token amount as + /// an authoritative figure, so there is no second number anywhere to reconcile this one + /// against, and no tolerance band to check it inside. + /// + /// An asset that is not denominated in dollars has no answer without a rate, and there is + /// no oracle here, so it reverts rather than guessing. + /// @param _symbol Currency the price is being expressed in + /// @param _priceUSDCents Price in USD cents + /// @return amountIn Amount in that currency's own smallest unit + function quote(bytes32 _symbol, uint64 _priceUSDCents) public view returns (uint256 amountIn) { + Asset memory asset = assets[_symbol]; + + if(!asset.allowed) revert Errors.AssetNotAllowed(_symbol); + if(!asset.isDollarUnit) revert Errors.AssetNeedsSwap(_symbol); + + return (uint256(_priceUSDCents) * 10 ** asset.decimals) / CENTS_PER_DOLLAR; + } + + /// @notice Reads back a currency entry, reverting if it was never registered + /// @dev Callers resolve the token address and the decimals through here rather than stating + /// them, which is what keeps them consistent across every record the protocol writes. + /// @param _symbol Currency to read + /// @return The stored entry + function resolveAsset(bytes32 _symbol) external view returns (Asset memory) { + Asset memory asset = assets[_symbol]; + if(!asset.allowed) revert Errors.AssetNotAllowed(_symbol); + + return asset; + } + + // --------------------------------------------------------------------------------------------- + // Payment references + // --------------------------------------------------------------------------------------------- + + /// @notice Spends a payment reference, refusing one already spent + /// @dev Registry only, on both the witnessed and the asserted path, so one reference cannot be + /// spent once on each. + /// @param _paymentReference Hash tying this purchase to the offchain payment behind it + function consumePaymentReference(bytes32 _paymentReference) external onlyRegistry { + if(_paymentReference == bytes32(0)) revert Errors.EmptyPaymentReference(); + if(usedReferences[_paymentReference]) revert Errors.PaymentReferenceAlreadyUsed(_paymentReference); + + usedReferences[_paymentReference] = true; + + emit PaymentReferenceConsumed(_paymentReference); + } + + // --------------------------------------------------------------------------------------------- + // Ownership and upgrades + // --------------------------------------------------------------------------------------------- + + /// @notice Ownership of this contract is never renounced + /// @dev The owner is the only caller `_authorizeUpgrade` accepts and the only one that can + /// change the currency vocabulary. Renouncing would freeze both for good. + function renounceOwnership() public pure override { + revert Errors.OwnershipCannotBeRenounced(); + } + + /// @notice Restricts UUPS upgrades to the owner + /// @param newImplementation Address of the implementation being moved to + function _authorizeUpgrade(address newImplementation) + internal + onlyOwner + override + {} + + /// @notice Address that can upgrade this contract + /// @dev Reads through to the owner rather than holding a second copy that could disagree. + function upgradeManager() public view returns (address) { + return owner(); + } + + // --------------------------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------------------------- + + /// @notice Validates and stores one currency entry + /// @dev `decimals` doubles as the "was this ever registered" marker, so it is non-zero on every + /// entry including a withdrawn one. That is why a withdrawal sets `allowed` to false + /// rather than deleting the row. + function _writeAsset(bytes32 _symbol, Asset calldata _asset) private { + if(_symbol == bytes32(0)) revert Errors.EmptyAssetSymbol(); + if(_asset.decimals < MIN_ASSET_DECIMALS) { + revert Errors.AssetDecimalsTooLow(_symbol, _asset.decimals); + } + + assets[_symbol] = _asset; + + emit AssetUpdated(_symbol, _asset.allowed, _asset.isDollarUnit, _asset.decimals, _asset.token); + } +} From a9e7c7fa20f17d7722f4ac0bb888286ca9ca95e9 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 13:56:46 +0530 Subject: [PATCH 03/75] Require a non-zero cents ceiling when the registry is initialised 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. --- contracts/Registry.sol | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/contracts/Registry.sol b/contracts/Registry.sol index ba14b0dc..40b7ef59 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -159,8 +159,10 @@ contract Registry is /// @param _deviceWalletFactory Factory that deploys device wallets /// @param _eSIMWalletFactory Factory that deploys eSIM wallets /// @param _entryPoint ERC-4337 EntryPoint singleton for this chain - /// @param _defaultDataBundlePriceCap Starting price ceiling. Must be non-zero: a zero cap, here - /// or on a wallet's own, reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`. + /// @param _defaultDataBundlePriceCap Starting wei price ceiling. Must be non-zero: a zero cap, + /// here or on a wallet's own, reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`. + /// @param _defaultPriceCapUSDCents Starting cents price ceiling. Non-zero for the same reason, + /// and this is the one that bounds every price the protocol records. function initialize( address _eSIMWalletAdmin, address _vault, @@ -168,7 +170,8 @@ contract Registry is address _deviceWalletFactory, address _eSIMWalletFactory, IEntryPoint _entryPoint, - uint256 _defaultDataBundlePriceCap + uint256 _defaultDataBundlePriceCap, + uint64 _defaultPriceCapUSDCents ) external initializer { if(_eSIMWalletAdmin == address(0)) revert Errors.ZeroAddress("_eSIMWalletAdmin"); if(_vault == address(0)) revert Errors.ZeroAddress("_vault"); @@ -181,11 +184,13 @@ contract Registry is if(_deviceWalletFactory == address(0)) revert Errors.ZeroAddress("_deviceWalletFactory"); if(_eSIMWalletFactory == address(0)) revert Errors.ZeroAddress("_eSIMWalletFactory"); if(_defaultDataBundlePriceCap == 0) revert Errors.ZeroDataBundlePriceCap(); + if(_defaultPriceCapUSDCents == 0) revert Errors.ZeroDataBundlePriceCents(); adminOfRecord = _eSIMWalletAdmin; entryPoint = _entryPoint; vault = _vault; defaultDataBundlePriceCap = _defaultDataBundlePriceCap; + defaultPriceCapUSDCents = _defaultPriceCapUSDCents; deviceWalletFactory = DeviceWalletFactory(_deviceWalletFactory); eSIMWalletFactory = ESIMWalletFactory(_eSIMWalletFactory); @@ -201,6 +206,7 @@ contract Registry is address(eSIMWalletFactory) ); emit DefaultDataBundlePriceCapUpdated(_defaultDataBundlePriceCap); + emit DefaultPriceCapUSDCentsUpdated(_defaultPriceCapUSDCents); } // --------------------------------------------------------------------------------------------- From 1f498f81235a9142d605494ad9c92b1216a04533 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 13:59:05 +0530 Subject: [PATCH 04/75] Deploy the payment adapter and wire it to the registry 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. --- env.sample | 11 ++++++-- scripts/deploy/Deploy.s.sol | 38 ++++++++++++++++++++++++-- scripts/deploy/config/DeployConfig.sol | 11 ++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/env.sample b/env.sample index 353f42e6..b9a8c7a9 100644 --- a/env.sample +++ b/env.sample @@ -32,10 +32,17 @@ TIMELOCK_GUARDIANS= # every proposer can already cancel. TIMELOCK_CANCELLERS= -# Ceiling on a single data bundle purchase, in wei. Unset or 0 means no ceiling, which is what the -# protocol does when the registry default is unset. Set it before wallets carry real balances. +# Ceiling on a single data bundle purchase, in wei. Guards the ETH path only, and goes when that +# path does. Rejected if zero: the registry reads zero as no ceiling at all. DATA_BUNDLE_PRICE_CAP= +# Ceiling on a single data bundle purchase, in USD cents. 100000 reads as $1000. This is the one +# that bounds every price the protocol records. Rejected if zero, same reason as above. +PRICE_CAP_USD_CENTS= + +# Currency the vault is meant to end up holding, normally USDC on the target chain. +SETTLEMENT_TOKEN= + # --------------------------------------------------------------------------------------------- # Upgrades: scripts/upgrade/UpgradeSingleton.s.sol, UpgradeBeacon.s.sol # --------------------------------------------------------------------------------------------- diff --git a/scripts/deploy/Deploy.s.sol b/scripts/deploy/Deploy.s.sol index 2a41b41e..f0c35b7f 100644 --- a/scripts/deploy/Deploy.s.sol +++ b/scripts/deploy/Deploy.s.sol @@ -13,6 +13,7 @@ import {DeviceWallet} from "../../contracts/device-wallet/DeviceWallet.sol"; import {DeviceWalletFactory} from "../../contracts/device-wallet/DeviceWalletFactory.sol"; import {ESIMWallet} from "../../contracts/esim-wallet/ESIMWallet.sol"; import {ESIMWalletFactory} from "../../contracts/esim-wallet/ESIMWalletFactory.sol"; +import {PaymentAdapter} from "../../contracts/payments/PaymentAdapter.sol"; // Config import {DeployConfig} from "./config/DeployConfig.sol"; @@ -53,6 +54,7 @@ contract Deploy is Script { address deviceWalletFactory; address registry; address lazyWalletRegistry; + address paymentAdapter; } /// @notice ERC-1967 implementation slot, read back rather than assumed @@ -146,7 +148,8 @@ contract Deploy is Script { deployed.deviceWalletFactory, deployed.eSIMWalletFactory, config.entryPoint, - config.dataBundlePriceCap + config.dataBundlePriceCap, + config.priceCapUSDCents ) ) ); @@ -155,6 +158,18 @@ contract Deploy is Script { address(new LazyWalletRegistry()), abi.encodeCall(LazyWalletRegistry.initialize, (deployed.registry, config.deployer)) ); + + deployed.paymentAdapter = _deployProxy( + address(new PaymentAdapter()), + abi.encodeCall( + PaymentAdapter.initialize, + (deployed.registry, config.settlementToken, config.deployer) + ) + ); + + // The registry refuses to record a settled purchase until it has an adapter to resolve the + // currency through, so the pointer is part of the deployment rather than of configuration. + Registry(deployed.registry).setPaymentAdapter(deployed.paymentAdapter); } /// @notice Stands a UUPS implementation up behind a proxy and initializes it in one transaction @@ -204,6 +219,16 @@ contract Deploy is Script { if(boundRegistry != deployed.registry) { revert WiringMismatch("LazyWalletRegistry.registry", deployed.registry, boundRegistry); } + + address boundAdapter = registry.paymentAdapter(); + if(boundAdapter != deployed.paymentAdapter) { + revert WiringMismatch("Registry.paymentAdapter", deployed.paymentAdapter, boundAdapter); + } + + address adapterRegistry = PaymentAdapter(deployed.paymentAdapter).registry(); + if(adapterRegistry != deployed.registry) { + revert WiringMismatch("PaymentAdapter.registry", deployed.registry, adapterRegistry); + } } /// @notice Writes the deployment record for this chain @@ -260,7 +285,9 @@ contract Deploy is Script { { vm.serializeAddress("params", "eSIMWalletAdmin", config.eSIMWalletAdmin); vm.serializeAddress("params", "vault", config.vault); - section = vm.serializeUint("params", "dataBundlePriceCap", config.dataBundlePriceCap); + vm.serializeUint("params", "dataBundlePriceCap", config.dataBundlePriceCap); + vm.serializeUint("params", "priceCapUSDCents", config.priceCapUSDCents); + section = vm.serializeAddress("params", "settlementToken", config.settlementToken); } function _adminSection(DeployConfig.Config memory config, Deployed memory deployed) @@ -312,11 +339,16 @@ contract Deploy is Script { ) ); vm.serializeString("contracts", "RegistryProxy", _proxy("registry", deployed.registry)); - section = vm.serializeString( + vm.serializeString( "contracts", "LazyWalletRegistryProxy", _proxy("lazyRegistry", deployed.lazyWalletRegistry) ); + section = vm.serializeString( + "contracts", + "PaymentAdapterProxy", + _proxy("paymentAdapter", deployed.paymentAdapter) + ); } function _statusSection() private returns (string memory section) { diff --git a/scripts/deploy/config/DeployConfig.sol b/scripts/deploy/config/DeployConfig.sol index a1ff417d..41d86f76 100644 --- a/scripts/deploy/config/DeployConfig.sol +++ b/scripts/deploy/config/DeployConfig.sol @@ -56,6 +56,8 @@ library DeployConfig { address eSIMWalletAdmin; address vault; uint256 dataBundlePriceCap; + uint64 priceCapUSDCents; + address settlementToken; address[] proposers; address[] cancellers; address[] guardians; @@ -99,6 +101,15 @@ library DeployConfig { config.dataBundlePriceCap = vm.envUint("DATA_BUNDLE_PRICE_CAP"); if(config.dataBundlePriceCap == 0) revert MissingValue("DATA_BUNDLE_PRICE_CAP"); + // Same rule for the cents ceiling, which is the one that bounds every recorded price. + config.priceCapUSDCents = uint64(vm.envUint("PRICE_CAP_USD_CENTS")); + if(config.priceCapUSDCents == 0) revert MissingValue("PRICE_CAP_USD_CENTS"); + + // The currency the vault is meant to hold. Nothing reads it until swaps exist, but the + // adapter takes it at initialisation so adding them later needs no migration transaction. + config.settlementToken = vm.envAddress("SETTLEMENT_TOKEN"); + if(config.settlementToken == address(0)) revert MissingAddress("SETTLEMENT_TOKEN"); + config.entryPoint = IEntryPoint(ENTRY_POINT_V08); if(ENTRY_POINT_V08.code.length == 0) { revert EntryPointNotDeployed(ENTRY_POINT_V08, block.chainid); From a2957ea545fffd3f34fed25be1b68506c2ec0276 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 14:04:51 +0530 Subject: [PATCH 05/75] Make USD cents the only price ceiling 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. --- contracts/Errors.sol | 4 +- contracts/Registry.sol | 44 ++---- contracts/RegistryHelper.sol | 5 +- contracts/esim-wallet/ESIMWallet.sol | 67 ++------- env.sample | 8 +- scripts/deploy/Configure.s.sol | 10 +- scripts/deploy/Deploy.s.sol | 2 - scripts/deploy/config/DeployConfig.sol | 5 - .../fuzz-testing/AdminOperations.t.sol | 38 ++--- test/foundry/fuzz-testing/ETHAmounts.t.sol | 64 ++++---- .../fuzz-testing/IdentifiersAndArrays.t.sol | 20 +-- .../fuzz-testing/LazyDeployBatching.t.sol | 2 +- .../fuzz-testing/LazyHistoryCopy.t.sol | 6 +- test/utils/DeployerBase.sol | 141 ++++++++++++++---- 14 files changed, 211 insertions(+), 205 deletions(-) diff --git a/contracts/Errors.sol b/contracts/Errors.sol index f9dcb0f8..fa602279 100644 --- a/contracts/Errors.sol +++ b/contracts/Errors.sol @@ -94,7 +94,7 @@ interface Errors { // ESIMWallet error OnlyRegistry(); error OnlyDeviceWalletOrESIMWalletAdmin(); - error DataBundlePriceAboveCap(uint256 price, uint256 cap); + error DataBundlePriceAboveCap(uint64 priceUSDCents, uint64 cap); error ESIMIdentifierAlreadySet(string eSIMUniqueIdentifier); error EmptyDataBundleID(); error ZeroDataBundlePrice(); @@ -102,8 +102,6 @@ interface Errors { error NotADeviceWallet(address account); error OnlyRequestedOwner(address newRequestedOwner); error UseAcceptOwnershipTransfer(); - error ZeroDataBundlePriceCents(); - error DataBundlePriceAboveCentsCap(uint64 priceUSDCents, uint64 cap); // DeviceWallet error UnknownESIMWallet(address eSIMWallet); diff --git a/contracts/Registry.sol b/contracts/Registry.sol index 40b7ef59..54c964ab 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -91,18 +91,13 @@ contract Registry is /// fast as it was suspended would leave the two sides trading transactions forever. bool public adminDisabled; - /// @notice Most an eSIM wallet may be charged for one data bundle unless it sets its own limit - /// @dev Held here rather than only on each wallet because a wallet deployed before this existed - /// reads zero, and there is no enumerable list to write a value into. Never zero: `initialize` - /// and `setDefaultDataBundlePriceCap` both reject it, since a zero here or on a wallet's own - /// cap reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`. - uint256 public defaultDataBundlePriceCap; - - /// @notice Most an eSIM wallet may be charged for one data bundle in USD cents, unless it sets + /// @notice Most an eSIM wallet may be charged for one data bundle, in USD cents, unless it sets /// its own limit - /// @dev The cents counterpart of the ceiling above, and the one that applies to every price the - /// protocol records. Held here for the same reason: wallets are beacon proxies with no - /// enumerable list, so one write here is how a change reaches all of them. + /// @dev Held here rather than only on each wallet because wallets are beacon proxies tracked by + /// a mapping with no enumerable list, so one write here is how a change reaches all of + /// them. Never zero: `initialize` and `setDefaultPriceCapUSDCents` both reject it, since a + /// zero here or on a wallet's own cap reads as "no ceiling" in + /// `ESIMWallet._requirePriceWithinCap`. uint64 public defaultPriceCapUSDCents; /// @notice The protocol's authority on how a data bundle was paid for @@ -159,10 +154,9 @@ contract Registry is /// @param _deviceWalletFactory Factory that deploys device wallets /// @param _eSIMWalletFactory Factory that deploys eSIM wallets /// @param _entryPoint ERC-4337 EntryPoint singleton for this chain - /// @param _defaultDataBundlePriceCap Starting wei price ceiling. Must be non-zero: a zero cap, - /// here or on a wallet's own, reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`. - /// @param _defaultPriceCapUSDCents Starting cents price ceiling. Non-zero for the same reason, - /// and this is the one that bounds every price the protocol records. + /// @param _defaultPriceCapUSDCents Starting price ceiling in USD cents. Must be non-zero: a + /// zero cap, here or on a wallet's own, reads as "no ceiling" in + /// `ESIMWallet._requirePriceWithinCap`. function initialize( address _eSIMWalletAdmin, address _vault, @@ -170,7 +164,6 @@ contract Registry is address _deviceWalletFactory, address _eSIMWalletFactory, IEntryPoint _entryPoint, - uint256 _defaultDataBundlePriceCap, uint64 _defaultPriceCapUSDCents ) external initializer { if(_eSIMWalletAdmin == address(0)) revert Errors.ZeroAddress("_eSIMWalletAdmin"); @@ -183,13 +176,11 @@ contract Registry is // ESIMWalletFactory's caller check dead, recoverable only by an upgrade. if(_deviceWalletFactory == address(0)) revert Errors.ZeroAddress("_deviceWalletFactory"); if(_eSIMWalletFactory == address(0)) revert Errors.ZeroAddress("_eSIMWalletFactory"); - if(_defaultDataBundlePriceCap == 0) revert Errors.ZeroDataBundlePriceCap(); - if(_defaultPriceCapUSDCents == 0) revert Errors.ZeroDataBundlePriceCents(); + if(_defaultPriceCapUSDCents == 0) revert Errors.ZeroDataBundlePriceCap(); adminOfRecord = _eSIMWalletAdmin; entryPoint = _entryPoint; vault = _vault; - defaultDataBundlePriceCap = _defaultDataBundlePriceCap; defaultPriceCapUSDCents = _defaultPriceCapUSDCents; deviceWalletFactory = DeviceWalletFactory(_deviceWalletFactory); @@ -205,7 +196,6 @@ contract Registry is address(deviceWalletFactory), address(eSIMWalletFactory) ); - emit DefaultDataBundlePriceCapUpdated(_defaultDataBundlePriceCap); emit DefaultPriceCapUSDCentsUpdated(_defaultPriceCapUSDCents); } @@ -361,21 +351,9 @@ contract Registry is /// letting it raise its own limit would leave the ceiling meaningless. Zero is refused: /// it would read as "no ceiling" in `ESIMWallet._requirePriceWithinCap` for every wallet /// that has not set its own. - /// @param _cap Maximum price in wei, non-zero - function setDefaultDataBundlePriceCap(uint256 _cap) external onlyOwner { - if(_cap == 0) revert Errors.ZeroDataBundlePriceCap(); - - defaultDataBundlePriceCap = _cap; - emit DefaultDataBundlePriceCapUpdated(_cap); - } - - /// @notice Sets the cents price ceiling eSIM wallets fall back to when they hold none of their - /// own - /// @dev Owner and not admin, for the same reason as the wei ceiling above. Zero is refused: it - /// reads as "no ceiling" on any wallet that has not set its own. /// @param _cap Maximum price in USD cents, non-zero function setDefaultPriceCapUSDCents(uint64 _cap) external onlyOwner { - if(_cap == 0) revert Errors.ZeroDataBundlePriceCents(); + if(_cap == 0) revert Errors.ZeroDataBundlePriceCap(); defaultPriceCapUSDCents = _cap; emit DefaultPriceCapUSDCentsUpdated(_cap); diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index d6fac45c..805279c8 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -158,10 +158,7 @@ contract RegistryHelper { /// @notice Emitted when the owner releases the pause event Unpaused(address indexed _owner); - /// @notice Emitted when the owner changes the wei price ceiling eSIM wallets fall back to - event DefaultDataBundlePriceCapUpdated(uint256 _cap); - - /// @notice Emitted when the owner changes the cents price ceiling eSIM wallets fall back to + /// @notice Emitted when the owner changes the price ceiling eSIM wallets fall back to event DefaultPriceCapUSDCentsUpdated(uint64 _cap); /// @notice Emitted when the owner points the registry at a payment adapter diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 981cbe31..7a827da1 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -38,21 +38,16 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Address of the owner (device wallet) that becomes the new owner address public newRequestedOwner; - /// @notice Most this wallet may be charged for one data bundle in USD cents, or zero to follow + /// @notice Most this wallet may be charged for one data bundle, in USD cents, or zero to follow /// the registry /// @dev Declared here so it packs into the 12 spare bytes beside `newRequestedOwner`. Both are /// cleared together on a handover, which makes that one SSTORE instead of two. Solidity /// packs in declaration order, so moving this line costs the slot. + /// + /// Zero has to keep meaning "no limit of my own" rather than "no limit", which is why the + /// fallback lives on the registry rather than here. uint64 public priceCapUSDCents; - /// @notice Most this wallet may be charged for one data bundle in wei, or zero to follow the - /// registry - /// @dev The ETH path still names its price in wei and nothing onchain relates that to the cents - /// figure recorded beside it, so this ceiling is what keeps the existing overcharge - /// protection alive. It goes when the ETH path does. Zero has to keep meaning "no limit of - /// my own", which is why the fallback lives on the registry rather than here. - uint256 public dataBundlePriceCap; - /// @notice Emitted when the eSIM wallet is deployed event ESIMWalletDeployed( address indexed _eSIMWalletAddress, @@ -95,10 +90,7 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Emitted when the current owner revoked the ownership transfer request event OwnershipTransferRevoked(address indexed _currentOwner, address indexed _revokedOwner); - /// @notice Emitted when the owner sets this wallet's own wei price ceiling - event DataBundlePriceCapUpdated(uint256 _cap); - - /// @notice Emitted when the owner sets this wallet's own cents price ceiling + /// @notice Emitted when the owner sets this wallet's own price ceiling event PriceCapUSDCentsUpdated(uint64 _cap); /// @notice Restricts a call to the device wallet that owns this eSIM wallet @@ -193,16 +185,6 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// price on `buyDataBundle`, so it must not also be able to raise the ceiling on that /// price. Setting zero hands the wallet back to the registry's ceiling. A handover clears /// it, so an incoming owner starts on the registry ceiling. - /// @param _cap Maximum price in wei, or zero to follow the registry - function setDataBundlePriceCap(uint256 _cap) external onlyDeviceWallet { - dataBundlePriceCap = _cap; - emit DataBundlePriceCapUpdated(_cap); - } - - /// @notice Sets the most this wallet may be charged for one data bundle, in USD cents - /// @dev Same trust model as the wei ceiling above: only the owning device wallet, which means a - /// signature. This is the ceiling that applies to every price the protocol records, both - /// the payments it witnesses and the ones the admin asserts. /// @param _cap Maximum price in USD cents, or zero to follow the registry function setPriceCapUSDCents(uint64 _cap) external onlyDeviceWallet { priceCapUSDCents = _cap; @@ -311,9 +293,11 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// knows the price. Any shortfall is pulled from the device wallet, which is why the price /// is checked against a ceiling the admin cannot raise. /// - /// The recorded price is in cents and the money that moves is in wei, and no contract here - /// relates the two. `_priceWei` is therefore checked against its own ceiling rather than - /// derived from the cents figure. Both it and that ceiling go when the ETH path does. + /// The ceiling is in cents and the money that moves is in wei, and with no rate onchain + /// nothing here relates the two. So the ceiling bounds what may be recorded, not what may + /// be sent, and `_priceWei` is the admin's figure taken as given. That gap closes when the + /// ETH path is replaced by the token path, where the amount is derived from the price + /// rather than stated alongside it. /// @param _dataBundleDetail Data bundle being bought. Its settlement field is ignored: this /// function is the only one that can prove the money moved, so it fills that in itself. /// @param _priceWei ETH actually being sent to the vault @@ -330,8 +314,7 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); if(_dataBundleDetail.priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); if(_priceWei == 0) revert Errors.ZeroDataBundlePrice(); - _requirePriceWithinCap(_priceWei, registry); - _requireCentsPriceWithinCap(_dataBundleDetail.priceUSDCents, registry); + _requirePriceWithinCap(_dataBundleDetail.priceUSDCents, registry); // The one path where the protocol sees the money reach the vault, so it is the one path // allowed to say so. Overwritten rather than validated: the caller has no say either way. @@ -414,11 +397,6 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade deviceWallet = DeviceWallet(payable(newOwner)); // Written only on a change, so a wallet that never set a ceiling emits nothing here - if(dataBundlePriceCap != 0) { - dataBundlePriceCap = 0; - emit DataBundlePriceCapUpdated(0); - } - if(priceCapUSDCents != 0) { priceCapUSDCents = 0; emit PriceCapUSDCentsUpdated(0); @@ -450,36 +428,21 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade } } + /// @notice Rejects a price above whichever ceiling applies to this wallet /// @dev The wallet's own ceiling wins when it has one. Zero here means "follow the registry", /// not "no ceiling": the registry default is guaranteed non-zero by `Registry.initialize` - /// and `setDefaultDataBundlePriceCap`, so `cap` always resolves to a real ceiling. - /// @param _price Price being charged - /// @param _registry Registry holding the fallback ceiling - function _requirePriceWithinCap(uint256 _price, Registry _registry) private view { - uint256 cap = dataBundlePriceCap; - if (cap == 0) { - cap = _registry.defaultDataBundlePriceCap(); - } - - if (cap != 0 && _price > cap) { - revert Errors.DataBundlePriceAboveCap(_price, cap); - } - } - - /// @notice Rejects a cents price above whichever ceiling applies to this wallet - /// @dev Same fallback rule as the wei check above. Zero on the wallet means "follow the - /// registry", and the registry default is guaranteed non-zero by its own setters. + /// and `setDefaultPriceCapUSDCents`, so `cap` always resolves to a real ceiling. /// @param _priceUSDCents Price being charged, in USD cents /// @param _registry Registry holding the fallback ceiling - function _requireCentsPriceWithinCap(uint64 _priceUSDCents, Registry _registry) private view { + function _requirePriceWithinCap(uint64 _priceUSDCents, Registry _registry) private view { uint64 cap = priceCapUSDCents; if (cap == 0) { cap = _registry.defaultPriceCapUSDCents(); } if (cap != 0 && _priceUSDCents > cap) { - revert Errors.DataBundlePriceAboveCentsCap(_priceUSDCents, cap); + revert Errors.DataBundlePriceAboveCap(_priceUSDCents, cap); } } diff --git a/env.sample b/env.sample index b9a8c7a9..9c6abec6 100644 --- a/env.sample +++ b/env.sample @@ -32,12 +32,8 @@ TIMELOCK_GUARDIANS= # every proposer can already cancel. TIMELOCK_CANCELLERS= -# Ceiling on a single data bundle purchase, in wei. Guards the ETH path only, and goes when that -# path does. Rejected if zero: the registry reads zero as no ceiling at all. -DATA_BUNDLE_PRICE_CAP= - -# Ceiling on a single data bundle purchase, in USD cents. 100000 reads as $1000. This is the one -# that bounds every price the protocol records. Rejected if zero, same reason as above. +# Ceiling on a single data bundle purchase, in USD cents. 100000 reads as $1000. Rejected if zero: +# the registry reads zero as no ceiling at all. PRICE_CAP_USD_CENTS= # Currency the vault is meant to end up holding, normally USDC on the target chain. diff --git a/scripts/deploy/Configure.s.sol b/scripts/deploy/Configure.s.sol index 760a5de4..6e93156a 100644 --- a/scripts/deploy/Configure.s.sol +++ b/scripts/deploy/Configure.s.sol @@ -48,7 +48,7 @@ contract Configure is Script { _bindRegistryToFactory("DeviceWalletFactory", deviceWalletFactory, registryAddress); _bindRegistryToFactory("ESIMWalletFactory", eSIMWalletFactory, registryAddress); _bindLazyWalletRegistry(registryAddress, lazyWalletRegistry); - _setPriceCap(registryAddress, config.dataBundlePriceCap); + _setPriceCap(registryAddress, config.priceCapUSDCents); vm.stopBroadcast(); @@ -95,14 +95,14 @@ contract Configure is Script { /// @notice Rotates the fallback ceiling on what an eSIM wallet may be charged for a data bundle /// @dev `Registry.initialize` already required a non-zero cap, so this only ever handles a /// later change to it. Zero is not a legal configuration at any point after deployment - /// either: `setDefaultDataBundlePriceCap` refuses it the same way `initialize` does. - function _setPriceCap(address registryAddress, uint256 cap) private { - if(Registry(registryAddress).defaultDataBundlePriceCap() == cap) { + /// either: `setDefaultPriceCapUSDCents` refuses it the same way `initialize` does. + function _setPriceCap(address registryAddress, uint64 cap) private { + if(Registry(registryAddress).defaultPriceCapUSDCents() == cap) { console.log("Registry: price cap already set, skipping"); return; } - Registry(registryAddress).setDefaultDataBundlePriceCap(cap); + Registry(registryAddress).setDefaultPriceCapUSDCents(cap); console.log("Registry: price cap set to", cap); } diff --git a/scripts/deploy/Deploy.s.sol b/scripts/deploy/Deploy.s.sol index f0c35b7f..69b7fcde 100644 --- a/scripts/deploy/Deploy.s.sol +++ b/scripts/deploy/Deploy.s.sol @@ -148,7 +148,6 @@ contract Deploy is Script { deployed.deviceWalletFactory, deployed.eSIMWalletFactory, config.entryPoint, - config.dataBundlePriceCap, config.priceCapUSDCents ) ) @@ -285,7 +284,6 @@ contract Deploy is Script { { vm.serializeAddress("params", "eSIMWalletAdmin", config.eSIMWalletAdmin); vm.serializeAddress("params", "vault", config.vault); - vm.serializeUint("params", "dataBundlePriceCap", config.dataBundlePriceCap); vm.serializeUint("params", "priceCapUSDCents", config.priceCapUSDCents); section = vm.serializeAddress("params", "settlementToken", config.settlementToken); } diff --git a/scripts/deploy/config/DeployConfig.sol b/scripts/deploy/config/DeployConfig.sol index 41d86f76..05ab07d8 100644 --- a/scripts/deploy/config/DeployConfig.sol +++ b/scripts/deploy/config/DeployConfig.sol @@ -55,7 +55,6 @@ library DeployConfig { IEntryPoint entryPoint; address eSIMWalletAdmin; address vault; - uint256 dataBundlePriceCap; uint64 priceCapUSDCents; address settlementToken; address[] proposers; @@ -98,10 +97,6 @@ library DeployConfig { // Required, and never zero: `Registry.initialize` refuses a zero cap, since zero would // read as "no ceiling" for every wallet that never sets its own. - config.dataBundlePriceCap = vm.envUint("DATA_BUNDLE_PRICE_CAP"); - if(config.dataBundlePriceCap == 0) revert MissingValue("DATA_BUNDLE_PRICE_CAP"); - - // Same rule for the cents ceiling, which is the one that bounds every recorded price. config.priceCapUSDCents = uint64(vm.envUint("PRICE_CAP_USD_CENTS")); if(config.priceCapUSDCents == 0) revert MissingValue("PRICE_CAP_USD_CENTS"); diff --git a/test/foundry/fuzz-testing/AdminOperations.t.sol b/test/foundry/fuzz-testing/AdminOperations.t.sol index 44bf8b2b..b920f771 100644 --- a/test/foundry/fuzz-testing/AdminOperations.t.sol +++ b/test/foundry/fuzz-testing/AdminOperations.t.sol @@ -24,7 +24,7 @@ contract AdminOperationsFuzzTest is AdminBase { function testFuzz_schedule_turnsAwayEveryAccountOutsideTheProposerSet(address _caller) public { vm.assume(_caller != proposer && _caller != coldProposer); - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 role = protocolAdmin.PROPOSER_ROLE(); vm.prank(_caller); @@ -64,13 +64,13 @@ contract AdminOperationsFuzzTest is AdminBase { /// @notice Execution is open to every account once the wait is served /// forge-config: default.fuzz.runs = 1000 - function testFuzz_execute_acceptsAnyCallerOnceTheDelayHasPassed(address _caller, uint96 _cap) public { + function testFuzz_execute_acceptsAnyCallerOnceTheDelayHasPassed(address _caller, uint64 _cap) public { // A precompile or the cheatcode address as msg.sender is a test harness artefact vm.assume(uint160(_caller) > 0x0a); vm.assume(_caller != address(vm)); - uint96 cap = uint96(bound(_cap, 1, type(uint96).max)); - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (cap)); + uint64 cap = uint64(bound(_cap, 1, type(uint64).max)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (cap)); _schedule(address(registry), data, bytes32(0)); vm.warp(block.timestamp + DELAY); @@ -78,7 +78,7 @@ contract AdminOperationsFuzzTest is AdminBase { vm.prank(_caller); protocolAdmin.execute(address(registry), 0, data, bytes32(0), bytes32(0)); - assertEq(registry.defaultDataBundlePriceCap(), cap); + assertEq(registry.defaultPriceCapUSDCents(), cap); } /// @notice An operation is executable at its ready time and not one second before @@ -87,7 +87,7 @@ contract AdminOperationsFuzzTest is AdminBase { uint256 delay = bound(_delay, protocolAdmin.getMinDelay(), 365 days); uint256 wait = bound(_wait, 0, delay + 1 days); - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); vm.prank(proposer); protocolAdmin.schedule(address(registry), 0, data, bytes32(0), bytes32(0), delay); @@ -102,7 +102,7 @@ contract AdminOperationsFuzzTest is AdminBase { } else { vm.prank(outsider); protocolAdmin.execute(address(registry), 0, data, bytes32(0), bytes32(0)); - assertEq(registry.defaultDataBundlePriceCap(), 1 ether); + assertEq(registry.defaultPriceCapUSDCents(), 1 ether); } } @@ -110,7 +110,7 @@ contract AdminOperationsFuzzTest is AdminBase { /// forge-config: default.fuzz.runs = 1000 function testFuzz_schedule_acceptsExactlyTheDelaysAtOrAboveTheFloor(uint256 _delay) public { uint256 delay = bound(_delay, 0, 30 days); - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); uint256 floor = protocolAdmin.getMinDelay(); vm.prank(proposer); @@ -140,7 +140,7 @@ contract AdminOperationsFuzzTest is AdminBase { /// @notice A salt separates two otherwise identical operations, and only a repeated one collides /// forge-config: default.fuzz.runs = 512 function testFuzz_salt_separatesOtherwiseIdenticalOperations(bytes32 _first, bytes32 _second) public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); _schedule(address(registry), data, _first); @@ -154,13 +154,13 @@ contract AdminOperationsFuzzTest is AdminBase { /// @notice Whatever the batch looks like, it applies whole or not at all /// forge-config: default.fuzz.runs = 512 - function testFuzz_executeBatch_appliesEveryCapInOrder(uint96[] calldata _caps) public { + function testFuzz_executeBatch_appliesEveryCapInOrder(uint64[] calldata _caps) public { vm.assume(_caps.length > 0); uint256 count = _caps.length < MAX_FUZZED_BATCH ? _caps.length : MAX_FUZZED_BATCH; - uint96[] memory caps = new uint96[](count); + uint64[] memory caps = new uint64[](count); for(uint256 i = 0; i < count; ++i) { - caps[i] = uint96(bound(_caps[i], 1, type(uint96).max)); + caps[i] = uint64(bound(_caps[i], 1, type(uint64).max)); } (address[] memory targets, uint256[] memory values, bytes[] memory payloads) = @@ -174,7 +174,7 @@ contract AdminOperationsFuzzTest is AdminBase { vm.prank(outsider); protocolAdmin.executeBatch(targets, values, payloads, bytes32(0), bytes32(0)); - assertEq(registry.defaultDataBundlePriceCap(), caps[count - 1], "the last call must win"); + assertEq(registry.defaultPriceCapUSDCents(), caps[count - 1], "the last call must win"); } /// @notice Nothing a guardian names loses anything but the cancel power @@ -207,12 +207,12 @@ contract AdminOperationsFuzzTest is AdminBase { /// @notice The guardian's unpause can never reach a second selector on its target /// forge-config: default.fuzz.runs = 512 - function testFuzz_unpauseInstantly_leavesEveryOtherRegistrySettingAlone(uint96 _cap) public { - uint96 cap = uint96(bound(_cap, 1, type(uint96).max)); + function testFuzz_unpauseInstantly_leavesEveryOtherRegistrySettingAlone(uint64 _cap) public { + uint64 cap = uint64(bound(_cap, 1, type(uint64).max)); _runThroughTheDelay( address(registry), - abi.encodeCall(registry.setDefaultDataBundlePriceCap, (cap)), + abi.encodeCall(registry.setDefaultPriceCapUSDCents, (cap)), bytes32(0) ); @@ -223,7 +223,7 @@ contract AdminOperationsFuzzTest is AdminBase { protocolAdmin.unpauseInstantly(address(registry)); assertFalse(registry.paused()); - assertEq(registry.defaultDataBundlePriceCap(), cap, "only the pause may have moved"); + assertEq(registry.defaultPriceCapUSDCents(), cap, "only the pause may have moved"); assertEq(registry.owner(), address(protocolAdmin)); } @@ -243,7 +243,7 @@ contract AdminOperationsFuzzTest is AdminBase { /// @dev Split out because building the arrays and asserting on them together runs the stack up. function _capBatch( - uint96[] memory _caps, + uint64[] memory _caps, uint256 _count ) private view returns (address[] memory targets, uint256[] memory values, bytes[] memory payloads) { targets = new address[](_count); @@ -252,7 +252,7 @@ contract AdminOperationsFuzzTest is AdminBase { for(uint256 i = 0; i < _count; ++i) { targets[i] = address(registry); - payloads[i] = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (_caps[i])); + payloads[i] = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (_caps[i])); } } } diff --git a/test/foundry/fuzz-testing/ETHAmounts.t.sol b/test/foundry/fuzz-testing/ETHAmounts.t.sol index 01227526..b1401dfb 100644 --- a/test/foundry/fuzz-testing/ETHAmounts.t.sol +++ b/test/foundry/fuzz-testing/ETHAmounts.t.sol @@ -15,15 +15,21 @@ import {FuzzBase} from "test/foundry/fuzz-testing/base/FuzzBase.sol"; /// price cap is honoured whichever of its two levels supplies it. And an amount larger than /// the balance behind it fails rather than moving a partial amount. /// +/// The ceiling is in cents and the ETH that moves is in wei, so the two are fuzzed +/// independently. Nothing onchain relates them without a rate. +/// /// The cap resolution is the part worth fuzzing rather than enumerating: the wallet's own cap /// wins when set, and the registry default applies when it is not. Zero on the wallet still /// means "follow the registry", but the registry itself can never hold zero: `initialize` and -/// `setDefaultDataBundlePriceCap` both refuse it, so a real ceiling always applies somewhere. +/// `setDefaultPriceCapUSDCents` both refuse it, so a real ceiling always applies somewhere. contract ETHAmountsTest is FuzzBase { /// @dev Keeps fuzzed amounts inside what vm.deal can fund without the totals overflowing uint256 private constant MAX_FUZZED_ETH = 1_000_000 ether; + /// @dev $10,000,000 in cents. Large enough to exercise the ceiling, small enough to read. + uint64 private constant MAX_FUZZED_CENTS = 1_000_000_000; + function setUp() public override { super.setUp(); _deployFuzzWallets(); @@ -47,16 +53,16 @@ contract ETHAmountsTest is FuzzBase { uint256 deviceBalance = bound(_deviceBalance, price, MAX_FUZZED_ETH); // This test is about conservation of ETH on a successful purchase, not cap behaviour, so - // the wallet's own cap is raised above the fuzzed price range and out of the way. + // the wallet's own cap is raised out of the way. vm.prank(address(fuzzDeviceWallet)); - fuzzESIMWallet.setDataBundlePriceCap(MAX_FUZZED_ETH); + fuzzESIMWallet.setPriceCapUSDCents(MAX_FUZZED_CENTS); vm.deal(address(fuzzESIMWallet), walletBalance); vm.deal(address(fuzzDeviceWallet), deviceBalance); uint256 vaultBefore = vault.balance; vm.prank(eSIMWalletAdmin); - fuzzESIMWallet.buyDataBundle(DataBundleDetails("DB_FUZZ", price)); + fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", TEST_PRICE_CENTS), price, nextRef()); assertEq(vault.balance - vaultBefore, price, "The vault must receive exactly the price"); assertEq( @@ -70,19 +76,19 @@ contract ETHAmountsTest is FuzzBase { /// @dev The revert has to carry both numbers, because the caller cannot otherwise tell which of /// the two levels refused it. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_refusesAPriceAboveTheWalletCap(uint256 _cap, uint256 _price) public { - uint256 cap = bound(_cap, 1, MAX_FUZZED_ETH - 1); - uint256 price = bound(_price, cap + 1, MAX_FUZZED_ETH); + function testFuzz_buyDataBundle_refusesAPriceAboveTheWalletCap(uint64 _cap, uint64 _price) public { + uint64 cap = uint64(bound(_cap, 1, MAX_FUZZED_CENTS - 1)); + uint64 price = uint64(bound(_price, uint256(cap) + 1, MAX_FUZZED_CENTS)); vm.prank(address(fuzzDeviceWallet)); - fuzzESIMWallet.setDataBundlePriceCap(cap); + fuzzESIMWallet.setPriceCapUSDCents(cap); vm.deal(address(fuzzDeviceWallet), MAX_FUZZED_ETH); uint256 vaultBefore = vault.balance; vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, cap)); - fuzzESIMWallet.buyDataBundle(DataBundleDetails("DB_FUZZ", price)); + fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); assertEq(vault.balance, vaultBefore, "A refused purchase must move no ETH"); } @@ -92,20 +98,20 @@ contract ETHAmountsTest is FuzzBase { /// levels are read in the right order rather than the default being ignored once a wallet /// exists. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_fallsBackToTheRegistryCap(uint256 _cap, uint256 _price) public { - uint256 cap = bound(_cap, 1, MAX_FUZZED_ETH - 1); - uint256 price = bound(_price, cap + 1, MAX_FUZZED_ETH); + function testFuzz_buyDataBundle_fallsBackToTheRegistryCap(uint64 _cap, uint64 _price) public { + uint64 cap = uint64(bound(_cap, 1, MAX_FUZZED_CENTS - 1)); + uint64 price = uint64(bound(_price, uint256(cap) + 1, MAX_FUZZED_CENTS)); vm.prank(registry.owner()); - registry.setDefaultDataBundlePriceCap(cap); + registry.setDefaultPriceCapUSDCents(cap); - assertEq(fuzzESIMWallet.dataBundlePriceCap(), 0, "The wallet must have no cap of its own"); + assertEq(fuzzESIMWallet.priceCapUSDCents(), 0, "The wallet must have no cap of its own"); vm.deal(address(fuzzDeviceWallet), MAX_FUZZED_ETH); vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, cap)); - fuzzESIMWallet.buyDataBundle(DataBundleDetails("DB_FUZZ", price)); + fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); } /// @notice The wallet's own cap wins over the registry default, in both directions @@ -113,25 +119,25 @@ contract ETHAmountsTest is FuzzBase { /// that took the minimum of the two would pass a test that only ever set the wallet /// tighter, and would silently cap wallets the device owner meant to raise. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_theWalletCapOverridesTheDefault(uint256 _walletCap, uint256 _price) public { - uint256 walletCap = bound(_walletCap, 2, MAX_FUZZED_ETH); - uint256 price = bound(_price, 1, walletCap); + function testFuzz_buyDataBundle_theWalletCapOverridesTheDefault(uint64 _walletCap, uint64 _price) public { + uint64 walletCap = uint64(bound(_walletCap, 2, MAX_FUZZED_CENTS)); + uint64 price = uint64(bound(_price, 1, walletCap)); // The registry default is set tighter than the price, so a purchase that succeeds proves // the wallet's own cap is what was read vm.prank(registry.owner()); - registry.setDefaultDataBundlePriceCap(1); + registry.setDefaultPriceCapUSDCents(1); vm.prank(address(fuzzDeviceWallet)); - fuzzESIMWallet.setDataBundlePriceCap(walletCap); + fuzzESIMWallet.setPriceCapUSDCents(walletCap); vm.deal(address(fuzzDeviceWallet), MAX_FUZZED_ETH); uint256 vaultBefore = vault.balance; vm.prank(eSIMWalletAdmin); - fuzzESIMWallet.buyDataBundle(DataBundleDetails("DB_FUZZ", price)); + fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); - assertEq(vault.balance - vaultBefore, price, "The wallet's own cap must be the one that applies"); + assertEq(vault.balance - vaultBefore, 1 ether, "The wallet's own cap must be the one that applies"); } /// @notice A price within the registry default set at deployment succeeds without either @@ -139,13 +145,13 @@ contract ETHAmountsTest is FuzzBase { /// @dev The wallet-level zero still means "follow the registry", but the registry itself can /// never hold zero, so this is the uncapped-looking path that is actually always bounded. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_withinTheDeployTimeDefaultSucceeds(uint256 _price) public { - uint256 price = bound(_price, 1, defaultDataBundlePriceCap); + function testFuzz_buyDataBundle_withinTheDeployTimeDefaultSucceeds(uint64 _price) public { + uint64 price = uint64(bound(_price, 1, defaultPriceCapUSDCents)); - assertEq(fuzzESIMWallet.dataBundlePriceCap(), 0, "The wallet must have no cap of its own"); + assertEq(fuzzESIMWallet.priceCapUSDCents(), 0, "The wallet must have no cap of its own"); assertEq( - registry.defaultDataBundlePriceCap(), - defaultDataBundlePriceCap, + registry.defaultPriceCapUSDCents(), + defaultPriceCapUSDCents, "The registry must hold the cap it was deployed with" ); @@ -153,9 +159,9 @@ contract ETHAmountsTest is FuzzBase { uint256 vaultBefore = vault.balance; vm.prank(eSIMWalletAdmin); - fuzzESIMWallet.buyDataBundle(DataBundleDetails("DB_FUZZ", price)); + fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); - assertEq(vault.balance - vaultBefore, price, "A price within the default must not be refused"); + assertEq(vault.balance - vaultBefore, 1 ether, "A price within the default must not be refused"); } /// @notice Pulling more than the device wallet holds fails and moves nothing diff --git a/test/foundry/fuzz-testing/IdentifiersAndArrays.t.sol b/test/foundry/fuzz-testing/IdentifiersAndArrays.t.sol index a4983bf5..465e7385 100644 --- a/test/foundry/fuzz-testing/IdentifiersAndArrays.t.sol +++ b/test/foundry/fuzz-testing/IdentifiersAndArrays.t.sol @@ -39,7 +39,7 @@ contract IdentifiersAndArraysTest is FuzzBase { string memory deviceIdentifier = _stringOfLength(deviceLength); string memory eSIMIdentifier = _stringOfLength(eSIMLength); - _populateOne(deviceIdentifier, eSIMIdentifier, 1 ether); + _populateOne(deviceIdentifier, eSIMIdentifier, TEST_PRICE_CENTS); assertEq( lazyWalletRegistry.eSIMIdentifierToDeviceIdentifier(eSIMIdentifier), @@ -64,7 +64,7 @@ contract IdentifiersAndArraysTest is FuzzBase { vm.expectRevert( abi.encodeWithSelector(Errors.IdentifierTooLong.selector, deviceIdentifier, MAX_IDENTIFIER_LENGTH) ); - _populateOne(deviceIdentifier, "ESIM_FUZZ", 1 ether); + _populateOne(deviceIdentifier, "ESIM_FUZZ", TEST_PRICE_CENTS); assertEq( lazyWalletRegistry.getESIMIdentifiersAssociatedWithDeviceIdentifier(deviceIdentifier).length, @@ -84,7 +84,7 @@ contract IdentifiersAndArraysTest is FuzzBase { vm.expectRevert( abi.encodeWithSelector(Errors.IdentifierTooLong.selector, eSIMIdentifier, MAX_IDENTIFIER_LENGTH) ); - _populateOne("DEVICE_FUZZ", eSIMIdentifier, 1 ether); + _populateOne("DEVICE_FUZZ", eSIMIdentifier, TEST_PRICE_CENTS); assertEq( lazyWalletRegistry.eSIMIdentifierToDeviceIdentifier(eSIMIdentifier), @@ -98,10 +98,10 @@ contract IdentifiersAndArraysTest is FuzzBase { /// every unbound eSIM identifier to the same entry. function test_populateHistory_refusesEmptyIdentifiers() public { vm.expectRevert(Errors.EmptyDeviceIdentifier.selector); - _populateOne("", "ESIM_FUZZ", 1 ether); + _populateOne("", "ESIM_FUZZ", TEST_PRICE_CENTS); vm.expectRevert(Errors.EmptyESIMIdentifier.selector); - _populateOne("DEVICE_FUZZ", "", 1 ether); + _populateOne("DEVICE_FUZZ", "", TEST_PRICE_CENTS); } /// @notice The three top-level arrays must agree in length @@ -132,7 +132,7 @@ contract IdentifiersAndArraysTest is FuzzBase { DataBundleDetails[][] memory details = new DataBundleDetails[][](detailCount); for (uint256 i = 0; i < detailCount; ++i) { details[i] = new DataBundleDetails[](1); - details[i][0] = DataBundleDetails("DB_FUZZ", 1 ether); + details[i][0] = bundle("DB_FUZZ", TEST_PRICE_CENTS); } // The device count is checked against the eSIM lists first, so that pair is what the error @@ -170,7 +170,7 @@ contract IdentifiersAndArraysTest is FuzzBase { DataBundleDetails[][] memory details = new DataBundleDetails[][](1); details[0] = new DataBundleDetails[](detailCount); for (uint256 i = 0; i < detailCount; ++i) { - details[0][i] = DataBundleDetails("DB_FUZZ", 1 ether); + details[0][i] = bundle("DB_FUZZ", TEST_PRICE_CENTS); } vm.prank(eSIMWalletAdmin); @@ -235,7 +235,7 @@ contract IdentifiersAndArraysTest is FuzzBase { DataBundleDetails[][] memory details = new DataBundleDetails[][](1); details[0] = new DataBundleDetails[](1); - details[0][0] = DataBundleDetails("DB_FUZZ", 1 ether); + details[0][0] = bundle("DB_FUZZ", TEST_PRICE_CENTS); vm.prank(eSIMWalletAdmin); try lazyWalletRegistry.batchPopulateHistory(devices, eSIMs, details) { @@ -271,7 +271,7 @@ contract IdentifiersAndArraysTest is FuzzBase { function _populateOne( string memory _deviceIdentifier, string memory _eSIMIdentifier, - uint256 _price + uint64 _priceUSDCents ) private { string[] memory devices = new string[](1); devices[0] = _deviceIdentifier; @@ -282,7 +282,7 @@ contract IdentifiersAndArraysTest is FuzzBase { DataBundleDetails[][] memory details = new DataBundleDetails[][](1); details[0] = new DataBundleDetails[](1); - details[0][0] = DataBundleDetails("DB_FUZZ", _price); + details[0][0] = bundle("DB_FUZZ", _priceUSDCents); vm.prank(eSIMWalletAdmin); lazyWalletRegistry.batchPopulateHistory(devices, eSIMs, details); diff --git a/test/foundry/fuzz-testing/LazyDeployBatching.t.sol b/test/foundry/fuzz-testing/LazyDeployBatching.t.sol index 8fa9448b..9af3714b 100644 --- a/test/foundry/fuzz-testing/LazyDeployBatching.t.sol +++ b/test/foundry/fuzz-testing/LazyDeployBatching.t.sol @@ -226,7 +226,7 @@ contract LazyDeployBatchingTest is FuzzBase { for(uint256 i=0; i<_count; ++i) { eSIMs[0][i] = _eSIMName(i); - bundles[0][i] = DataBundleDetails("DB_BATCH", 1); + bundles[0][i] = bundle("DB_BATCH", TEST_PRICE_CENTS); } vm.prank(eSIMWalletAdmin); diff --git a/test/foundry/fuzz-testing/LazyHistoryCopy.t.sol b/test/foundry/fuzz-testing/LazyHistoryCopy.t.sol index 12c8695d..e3bc269f 100644 --- a/test/foundry/fuzz-testing/LazyHistoryCopy.t.sol +++ b/test/foundry/fuzz-testing/LazyHistoryCopy.t.sol @@ -57,8 +57,8 @@ contract LazyHistoryCopyTest is FuzzBase { assertEq(inWallet.length, entries, "The wallet must hold every entry and no more"); for(uint256 i=0; i Date: Fri, 28 Aug 2026 14:23:44 +0530 Subject: [PATCH 06/75] Move the test suite onto cents, bytes32 ids and payment references 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. --- .../gas/ESIMWallet.Operations.gas.t.sol | 16 +- .../gas/ProtocolAdmin.Operations.gas.t.sol | 8 +- .../foundry/gas/Registry.Operations.gas.t.sol | 6 +- test/foundry/gas/base/GasBase.sol | 2 +- .../invariant-testing/AdminInvariants.t.sol | 2 +- .../HandlerDistribution.t.sol | 2 +- .../invariant-testing/LazyInvariants.t.sol | 4 +- .../invariant-testing/base/InvariantBase.sol | 28 +++- .../handler/AdminHandler.sol | 43 +++-- .../invariant-testing/handler/HandlerBase.sol | 2 +- .../handler/ProtocolAdminHandler.sol | 20 +-- .../handler/UpgradeManagerHandler.sol | 10 +- .../handler/WalletHandler.sol | 6 +- .../unit-testing/admin/GuardianPowers.t.sol | 8 +- .../admin/OwnershipHandover.t.sol | 4 +- .../unit-testing/admin/ProtocolAdmin.t.sol | 36 ++--- .../unit-testing/admin/RoleRecovery.t.sol | 4 +- .../DeviceWalletESIMWallets.t.sol | 26 ++- .../device-wallet/DeviceWalletETH.t.sol | 60 +++---- .../unit-testing/esim-wallet/ESIMWallet.t.sol | 148 +++++++++--------- .../esim-wallet/ESIMWalletGuards.t.sol | 29 ++-- .../unit-testing/registry/AdminRotation.t.sol | 8 +- .../registry/IdentifierCollision.t.sol | 4 +- .../registry/LazySaltCollision.t.sol | 2 +- .../registry/LazyWalletRegistry.t.sol | 22 +-- .../registry/LazyWalletRegistryGuards.t.sol | 2 +- .../unit-testing/registry/Registry.t.sol | 16 +- .../registry/RegistryGuards.t.sol | 2 +- .../registry/RegistryInitialize.t.sol | 10 +- .../unit-testing/upgrade/StorageLayout.t.sol | 2 +- 30 files changed, 279 insertions(+), 253 deletions(-) diff --git a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol index 05cc6bea..e3cf623e 100644 --- a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol +++ b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol @@ -46,12 +46,12 @@ contract ESIMWalletOperationsGasTest is GasBase { vm.deal(address(eSIMWallet), 10 ether); vm.prank(address(wallet)); - eSIMWallet.buyDataBundle(DataBundleDetails("DB_GAS_1", 1 ether)); + eSIMWallet.buyDataBundle(bundle("DB_GAS_1", TEST_PRICE_CENTS), 1 ether, paymentRef("gas-1")); vm.snapshotGasLastCall(NAMESPACE, "buyDataBundle: wallet already holds the price"); vm.deal(address(eSIMWallet), 0); vm.prank(address(wallet)); - eSIMWallet.buyDataBundle(DataBundleDetails("DB_GAS_2", 1 ether)); + eSIMWallet.buyDataBundle(bundle("DB_GAS_2", TEST_PRICE_CENTS), 1 ether, paymentRef("gas-2")); vm.snapshotGasLastCall(NAMESPACE, "buyDataBundle: pulls from the device wallet"); } @@ -69,16 +69,16 @@ contract ESIMWalletOperationsGasTest is GasBase { } /// @notice Moving the wallet's own price ceiling, and handing it back to the registry's - function test_setDataBundlePriceCap() public { + function test_setPriceCapUSDCents() public { _deploy(); vm.prank(address(wallet)); - eSIMWallet.setDataBundlePriceCap(5 ether); - vm.snapshotGasLastCall(NAMESPACE, "setDataBundlePriceCap: set"); + eSIMWallet.setPriceCapUSDCents(5 ether); + vm.snapshotGasLastCall(NAMESPACE, "setPriceCapUSDCents: set"); vm.prank(address(wallet)); - eSIMWallet.setDataBundlePriceCap(0); - vm.snapshotGasLastCall(NAMESPACE, "setDataBundlePriceCap: back to the registry ceiling"); + eSIMWallet.setPriceCapUSDCents(0); + vm.snapshotGasLastCall(NAMESPACE, "setPriceCapUSDCents: back to the registry ceiling"); } /// @notice The two halves of an eSIM wallet ownership transfer @@ -104,7 +104,7 @@ contract ESIMWalletOperationsGasTest is GasBase { DataBundleDetails[] memory history = new DataBundleDetails[](10); for(uint256 i = 0; i < history.length; ++i) { - history[i] = DataBundleDetails("DB_GAS_HISTORY", 1); + history[i] = bundle("DB_GAS_HISTORY", TEST_PRICE_CENTS); } vm.prank(address(registry)); diff --git a/test/foundry/gas/ProtocolAdmin.Operations.gas.t.sol b/test/foundry/gas/ProtocolAdmin.Operations.gas.t.sol index 503b4f73..ab047f5f 100644 --- a/test/foundry/gas/ProtocolAdmin.Operations.gas.t.sol +++ b/test/foundry/gas/ProtocolAdmin.Operations.gas.t.sol @@ -26,7 +26,7 @@ contract ProtocolAdminOperationsGasTest is AdminBase { /// @notice Announcing a change, one call and four function test_schedule() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); vm.prank(proposer); protocolAdmin.schedule(address(registry), 0, data, bytes32(0), bytes32(uint256(1)), DELAY); @@ -43,7 +43,7 @@ contract ProtocolAdminOperationsGasTest is AdminBase { /// @dev Held apart from the batch below because both land on the same registry slot. Measured /// in one body, whichever ran first would pay the cold write and make the other look free. function test_execute_oneCall() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); _schedule(address(registry), data, bytes32(uint256(1))); vm.warp(block.timestamp + DELAY); @@ -88,7 +88,7 @@ contract ProtocolAdminOperationsGasTest is AdminBase { /// @notice Calling off something already announced function test_cancel() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 id = _schedule(address(registry), data, bytes32(0)); vm.prank(proposer); @@ -177,7 +177,7 @@ contract ProtocolAdminOperationsGasTest is AdminBase { for(uint256 i = 0; i < 4; ++i) { targets[i] = address(registry); - payloads[i] = abi.encodeCall(registry.setDefaultDataBundlePriceCap, ((i + 1) * 1 ether)); + payloads[i] = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (uint64((i + 1) * 1_000))); } } diff --git a/test/foundry/gas/Registry.Operations.gas.t.sol b/test/foundry/gas/Registry.Operations.gas.t.sol index f0b821fa..42e08812 100644 --- a/test/foundry/gas/Registry.Operations.gas.t.sol +++ b/test/foundry/gas/Registry.Operations.gas.t.sol @@ -82,10 +82,10 @@ contract RegistryOperationsGasTest is GasBase { } /// @notice Moving the default price cap new eSIM wallets inherit - function test_setDefaultDataBundlePriceCap() public { + function test_setDefaultPriceCapUSDCents() public { vm.prank(upgradeManager); - registry.setDefaultDataBundlePriceCap(2 ether); - vm.snapshotGasLastCall(NAMESPACE, "setDefaultDataBundlePriceCap"); + registry.setDefaultPriceCapUSDCents(2 ether); + vm.snapshotGasLastCall(NAMESPACE, "setDefaultPriceCapUSDCents"); } /// @notice Pointing every data bundle payment at a different vault diff --git a/test/foundry/gas/base/GasBase.sol b/test/foundry/gas/base/GasBase.sol index f5c44644..3e7f46d3 100644 --- a/test/foundry/gas/base/GasBase.sol +++ b/test/foundry/gas/base/GasBase.sol @@ -104,7 +104,7 @@ abstract contract GasBase is DeployerBase { for(uint256 i = 0; i < _count; ++i) { eSIMs[0][i] = _eSIMName(_device, i); - bundles[0][i] = DataBundleDetails("DB_GAS", 1); + bundles[0][i] = bundle("DB_GAS", TEST_PRICE_CENTS); } for(uint256 pass = 0; pass < _entriesPerESIM; ++pass) { diff --git a/test/foundry/invariant-testing/AdminInvariants.t.sol b/test/foundry/invariant-testing/AdminInvariants.t.sol index 46bc33aa..43c74512 100644 --- a/test/foundry/invariant-testing/AdminInvariants.t.sol +++ b/test/foundry/invariant-testing/AdminInvariants.t.sol @@ -139,7 +139,7 @@ contract AdminInvariantsTest is AdminBase { /// bookkeeping; this one says the bookkeeping actually governs the protocol. function invariant_theRegistryOnlyReflectsCompletedOperations() public view { assertEq( - registry.defaultDataBundlePriceCap(), + registry.defaultPriceCapUSDCents(), handler.expectedCap(), "the registry holds a value no completed operation wrote" ); diff --git a/test/foundry/invariant-testing/HandlerDistribution.t.sol b/test/foundry/invariant-testing/HandlerDistribution.t.sol index ae5657ff..48b61dbd 100644 --- a/test/foundry/invariant-testing/HandlerDistribution.t.sol +++ b/test/foundry/invariant-testing/HandlerDistribution.t.sol @@ -73,7 +73,7 @@ contract HandlerDistributionTest is CampaignBase { adminHandler.setESIMIdentifier(round, seed + 3000, false); walletHandler.toggleAccessToETH(round, true); walletHandler.setESIMWalletPriceCap(round, round); - adminHandler.buyDataBundle(round, 1 gwei); + adminHandler.buyDataBundle(round, 100, 1 gwei); walletHandler.pullETH(round, 1 gwei); // Removal comes before the transfer pair on purpose. Requesting a transfer detaches // the wallet on its way through, so a removal after it has nothing left to remove. diff --git a/test/foundry/invariant-testing/LazyInvariants.t.sol b/test/foundry/invariant-testing/LazyInvariants.t.sol index 35260a78..08ff6fac 100644 --- a/test/foundry/invariant-testing/LazyInvariants.t.sol +++ b/test/foundry/invariant-testing/LazyInvariants.t.sol @@ -127,8 +127,8 @@ contract LazyInvariantsTest is CampaignBase { uint256 matched = 0; for (uint256 j = 0; j < inWallet.length && matched < copied; ++j) { if ( - _sameString(inWallet[j].dataBundleID, stored[matched].dataBundleID) - && inWallet[j].dataBundlePrice == stored[matched].dataBundlePrice + inWallet[j].id == stored[matched].id + && inWallet[j].priceUSDCents == stored[matched].priceUSDCents ) { ++matched; } diff --git a/test/foundry/invariant-testing/base/InvariantBase.sol b/test/foundry/invariant-testing/base/InvariantBase.sol index a947d803..b9104aa8 100644 --- a/test/foundry/invariant-testing/base/InvariantBase.sol +++ b/test/foundry/invariant-testing/base/InvariantBase.sol @@ -11,6 +11,7 @@ import "contracts/CustomStructs.sol"; import {P256Verifier} from "contracts/P256Verifier.sol"; import {DeviceWalletFactory} from "contracts/device-wallet/DeviceWalletFactory.sol"; import {ESIMWalletFactory} from "contracts/esim-wallet/ESIMWalletFactory.sol"; +import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; import "test/utils/mocks/MockEntryPoint.sol"; import "test/utils/mocks/MockRegistry.sol"; @@ -38,7 +39,10 @@ contract InvariantBase is Test { address internal constant UPGRADE_MANAGER = address(0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2); address internal constant VAULT = address(0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB); address internal constant ATTACKER = address(0xbADc0DE000000000000000000000000000000001); - uint256 internal constant DEFAULT_DATA_BUNDLE_PRICE_CAP = 1 ether; + uint64 internal constant DEFAULT_PRICE_CAP_USD_CENTS = 100_000; // $1000 + + /// @dev A stand-in for USDC. Nothing settles onchain yet, so it only has to be resolvable. + address internal constant SETTLEMENT_TOKEN = address(0x036CbD53842c5426634e7929541eC2318f3dCF7e); /// @notice The address the admin role rotates onto, and back off /// @dev Carries a budget of its own. Every admin path reads the role out of the registry, so @@ -57,6 +61,7 @@ contract InvariantBase is Test { ESIMWalletFactory internal eSIMWalletFactory; MockRegistry internal registry; MockLazyWalletRegistry internal lazyWalletRegistry; + PaymentAdapter internal paymentAdapter; /// @notice Deploys the system in the same order the deploy script does function _deployProtocol() internal { @@ -102,7 +107,7 @@ contract InvariantBase is Test { address(deviceWalletFactory), address(eSIMWalletFactory), IEntryPoint(address(entryPoint)), - DEFAULT_DATA_BUNDLE_PRICE_CAP + DEFAULT_PRICE_CAP_USD_CENTS ) ) ); @@ -115,8 +120,27 @@ contract InvariantBase is Test { ); lazyWalletRegistry = MockLazyWalletRegistry(address(lazyWalletRegistryProxy)); + PaymentAdapter paymentAdapterImpl = new PaymentAdapter(); + ERC1967Proxy paymentAdapterProxy = new ERC1967Proxy( + address(paymentAdapterImpl), + abi.encodeCall( + paymentAdapterImpl.initialize, + (address(registry), SETTLEMENT_TOKEN, UPGRADE_MANAGER) + ) + ); + paymentAdapter = PaymentAdapter(address(paymentAdapterProxy)); + vm.startPrank(UPGRADE_MANAGER); registry.addOrUpdateLazyWalletRegistryAddress(address(lazyWalletRegistry)); + registry.setPaymentAdapter(address(paymentAdapter)); + // A purchase spends a payment reference through the adapter, so a campaign with no + // currencies registered would see every purchase revert before it reached anything else. + paymentAdapter.registerAsset("USD", Asset({ + allowed: true, + isDollarUnit: true, + decimals: 2, + token: address(0) + })); deviceWalletFactory.addRegistryAddress(address(registry)); eSIMWalletFactory.addRegistryAddress(address(registry)); vm.stopPrank(); diff --git a/test/foundry/invariant-testing/handler/AdminHandler.sol b/test/foundry/invariant-testing/handler/AdminHandler.sol index de424382..26f24d51 100644 --- a/test/foundry/invariant-testing/handler/AdminHandler.sol +++ b/test/foundry/invariant-testing/handler/AdminHandler.sol @@ -16,6 +16,11 @@ import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; /// is checking is whether that reach stops where the contracts say it stops. contract AdminHandler is HandlerBase { + /// @dev A payment reference is spendable once protocol-wide, so every purchase in a run + /// needs a fresh one or the second call reverts on replay rather than on what is + /// being tested. + uint256 private _refNonce; + constructor(HandlerConfig memory config) HandlerBase(config) {} /// @notice The admin deploys a batch of device wallets, each with one eSIM wallet @@ -144,17 +149,21 @@ contract AdminHandler is HandlerBase { } /// @notice The admin charges an eSIM wallet for a data bundle - /// @dev The price is unbounded upward on purpose. The ceiling is the only thing standing - /// between the admin and a wallet's whole balance, so a run has to reach past it. + /// @dev Both figures are unbounded upward on purpose. The ceiling is the only thing standing + /// between the admin and a wallet's whole balance, so a run has to reach past it. They are + /// fuzzed separately because the ceiling is in cents and the ETH that moves is in wei, and + /// no contract relates the two. /// @param eSIMIndex Which eSIM wallet pays - /// @param price What it is charged - function buyDataBundle(uint256 eSIMIndex, uint256 price) external counted { + /// @param priceUSDCents What the purchase is recorded at + /// @param priceWei What the wallet actually sends the vault + function buyDataBundle(uint256 eSIMIndex, uint64 priceUSDCents, uint256 priceWei) external counted { address wallet = _pickESIMWallet(eSIMIndex); if (wallet == address(0)) { state.recordRevert("buyDataBundle"); return; } - price = bound(price, 1, 100 ether); + priceUSDCents = uint64(bound(priceUSDCents, 1, 1_000_000)); + priceWei = bound(priceWei, 1, 100 ether); address device = registry.isESIMWalletValid(wallet); uint256 vaultBefore = vault.balance; @@ -163,27 +172,33 @@ contract AdminHandler is HandlerBase { vm.prank(_currentAdmin()); try ESIMWallet(payable(wallet)).buyDataBundle( - DataBundleDetails({dataBundleID: "bundle", dataBundlePrice: price}) + DataBundleDetails({ + id: "bundle", + priceUSDCents: priceUSDCents, + settlement: Settlement.Fiat + }), + priceWei, + keccak256(abi.encode("admin-handler", ++_refNonce)) ) { // Asserted here rather than as an invariant because the ceiling is a property of the // charge and not of any state left behind. Both ceilings move during a run, so a // purchase that was inside the ceiling when it went through can sit above the one the // next invariant call would read - uint256 cap = ESIMWallet(payable(wallet)).dataBundlePriceCap(); - if (cap == 0) cap = registry.defaultDataBundlePriceCap(); + uint64 cap = ESIMWallet(payable(wallet)).priceCapUSDCents(); + if (cap == 0) cap = registry.defaultPriceCapUSDCents(); if (cap != 0) { - assertLe(price, cap, "A purchase went through above the ceiling that applied to it"); + assertLe(priceUSDCents, cap, "A purchase went through above the ceiling that applied to it"); } // The wallet pays out of its own balance and pulls the shortfall from the device // wallet, so neither balance alone says what a purchase cost. Their sum does, and it // has to fall by exactly what the vault gained assertEq( - vault.balance - vaultBefore, price, "The vault received something other than the price" + vault.balance - vaultBefore, priceWei, "The vault received something other than the price" ); assertEq( walletsBefore - (wallet.balance + device.balance), - price, + priceWei, "A purchase moved a different amount out of the wallets than it sent to the vault" ); assertEq( @@ -233,7 +248,11 @@ contract AdminHandler is HandlerBase { eSIMIdentifiers[0][i] = contest ? _contestedESIMIdentifier(seed + i) : _eSIMIdentifier(seed + i); - bundles[0][i] = DataBundleDetails({dataBundleID: "bundle", dataBundlePrice: 1 gwei}); + bundles[0][i] = DataBundleDetails({ + id: "bundle", + priceUSDCents: 100, + settlement: Settlement.Fiat + }); } vm.prank(_currentAdmin()); diff --git a/test/foundry/invariant-testing/handler/HandlerBase.sol b/test/foundry/invariant-testing/handler/HandlerBase.sol index 4bc7c610..bf41c358 100644 --- a/test/foundry/invariant-testing/handler/HandlerBase.sol +++ b/test/foundry/invariant-testing/handler/HandlerBase.sol @@ -221,7 +221,7 @@ abstract contract HandlerBase is Test { for (uint256 i = 0; i < entries.length; ++i) { fullDigest = - keccak256(abi.encode(fullDigest, entries[i].dataBundleID, entries[i].dataBundlePrice)); + keccak256(abi.encode(fullDigest, entries[i].id, entries[i].priceUSDCents)); if (i + 1 == recorded) prefixDigest = fullDigest; } diff --git a/test/foundry/invariant-testing/handler/ProtocolAdminHandler.sol b/test/foundry/invariant-testing/handler/ProtocolAdminHandler.sol index 92eb56cd..fd7cad7e 100644 --- a/test/foundry/invariant-testing/handler/ProtocolAdminHandler.sol +++ b/test/foundry/invariant-testing/handler/ProtocolAdminHandler.sol @@ -26,7 +26,7 @@ contract ProtocolAdminHandler is Test { bytes data; bytes32 salt; bytes32 id; - uint256 cap; + uint64 cap; } ProtocolAdmin public immutable admin; @@ -45,7 +45,7 @@ contract ProtocolAdminHandler is Test { /// @notice What the registry's ceiling should read, given every operation that has completed /// @dev Written only where an execution succeeded, so a value reaching the registry any other /// way pulls the two apart. - uint256 public expectedCap; + uint64 public expectedCap; uint256 public scheduled; uint256 public executed; @@ -68,10 +68,10 @@ contract ProtocolAdminHandler is Test { guardian = _guardian; walletAdmin = _walletAdmin; - // The registry never holds a zero cap: `initialize` and `setDefaultDataBundlePriceCap` + // The registry never holds a zero cap: `initialize` and `setDefaultPriceCapUSDCents` // both refuse it. Starting the tracker from whatever the registry was deployed with, rather // than from zero, is what keeps it matching a value no operation in this campaign wrote. - expectedCap = _registry.defaultDataBundlePriceCap(); + expectedCap = _registry.defaultPriceCapUSDCents(); for(uint256 i = 0; i < _cancellers.length; ++i) { cancellers.push(_cancellers[i]); @@ -91,8 +91,8 @@ contract ProtocolAdminHandler is Test { } /// @notice A proposer announces a change to the registry's price ceiling - function scheduleCap(uint96 _cap, bytes32 _salt) external { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (_cap)); + function scheduleCap(uint64 _cap, bytes32 _salt) external { + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (_cap)); uint256 delay = admin.getMinDelay(); vm.prank(proposer); @@ -190,10 +190,10 @@ contract ProtocolAdminHandler is Test { } /// @notice Nobody outside the proposer set may announce anything - function rejectsAnUnauthorisedSchedule(address _caller, uint96 _cap) external { + function rejectsAnUnauthorisedSchedule(address _caller, uint64 _cap) external { if(_caller == proposer) return; - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (_cap)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (_cap)); vm.prank(_caller); try admin.schedule(address(registry), 0, data, bytes32(0), bytes32(0), admin.getMinDelay()) { @@ -245,11 +245,11 @@ contract ProtocolAdminHandler is Test { } /// @notice Nobody may reach the registry without going through the admin contract - function rejectsADirectCallToTheRegistry(address _caller, uint96 _cap) external { + function rejectsADirectCallToTheRegistry(address _caller, uint64 _cap) external { if(_caller == address(admin)) return; vm.prank(_caller); - try registry.setDefaultDataBundlePriceCap(_cap) { + try registry.setDefaultPriceCapUSDCents(_cap) { revert("an account reached the registry without going through the admin contract"); } catch { ++rejections; diff --git a/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol b/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol index 50ffeda2..05ff12c2 100644 --- a/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol +++ b/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol @@ -101,16 +101,18 @@ contract UpgradeManagerHandler is HandlerBase { /// @notice The upgrade manager sets the ceiling wallets fall back to when they hold none /// @dev Picked off a ladder rather than bounded over a range, because a range wide enough to be /// interesting would rarely land on the values worth exercising. Zero is on the ladder on - /// purpose: `setDefaultDataBundlePriceCap` refuses it, so that entry is what exercises the + /// purpose: `setDefaultPriceCapUSDCents` refuses it, so that entry is what exercises the /// rejection rather than the ceiling. The top of the ladder sits above what the admin can /// charge, so the ceiling is sometimes binding and sometimes not. /// @param seed Chooses the ceiling function setDefaultPriceCap(uint256 seed) external counted { - uint256[4] memory ladder = [uint256(0), 1 gwei, 1 ether, 100 ether]; - uint256 cap = ladder[bound(seed, 0, 3)]; + // Zero is in the ladder deliberately: the registry refuses it, and a run has to reach + // that refusal rather than only ever offering values it accepts. + uint64[4] memory ladder = [uint64(0), 1, 1_000, 100_000_000]; + uint64 cap = ladder[bound(seed, 0, 3)]; vm.prank(upgradeManager); - try registry.setDefaultDataBundlePriceCap(cap) { + try registry.setDefaultPriceCapUSDCents(cap) { state.recordCall("setDefaultPriceCap"); } catch { state.recordRevert("setDefaultPriceCap"); diff --git a/test/foundry/invariant-testing/handler/WalletHandler.sol b/test/foundry/invariant-testing/handler/WalletHandler.sol index 75902099..164d5317 100644 --- a/test/foundry/invariant-testing/handler/WalletHandler.sol +++ b/test/foundry/invariant-testing/handler/WalletHandler.sol @@ -269,10 +269,12 @@ contract WalletHandler is HandlerBase { state.recordRevert("setESIMWalletPriceCap"); return; } - uint256[4] memory ladder = [uint256(0), 1 gwei, 1 ether, 100 ether]; + // Zero, one cent, ten dollars, a million dollars. Zero is in the ladder because it is + // the value that hands the wallet back to the registry ceiling. + uint64[4] memory ladder = [uint64(0), 1, 1_000, 100_000_000]; vm.prank(device); - try ESIMWallet(payable(wallet)).setDataBundlePriceCap(ladder[bound(seed, 0, 3)]) { + try ESIMWallet(payable(wallet)).setPriceCapUSDCents(ladder[bound(seed, 0, 3)]) { state.recordCall("setESIMWalletPriceCap"); } catch { state.recordRevert("setESIMWalletPriceCap"); diff --git a/test/foundry/unit-testing/admin/GuardianPowers.t.sol b/test/foundry/unit-testing/admin/GuardianPowers.t.sol index 7a32ba2e..d95cb2d3 100644 --- a/test/foundry/unit-testing/admin/GuardianPowers.t.sol +++ b/test/foundry/unit-testing/admin/GuardianPowers.t.sol @@ -265,7 +265,7 @@ contract GuardianPowersTest is AdminBase { } function test_guardian_cannotSchedule() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (4 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (4 ether)); uint256 delay = protocolAdmin.getMinDelay(); bytes32 role = protocolAdmin.PROPOSER_ROLE(); @@ -311,14 +311,14 @@ contract GuardianPowersTest is AdminBase { bytes32(uint256(1)) ); - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (4 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (4 ether)); _schedule(address(registry), data, bytes32(uint256(2))); vm.warp(block.timestamp + DELAY); vm.prank(guardian); protocolAdmin.execute(address(registry), 0, data, bytes32(0), bytes32(uint256(2))); - assertEq(registry.defaultDataBundlePriceCap(), 4 ether); + assertEq(registry.defaultPriceCapUSDCents(), 4 ether); } /// @dev With open execution closed, an ordinary execution needs the role again. @@ -331,7 +331,7 @@ contract GuardianPowersTest is AdminBase { bytes32(uint256(1)) ); - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (4 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (4 ether)); _schedule(address(registry), data, bytes32(uint256(2))); vm.warp(block.timestamp + DELAY); diff --git a/test/foundry/unit-testing/admin/OwnershipHandover.t.sol b/test/foundry/unit-testing/admin/OwnershipHandover.t.sol index fe51bdbb..06483039 100644 --- a/test/foundry/unit-testing/admin/OwnershipHandover.t.sol +++ b/test/foundry/unit-testing/admin/OwnershipHandover.t.sol @@ -126,8 +126,8 @@ contract OwnershipHandoverTest is AdminBase { assertEq(registry.owner(), user5); vm.prank(user5); - registry.setDefaultDataBundlePriceCap(6 ether); - assertEq(registry.defaultDataBundlePriceCap(), 6 ether); + registry.setDefaultPriceCapUSDCents(6 ether); + assertEq(registry.defaultPriceCapUSDCents(), 6 ether); } /// @dev None of the four will let their owner walk away, so a handover cannot end with nobody diff --git a/test/foundry/unit-testing/admin/ProtocolAdmin.t.sol b/test/foundry/unit-testing/admin/ProtocolAdmin.t.sol index ccf80304..431152d3 100644 --- a/test/foundry/unit-testing/admin/ProtocolAdmin.t.sol +++ b/test/foundry/unit-testing/admin/ProtocolAdmin.t.sol @@ -140,7 +140,7 @@ contract ProtocolAdminTest is AdminBase { } function test_schedule_rejectsAnAccountWithoutTheProposerRole() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 role = protocolAdmin.PROPOSER_ROLE(); @@ -153,7 +153,7 @@ contract ProtocolAdminTest is AdminBase { /// @dev A guardian skips the wait but cannot start one. Scheduling stays with the proposers. function test_schedule_rejectsTheGuardian() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 role = protocolAdmin.PROPOSER_ROLE(); @@ -165,7 +165,7 @@ contract ProtocolAdminTest is AdminBase { } function test_execute_rejectsAnOperationStillInsideItsDelay() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 id = _schedule(address(registry), data, bytes32(0)); vm.warp(block.timestamp + DELAY - 1); @@ -184,19 +184,19 @@ contract ProtocolAdminTest is AdminBase { /// @dev The point of open execution: once the wait is served the protocol does not depend on /// any particular key still being available to press the button. function test_execute_allowsAnyoneOnceTheDelayHasPassed() public { - assertEq(registry.defaultDataBundlePriceCap(), defaultDataBundlePriceCap); + assertEq(registry.defaultPriceCapUSDCents(), defaultPriceCapUSDCents); _runThroughTheDelay( address(registry), - abi.encodeCall(registry.setDefaultDataBundlePriceCap, (7 ether)), + abi.encodeCall(registry.setDefaultPriceCapUSDCents, (7 ether)), bytes32(0) ); - assertEq(registry.defaultDataBundlePriceCap(), 7 ether); + assertEq(registry.defaultPriceCapUSDCents(), 7 ether); } function test_execute_rejectsAnOperationThatWasNeverScheduled() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); vm.prank(outsider); vm.expectRevert(); @@ -209,7 +209,7 @@ contract ProtocolAdminTest is AdminBase { /// Requiring the proposer set to agree to stop something would make the delay useless in /// exactly the case it exists for, which is that set having been compromised. function test_cancel_worksForAProposerAndForACancellerAlike() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 first = _schedule(address(registry), data, bytes32(uint256(1))); vm.prank(proposer); @@ -230,7 +230,7 @@ contract ProtocolAdminTest is AdminBase { /// @dev The guardian is not part of the veto. It can take the cancel power away from an /// account and can never use it, which is what keeps it evictable. function test_cancel_rejectsTheGuardian() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 id = _schedule(address(registry), data, bytes32(0)); bytes32 role = protocolAdmin.CANCELLER_ROLE(); @@ -242,7 +242,7 @@ contract ProtocolAdminTest is AdminBase { } function test_cancel_rejectsAnAccountHoldingNeitherRole() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 id = _schedule(address(registry), data, bytes32(0)); bytes32 role = protocolAdmin.CANCELLER_ROLE(); @@ -257,14 +257,14 @@ contract ProtocolAdminTest is AdminBase { /// @dev A cancelled operation goes back to unset, so the same payload can be proposed again /// rather than being burned by one cancellation. function test_cancel_leavesThePayloadSchedulableAgain() public { - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); bytes32 id = _schedule(address(registry), data, bytes32(0)); vm.prank(proposer); protocolAdmin.cancel(id); _runThroughTheDelay(address(registry), data, bytes32(0)); - assertEq(registry.defaultDataBundlePriceCap(), 1 ether); + assertEq(registry.defaultPriceCapUSDCents(), 1 ether); } function test_getMinDelay_startsAtTheConstructorValue() public view { @@ -296,7 +296,7 @@ contract ProtocolAdminTest is AdminBase { assertEq(protocolAdmin.getMinDelay(), DELAY_FLOOR); - bytes memory data = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (1 ether)); + bytes memory data = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (1 ether)); vm.prank(proposer); vm.expectRevert( @@ -359,7 +359,7 @@ contract ProtocolAdminTest is AdminBase { vm.prank(outsider); protocolAdmin.executeBatch(targets, values, payloads, bytes32(0), bytes32(0)); - assertEq(registry.defaultDataBundlePriceCap(), 3 ether); + assertEq(registry.defaultPriceCapUSDCents(), 3 ether); assertEq(registry.lazyWalletRegistry(), address(lazyWalletRegistry)); } @@ -371,7 +371,7 @@ contract ProtocolAdminTest is AdminBase { bytes[] memory payloads = new bytes[](2); targets[0] = address(registry); - payloads[0] = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (3 ether)); + payloads[0] = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (3 ether)); targets[1] = address(registry); payloads[1] = abi.encodeCall(registry.addOrUpdateLazyWalletRegistryAddress, (address(0))); @@ -385,8 +385,8 @@ contract ProtocolAdminTest is AdminBase { protocolAdmin.executeBatch(targets, values, payloads, bytes32(0), bytes32(0)); assertEq( - registry.defaultDataBundlePriceCap(), - defaultDataBundlePriceCap, + registry.defaultPriceCapUSDCents(), + defaultPriceCapUSDCents, "the first call must not have stuck" ); } @@ -414,7 +414,7 @@ contract ProtocolAdminTest is AdminBase { payloads = new bytes[](2); targets[0] = address(registry); - payloads[0] = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (3 ether)); + payloads[0] = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (3 ether)); targets[1] = address(registry); payloads[1] = abi.encodeCall(registry.addOrUpdateLazyWalletRegistryAddress, (address(lazyWalletRegistry))); } diff --git a/test/foundry/unit-testing/admin/RoleRecovery.t.sol b/test/foundry/unit-testing/admin/RoleRecovery.t.sol index ef91c1b2..1e1bf651 100644 --- a/test/foundry/unit-testing/admin/RoleRecovery.t.sol +++ b/test/foundry/unit-testing/admin/RoleRecovery.t.sol @@ -318,14 +318,14 @@ contract RoleRecoveryTest is AdminBase { vm.prank(guardian); protocolAdmin.execute(address(protocolAdmin), 0, grant, bytes32(0), bytes32(uint256(1))); - bytes memory priceCap = abi.encodeCall(registry.setDefaultDataBundlePriceCap, (3 ether)); + bytes memory priceCap = abi.encodeCall(registry.setDefaultPriceCapUSDCents, (3 ether)); _schedule(address(registry), priceCap, bytes32(uint256(2))); vm.warp(block.timestamp + protocolAdmin.getMinDelay()); vm.prank(newGuardian); protocolAdmin.execute(address(registry), 0, priceCap, bytes32(0), bytes32(uint256(2))); - assertEq(registry.defaultDataBundlePriceCap(), 3 ether); + assertEq(registry.defaultPriceCapUSDCents(), 3 ether); } /// @dev The grant is paired and the revocation is not, because `revokeRole` cannot tell a diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol index 1f5d0d3f..7e9b34e4 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol @@ -233,13 +233,11 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { assertEq(address(eSIMWallet1).balance, 0, "eSIMWallet1 balance should have been 0 ETH"); // 6. Add ETH to deviceWallet2, and buy data bundle for eSIMWallet1 - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 1 ether; vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(deviceWallet2).balance, 4 ether, "Device wallet balance should have been 4 ETH"); @@ -247,8 +245,8 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 1 ether, "Transaction history's data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); } /// @notice A registered device wallet cannot release an eSIM wallet another one holds @@ -321,7 +319,7 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { _assertESIMWalletBinding(deviceWallet, eSIMWallet1, true, address(deviceWallet), false, false); DataBundleDetails[] memory batch = new DataBundleDetails[](1); - batch[0] = DataBundleDetails("DB_ID_0", 1 ether); + batch[0] = bundle("DB_ID_0", TEST_PRICE_CENTS); vm.prank(address(lazyWalletRegistry)); registry.populateLazyHistory(address(eSIMWallet1), batch); @@ -458,13 +456,11 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { assertEq(address(eSIMWallet1).balance, 0, "eSIMWallet1 balance should have been 0 ETH"); // 7. Add ETH to deviceWallet2, and buy data bundle for eSIMWallet1 - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 1 ether; vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(deviceWallet2).balance, 4 ether, "Device wallet balance should have been 4 ETH"); @@ -472,7 +468,7 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 1 ether, "Transaction history's data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); } } diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol index 1ece8715..2f936a1f 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol @@ -102,15 +102,13 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 0.1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 0.1 ether; vm.deal(address(deviceWallet), 1 ether); vm.startPrank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.ETHAccessRevoked.selector, address(eSIMWallet1))); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); } @@ -128,14 +126,12 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 0.1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 0.1 ether; vm.deal(address(eSIMWallet1), 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(eSIMWallet1).balance, 0.9 ether, "ESIMWalletAdmin balance should have been decreased to 0.9 ETH"); @@ -143,8 +139,8 @@ contract DeviceWalletETHTest is DeviceWalletFixture { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } function test_toggleAccessToETH_revoke_userHasETH() public { @@ -161,14 +157,12 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 0.1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 0.1 ether; vm.deal(eSIMWalletAdmin, 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle{value: 0.2 ether}(_dataBundleDetail); + eSIMWallet1.buyDataBundle{value: 0.2 ether}(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(eSIMWallet1).balance, 0.1 ether, "ESIMWallet balance should have been increased to 0.1 ETH"); @@ -177,8 +171,8 @@ contract DeviceWalletETHTest is DeviceWalletFixture { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } function test_toggleAccessToETH_grant_deviceWalletHasETH() public { @@ -195,14 +189,12 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 0.1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 0.1 ether; vm.deal(address(deviceWallet), 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet2.buyDataBundle(_dataBundleDetail); + eSIMWallet2.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(vault.balance, 0.1 ether, "Vault balance should have updated to 0.1 ETH"); @@ -210,8 +202,8 @@ contract DeviceWalletETHTest is DeviceWalletFixture { DataBundleDetails[] memory history = eSIMWallet2.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } function test_toggleAccessToETH_grant_userHasETH() public { @@ -228,14 +220,12 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 0.1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 0.1 ether; vm.deal(eSIMWalletAdmin, 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet2.buyDataBundle{value: 0.2 ether}(_dataBundleDetail); + eSIMWallet2.buyDataBundle{value: 0.2 ether}(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(eSIMWallet2).balance, 0.1 ether, "ESIMWallet balance should have been increased to 0.1 ETH"); @@ -244,8 +234,8 @@ contract DeviceWalletETHTest is DeviceWalletFixture { DataBundleDetails[] memory history = eSIMWallet2.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } // --------------------------------------------------------------------------------------------- @@ -296,7 +286,7 @@ contract DeviceWalletETHTest is DeviceWalletFixture { vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.ETHAccessRevoked.selector, fresh)); - MockESIMWallet(payable(fresh)).buyDataBundle(DataBundleDetails("DB_ID_0", 1 ether)); + MockESIMWallet(payable(fresh)).buyDataBundle(bundle("DB_ID_0", TEST_PRICE_CENTS), 1 ether, nextRef()); assertEq(address(deviceWallet).balance, 10 ether, "No ETH may leave through a wallet the user never granted"); assertEq(vault.balance, 0, "The vault must have been paid nothing"); @@ -350,7 +340,7 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertTrue(deviceWallet.canPullETH(fresh), "The grant must land"); vm.prank(eSIMWalletAdmin); - MockESIMWallet(payable(fresh)).buyDataBundle(DataBundleDetails("DB_ID_0", 1 ether)); + MockESIMWallet(payable(fresh)).buyDataBundle(bundle("DB_ID_0", TEST_PRICE_CENTS), 1 ether, nextRef()); assertEq(vault.balance, 1 ether, "The vault must have been paid"); assertEq(address(deviceWallet).balance, 9 ether, "The price must have come out of the device wallet"); diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol index 3ccf3aaa..09589685 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol @@ -15,7 +15,7 @@ import "test/utils/mocks/MockDeviceWallet.sol"; contract ESIMWalletTest is DeployerBase { // Redeclared so expectEmit can name it. This test contract does not inherit ESIMWallet. - event DataBundlePriceCapUpdated(uint256 _cap); + event PriceCapUSDCentsUpdated(uint64 _cap); /// @notice A rotated admin has to reach buyDataBundle, which the factory alone does not /// @dev This path pulls whatever price it is given out of the device wallet and sends it to @@ -34,13 +34,13 @@ contract ESIMWalletTest is DeployerBase { vm.prank(retiredAdmin); vm.expectRevert(Errors.OnlyDeviceWalletOrESIMWalletAdmin.selector); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 1)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 1, nextRef()); vm.prank(user3); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 1)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 1, nextRef()); - (, uint256 price) = eSIMWallet1.transactionHistory(0); - assertEq(price, 1, "The purchase made by the rotated admin must be recorded"); + (, uint64 price,) = eSIMWallet1.transactionHistory(0); + assertEq(price, TEST_PRICE_CENTS, "The purchase made by the rotated admin must be recorded"); } MockDeviceWallet deviceWallet; @@ -210,8 +210,8 @@ contract ESIMWalletTest is DeployerBase { deployWallets(); DataBundleDetails[] memory secondBatch = new DataBundleDetails[](2); - secondBatch[0] = DataBundleDetails("DB_ID_6", 61); - secondBatch[1] = DataBundleDetails("DB_ID_7", 71); + secondBatch[0] = bundle("DB_ID_6", 61); + secondBatch[1] = bundle("DB_ID_7", 71); vm.startPrank(address(registry)); eSIMWallet1.populateHistory(customDataBundleDetails[0]); @@ -222,13 +222,13 @@ contract ESIMWalletTest is DeployerBase { assertEq(history.length, 7, "Both batches should have landed"); for (uint256 i = 0; i < customDataBundleDetails[0].length; ++i) { - assertEq(history[i].dataBundleID, customDataBundleDetails[0][i].dataBundleID); - assertEq(history[i].dataBundlePrice, customDataBundleDetails[0][i].dataBundlePrice); + assertEq(history[i].id, customDataBundleDetails[0][i].id); + assertEq(history[i].priceUSDCents, customDataBundleDetails[0][i].priceUSDCents); } - assertEq(history[5].dataBundleID, "DB_ID_6", "The second batch must start where the first ended"); - assertEq(history[5].dataBundlePrice, 61); - assertEq(history[6].dataBundleID, "DB_ID_7"); - assertEq(history[6].dataBundlePrice, 71); + assertEq(history[5].id, "DB_ID_6", "The second batch must start where the first ended"); + assertEq(history[5].priceUSDCents, 61); + assertEq(history[6].id, "DB_ID_7"); + assertEq(history[6].priceUSDCents, 71); } /// @notice The event carries the running total, which is how an indexer spots the final batch @@ -236,7 +236,7 @@ contract ESIMWalletTest is DeployerBase { deployWallets(); DataBundleDetails[] memory secondBatch = new DataBundleDetails[](1); - secondBatch[0] = DataBundleDetails("DB_ID_6", 61); + secondBatch[0] = bundle("DB_ID_6", 61); vm.prank(address(registry)); eSIMWallet1.populateHistory(customDataBundleDetails[0]); @@ -551,20 +551,18 @@ contract ESIMWalletTest is DeployerBase { function test_buyDataBundle_noFundsFromESIMWallet() public { deployWallets(); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 100000000000000000 // 0.1 ETH - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 100000000000000000; // 0.1 ETH vm.startPrank(eSIMWalletAdmin); // The shortfall is pulled from the device wallet, which holds nothing vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientBalance.selector, 0, 0.1 ether)); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); vm.deal(address(deviceWallet), 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(deviceWallet).balance, 0.9 ether, "Device wallet balance should have been 0.9 ETH"); @@ -572,28 +570,26 @@ contract ESIMWalletTest is DeployerBase { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Transaction history's data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); } function test_buyDataBundle_partialFundsFromESIMWallet() public { deployWallets(); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 100000000000000000 // 0.1 ETH - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 100000000000000000; // 0.1 ETH vm.deal(address(eSIMWallet1), 0.03 ether); vm.startPrank(eSIMWalletAdmin); // The wallet covers 0.03 of the 0.1 and pulls the rest from an empty device wallet vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientBalance.selector, 0, 0.07 ether)); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); vm.deal(address(deviceWallet), 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail); + eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(deviceWallet).balance, 0.93 ether, "Device wallet balance should have been 0.93 ETH"); @@ -602,17 +598,15 @@ contract ESIMWalletTest is DeployerBase { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Transaction history's data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); } function test_buyDataBundle_partialFundsFromUserAndESIMWallet() public { deployWallets(); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 100000000000000000 // 0.1 ETH - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 100000000000000000; // 0.1 ETH vm.deal(eSIMWalletAdmin, 0.04 ether); // 0.04 ETH to be supplied as msg.value vm.deal(address(eSIMWallet1), 0.03 ether); // 0.03 ETH to be used from eSIM wallet @@ -620,12 +614,12 @@ contract ESIMWalletTest is DeployerBase { vm.startPrank(eSIMWalletAdmin); // Revert from the device wallet, needed 0.03 ETH, found 0 ETH vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientBalance.selector, 0, 0.03 ether)); - eSIMWallet1.buyDataBundle{value: 0.04 ether}(_dataBundleDetail); + eSIMWallet1.buyDataBundle{value: 0.04 ether}(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); vm.deal(address(deviceWallet), 1 ether); // needed 0.03 ETH from Device walelt, found 1 ETH vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle{value: 0.04 ether}(_dataBundleDetail); + eSIMWallet1.buyDataBundle{value: 0.04 ether}(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(deviceWallet).balance, 0.97 ether, "Device wallet balance should have been 0.97 ETH"); @@ -634,24 +628,22 @@ contract ESIMWalletTest is DeployerBase { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Transaction history's data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); } function test_buyDataBundle_allFundsFromUser() public { deployWallets(); - DataBundleDetails memory _dataBundleDetail = DataBundleDetails( - "DB_ID_0", - 0.1 ether - ); + DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); + uint256 _priceWei = 0.1 ether; vm.deal(address(deviceWallet), 2 ether); vm.deal(address(eSIMWallet1), 1 ether); vm.deal(eSIMWalletAdmin, 0.2 ether); // remaining ETH should go to eSIM wallet vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle{value: 0.2 ether}(_dataBundleDetail); + eSIMWallet1.buyDataBundle{value: 0.2 ether}(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); assertEq(address(deviceWallet).balance, 2 ether, "Device wallet balance should have been 2 ETH"); @@ -660,8 +652,8 @@ contract ESIMWalletTest is DeployerBase { DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].dataBundleID, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].dataBundlePrice, 0.1 ether, "Transaction history's data bundle price should have been correct"); + assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); + assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); } function test_sendETHToDeviceWallet_unauthorised() public { @@ -714,7 +706,7 @@ contract ESIMWalletTest is DeployerBase { vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.ProtocolPaused.selector); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 0.1 ether)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 0.1 ether, nextRef()); assertEq(vault.balance, 0, "The vault must receive nothing while paused"); assertEq(address(deviceWallet).balance, 1 ether, "The device wallet must keep its ETH"); @@ -723,7 +715,7 @@ contract ESIMWalletTest is DeployerBase { registry.unpause(); vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 0.1 ether)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 0.1 ether, nextRef()); assertEq(vault.balance, 0.1 ether, "The release must restore the path"); } @@ -733,11 +725,11 @@ contract ESIMWalletTest is DeployerBase { vm.deal(address(deviceWallet), 5 ether); vm.prank(address(deviceWallet)); - eSIMWallet1.setDataBundlePriceCap(0.2 ether); + eSIMWallet1.setPriceCapUSDCents(20_000); // $200 vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 0.3 ether, 0.2 ether)); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 0.3 ether)); + vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 30_000, 20_000)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", 30_000), 0.3 ether, nextRef()); assertEq(vault.balance, 0, "The vault must receive nothing above the cap"); assertEq(address(deviceWallet).balance, 5 ether, "The device wallet must keep its ETH"); @@ -751,13 +743,13 @@ contract ESIMWalletTest is DeployerBase { vm.deal(address(deviceWallet), 5 ether); vm.prank(registry.owner()); - registry.setDefaultDataBundlePriceCap(0.2 ether); + registry.setDefaultPriceCapUSDCents(20_000); // $200 - assertEq(eSIMWallet1.dataBundlePriceCap(), 0, "The wallet must hold no ceiling of its own"); + assertEq(eSIMWallet1.priceCapUSDCents(), 0, "The wallet must hold no ceiling of its own"); vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 0.3 ether, 0.2 ether)); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 0.3 ether)); + vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 30_000, 20_000)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", 30_000), 0.3 ether, nextRef()); assertEq(vault.balance, 0, "The vault must receive nothing above the default"); } @@ -768,51 +760,51 @@ contract ESIMWalletTest is DeployerBase { vm.deal(address(deviceWallet), 5 ether); vm.prank(registry.owner()); - registry.setDefaultDataBundlePriceCap(0.1 ether); + registry.setDefaultPriceCapUSDCents(10_000); // $100 vm.prank(address(deviceWallet)); - eSIMWallet1.setDataBundlePriceCap(1 ether); + eSIMWallet1.setPriceCapUSDCents(100_000); // $1000 vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 0.5 ether)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", 50_000), 0.5 ether, nextRef()); assertEq(vault.balance, 0.5 ether, "A wallet raising its own ceiling must be able to spend to it"); vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 2 ether, 1 ether)); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_2", 2 ether)); + vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 200_000, 100_000)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_2", 200_000), 2 ether, nextRef()); } /// @notice A wallet with no ceiling of its own is still bound by the registry default set at /// deployment. - /// @dev `Registry.initialize` and `setDefaultDataBundlePriceCap` both refuse zero, so a wallet + /// @dev `Registry.initialize` and `setDefaultPriceCapUSDCents` both refuse zero, so a wallet /// that never sets its own cap is never actually uncapped, only deferring to whatever the /// registry was given at deploy time. function test_buyDataBundle_rejectsAPriceAboveTheRegistryDefault() public { deployWallets(); vm.deal(address(deviceWallet), 5 ether); - assertEq(eSIMWallet1.dataBundlePriceCap(), 0, "The wallet must hold no ceiling of its own"); + assertEq(eSIMWallet1.priceCapUSDCents(), 0, "The wallet must hold no ceiling of its own"); - uint256 price = defaultDataBundlePriceCap + 1; + uint64 price = defaultPriceCapUSDCents + 1; vm.prank(eSIMWalletAdmin); vm.expectRevert( - abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, defaultDataBundlePriceCap) + abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, defaultPriceCapUSDCents) ); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", price)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", price), 0.1 ether, nextRef()); assertEq(vault.balance, 0, "The vault must receive nothing above the registry default"); } /// @notice The admin names the price, so it must not also be able to raise the ceiling. - function test_setDataBundlePriceCap_rejectsTheAdmin() public { + function test_setPriceCapUSDCents_rejectsTheAdmin() public { deployWallets(); vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.OnlyDeviceWallet.selector); - eSIMWallet1.setDataBundlePriceCap(100 ether); + eSIMWallet1.setPriceCapUSDCents(100_000); - assertEq(eSIMWallet1.dataBundlePriceCap(), 0, "The admin must not be able to set a ceiling"); + assertEq(eSIMWallet1.priceCapUSDCents(), 0, "The admin must not be able to set a ceiling"); } /// @notice A handover clears the outgoing owner's ceiling and announces it. @@ -824,29 +816,29 @@ contract ESIMWalletTest is DeployerBase { // Still owned by the first device wallet until the request is accepted vm.prank(address(deviceWallet)); - eSIMWallet1.setDataBundlePriceCap(type(uint256).max); + eSIMWallet1.setPriceCapUSDCents(type(uint64).max); vm.expectEmit(false, false, false, true, address(eSIMWallet1)); - emit DataBundlePriceCapUpdated(0); + emit PriceCapUSDCentsUpdated(0); vm.prank(address(deviceWallet2)); eSIMWallet1.acceptOwnershipTransfer(); assertEq(eSIMWallet1.owner(), address(deviceWallet2), "Ownership should have moved"); - assertEq(eSIMWallet1.dataBundlePriceCap(), 0, "The new owner must start on the registry ceiling"); + assertEq(eSIMWallet1.priceCapUSDCents(), 0, "The new owner must start on the registry ceiling"); } /// @notice A wallet that never set a ceiling emits nothing on handover. function test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() public { test_requestTransferOwnership(); - assertEq(eSIMWallet1.dataBundlePriceCap(), 0, "The wallet must hold no ceiling of its own"); + assertEq(eSIMWallet1.priceCapUSDCents(), 0, "The wallet must hold no ceiling of its own"); vm.recordLogs(); vm.prank(address(deviceWallet2)); eSIMWallet1.acceptOwnershipTransfer(); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 capUpdated = keccak256("DataBundlePriceCapUpdated(uint256)"); + bytes32 capUpdated = keccak256("PriceCapUSDCentsUpdated(uint64)"); for (uint256 i = 0; i < logs.length; ++i) { if (logs[i].emitter == address(eSIMWallet1) && logs[i].topics.length > 0) { assertNotEq(logs[i].topics[0], capUpdated, "An unchanged ceiling must announce nothing"); @@ -855,13 +847,13 @@ contract ESIMWalletTest is DeployerBase { } /// @notice The registry default applies again once a wallet changes hands. - /// @dev The consequence of the clear: an inherited ceiling of type(uint256).max would leave the + /// @dev The consequence of the clear: an inherited ceiling of type(uint64).max would leave the /// new owner with no effective limit on what the admin may charge it. function test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() public { test_requestTransferOwnership(); vm.prank(address(deviceWallet)); - eSIMWallet1.setDataBundlePriceCap(type(uint256).max); + eSIMWallet1.setPriceCapUSDCents(type(uint64).max); vm.prank(address(deviceWallet2)); eSIMWallet1.acceptOwnershipTransfer(); @@ -869,13 +861,13 @@ contract ESIMWalletTest is DeployerBase { deviceWallet2.addESIMWallet(address(eSIMWallet1), false); vm.deal(address(deviceWallet2), 5 ether); - uint256 price = defaultDataBundlePriceCap + 1; + uint64 price = defaultPriceCapUSDCents + 1; vm.prank(eSIMWalletAdmin); vm.expectRevert( - abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, defaultDataBundlePriceCap) + abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, defaultPriceCapUSDCents) ); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", price)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", price), 0.1 ether, nextRef()); assertEq(vault.balance, 0, "The registry default must bind the new owner"); } @@ -892,7 +884,7 @@ contract ESIMWalletTest is DeployerBase { registry.updateVaultAddress(address(historyReadingVault)); vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(DataBundleDetails("DB_ID_1", 0.1 ether)); + eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 0.1 ether, nextRef()); assertEq(historyReadingVault.historyLengthSeen(), 1, "The vault must see the purchase already recorded"); } diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol index e9d89af6..27ec388f 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol @@ -24,9 +24,9 @@ contract ETHRefuser { /// @dev The purchase and ownership transfer behaviour is covered in `ESIMWallet.t.sol`. contract ESIMWalletGuardsTest is DeployerBase { - uint256 constant REGISTRY_CAP = 1 ether; - uint256 constant WALLET_CAP = 3 ether; - uint256 constant PRICE_BETWEEN_THE_TWO = 2 ether; + uint64 constant REGISTRY_CAP = 10_000; // $100 + uint64 constant WALLET_CAP = 30_000; // $300 + uint64 constant PRICE_BETWEEN_THE_TWO = 20_000; // $200 DeviceWallet deviceWallet; ESIMWallet eSIMWallet; @@ -63,11 +63,12 @@ contract ESIMWalletGuardsTest is DeployerBase { new BeaconProxy(_beacon, abi.encodeCall(ESIMWallet.initialize, (_factory, _deviceWallet))); } - /// @notice Buys a bundle at the given price as the admin - /// @param _price The price to charge - function _buyAt(uint256 _price) internal { + /// @notice Buys a bundle at the given recorded price as the admin + /// @dev The ETH figure is fixed. The ceiling bounds the recorded price, not the amount sent. + /// @param _priceUSDCents The price to record + function _buyAt(uint64 _priceUSDCents) internal { vm.prank(eSIMWalletAdmin); - eSIMWallet.buyDataBundle(DataBundleDetails("DB_ID_CAP", _price)); + eSIMWallet.buyDataBundle(bundle("DB_ID_CAP", _priceUSDCents), 0.1 ether, nextRef()); } // --------------------------------------------------------------------------------------------- @@ -105,7 +106,7 @@ contract ESIMWalletGuardsTest is DeployerBase { vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.EmptyDataBundleID.selector); - eSIMWallet.buyDataBundle(DataBundleDetails("", 1)); + eSIMWallet.buyDataBundle(bundle("", TEST_PRICE_CENTS), 1, nextRef()); } /// @notice A purchase for nothing is refused @@ -115,7 +116,7 @@ contract ESIMWalletGuardsTest is DeployerBase { vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.ZeroDataBundlePrice.selector); - eSIMWallet.buyDataBundle(DataBundleDetails("DB_ID_1", 0)); + eSIMWallet.buyDataBundle(bundle("DB_ID_1", 0), 0.1 ether, nextRef()); } // --------------------------------------------------------------------------------------------- @@ -142,25 +143,25 @@ contract ESIMWalletGuardsTest is DeployerBase { /// deployed before the cap existed reads zero and has to keep working. This is the /// transition that says so: the same price is accepted under the wallet's own ceiling and /// refused once that ceiling is cleared. - function test_setDataBundlePriceCap_clearingItReturnsToTheRegistryDefault() public { + function test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() public { _deployWallets(9004); vm.deal(address(deviceWallet), 10 ether); vm.prank(upgradeManager); - registry.setDefaultDataBundlePriceCap(REGISTRY_CAP); + registry.setDefaultPriceCapUSDCents(REGISTRY_CAP); vm.prank(address(deviceWallet)); - eSIMWallet.setDataBundlePriceCap(WALLET_CAP); + eSIMWallet.setPriceCapUSDCents(WALLET_CAP); _buyAt(PRICE_BETWEEN_THE_TWO); vm.prank(address(deviceWallet)); - eSIMWallet.setDataBundlePriceCap(0); + eSIMWallet.setPriceCapUSDCents(0); vm.prank(eSIMWalletAdmin); vm.expectRevert( abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, PRICE_BETWEEN_THE_TWO, REGISTRY_CAP) ); - eSIMWallet.buyDataBundle(DataBundleDetails("DB_ID_CAP", PRICE_BETWEEN_THE_TWO)); + eSIMWallet.buyDataBundle(bundle("DB_ID_CAP", PRICE_BETWEEN_THE_TWO), 0.1 ether, nextRef()); } // --------------------------------------------------------------------------------------------- diff --git a/test/foundry/unit-testing/registry/AdminRotation.t.sol b/test/foundry/unit-testing/registry/AdminRotation.t.sol index 95938332..0def6dd9 100644 --- a/test/foundry/unit-testing/registry/AdminRotation.t.sol +++ b/test/foundry/unit-testing/registry/AdminRotation.t.sol @@ -123,10 +123,10 @@ contract AdminRotationTest is DeployerBase { vm.expectRevert(Errors.OnlyESIMWalletAdmin.selector); DeviceWallet(payable(deviceWallet)).deployESIMWallet(false, 99); - DataBundleDetails memory bundle = DataBundleDetails("bundle", 1); + DataBundleDetails memory purchase = bundle("bundle", TEST_PRICE_CENTS); vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.OnlyDeviceWalletOrESIMWalletAdmin.selector); - ESIMWallet(payable(eSIMWallet)).buyDataBundle(bundle); + ESIMWallet(payable(eSIMWallet)).buyDataBundle(purchase, 1 ether, nextRef()); // The nominee has nothing yet either. The role is dormant, not transferred. _assertAdminGatesClosed(user2); @@ -293,10 +293,10 @@ contract AdminRotationTest is DeployerBase { vm.expectRevert(Errors.OnlyESIMWalletAdmin.selector); DeviceWallet(payable(deviceWallet)).deployESIMWallet(false, 99); - DataBundleDetails memory bundle = DataBundleDetails("bundle", 1); + DataBundleDetails memory purchase = bundle("bundle", TEST_PRICE_CENTS); vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.OnlyDeviceWalletOrESIMWalletAdmin.selector); - ESIMWallet(payable(eSIMWallet)).buyDataBundle(bundle); + ESIMWallet(payable(eSIMWallet)).buyDataBundle(purchase, 1 ether, nextRef()); } /// @notice Suspension is the owner's, which is what lets a guardian reach it through the diff --git a/test/foundry/unit-testing/registry/IdentifierCollision.t.sol b/test/foundry/unit-testing/registry/IdentifierCollision.t.sol index 5b328450..81fb639f 100644 --- a/test/foundry/unit-testing/registry/IdentifierCollision.t.sol +++ b/test/foundry/unit-testing/registry/IdentifierCollision.t.sol @@ -37,7 +37,7 @@ contract IdentifierCollisionTest is DeployerBase { DataBundleDetails[][] memory details = new DataBundleDetails[][](1); details[0] = new DataBundleDetails[](_eSIMIdentifiers.length); for(uint256 i = 0; i < _eSIMIdentifiers.length; ++i) { - details[0][i] = DataBundleDetails("DB_COLLISION", 1 ether); + details[0][i] = bundle("DB_COLLISION", TEST_PRICE_CENTS); } vm.prank(eSIMWalletAdmin); @@ -225,7 +225,7 @@ contract IdentifierCollisionTest is DeployerBase { eSIMs[0] = _one(eSIMIdentifier); DataBundleDetails[][] memory details = new DataBundleDetails[][](1); details[0] = new DataBundleDetails[](1); - details[0][0] = DataBundleDetails("DB_COLLISION", 1 ether); + details[0][0] = bundle("DB_COLLISION", TEST_PRICE_CENTS); vm.prank(eSIMWalletAdmin); vm.expectRevert( diff --git a/test/foundry/unit-testing/registry/LazySaltCollision.t.sol b/test/foundry/unit-testing/registry/LazySaltCollision.t.sol index d4100c83..b124965a 100644 --- a/test/foundry/unit-testing/registry/LazySaltCollision.t.sol +++ b/test/foundry/unit-testing/registry/LazySaltCollision.t.sol @@ -118,7 +118,7 @@ contract LazySaltCollisionTest is DeployerBase { for(uint256 i=0; i<_count; ++i) { eSIMs[0][i] = _eSIMName(i); - bundles[0][i] = DataBundleDetails("DB_BATCH", 1); + bundles[0][i] = bundle("DB_BATCH", TEST_PRICE_CENTS); } vm.prank(eSIMWalletAdmin); diff --git a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol index f60aa6f9..bc1dac4a 100644 --- a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol +++ b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol @@ -44,8 +44,8 @@ contract LazyWalletRegistryTest is DeployerBase { customESIMUniqueIdentifiers[0][1] ); assertEq(storedData.length, 1, "DataBundleDetails array length should be 1"); - assertEq(storedData[0].dataBundleID, "DB_ID_2"); - assertEq(storedData[0].dataBundlePrice, 21); + assertEq(storedData[0].id, "DB_ID_2"); + assertEq(storedData[0].priceUSDCents, 21); } /// @notice A rotated admin has to reach this registry, which the factory alone does not @@ -122,8 +122,8 @@ contract LazyWalletRegistryTest is DeployerBase { ); assertEq(storedData.length, 2, "DataBundleDetails array length should be 2"); - assertEq(storedData[1].dataBundleID, "DB_ID_2"); - assertEq(storedData[1].dataBundlePrice, 21); + assertEq(storedData[1].id, "DB_ID_2"); + assertEq(storedData[1].priceUSDCents, 21); } /// Providing eSIM identifiers that have been associated with a different device identifier @@ -200,8 +200,8 @@ contract LazyWalletRegistryTest is DeployerBase { eSIMIdentifier ); assertEq(newDeviceData.length, 1, "Data bundles should have been added to the new device identifier"); - assertEq(newDeviceData[0].dataBundleID, customDataBundleDetails[1][0].dataBundleID); - assertEq(newDeviceData[0].dataBundlePrice, customDataBundleDetails[1][0].dataBundlePrice); + assertEq(newDeviceData[0].id, customDataBundleDetails[1][0].id); + assertEq(newDeviceData[0].priceUSDCents, customDataBundleDetails[1][0].priceUSDCents); string[] memory oldDeviceListOfESIMs = lazyWalletRegistry.getESIMIdentifiersAssociatedWithDeviceIdentifier( oldDeviceIdentifier @@ -546,7 +546,7 @@ contract LazyWalletRegistryTest is DeployerBase { for(uint256 i=0; i<_count; ++i) { eSIMs[0][i] = string.concat(_prefix, vm.toString(i)); - bundles[0][i] = DataBundleDetails("DB_CAP", 1); + bundles[0][i] = bundle("DB_CAP", TEST_PRICE_CENTS); } vm.prank(eSIMWalletAdmin); @@ -610,7 +610,7 @@ contract LazyWalletRegistryTest is DeployerBase { for(uint256 i=0; i<_purchases; ++i) { DataBundleDetails[][] memory bundles = new DataBundleDetails[][](1); bundles[0] = new DataBundleDetails[](1); - bundles[0][0] = DataBundleDetails(string.concat("DB_", vm.toString(i)), i + 1); + bundles[0][0] = bundle(bytes32(bytes(string.concat("DB_", vm.toString(i)))), uint64(i + 1)); vm.prank(eSIMWalletAdmin); lazyWalletRegistry.batchPopulateHistory(devices, eSIMs, bundles); @@ -946,8 +946,8 @@ contract LazyWalletRegistryTest is DeployerBase { assertEq(inWallet.length, stored.length, "The wallet must end up holding every stored entry"); for(uint256 i=0; i Date: Fri, 28 Aug 2026 14:24:08 +0530 Subject: [PATCH 07/75] Refresh the gas baselines for the cents pricing path 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. --- certora/specs/ESIMWallet.spec | 8 +++---- snapshots/DeviceWallet.Operations.json | 4 ++-- snapshots/DeviceWalletFactory.Operations.json | 8 +++---- snapshots/ESIMWallet.Operations.json | 18 +++++++------- snapshots/LazyWalletRegistry.Operations.json | 24 +++++++++---------- snapshots/ProtocolAdmin.Operations.json | 10 ++++---- snapshots/Registry.Operations.json | 22 ++++++++--------- 7 files changed, 47 insertions(+), 47 deletions(-) diff --git a/certora/specs/ESIMWallet.spec b/certora/specs/ESIMWallet.spec index 47eb0721..e5f872f3 100644 --- a/certora/specs/ESIMWallet.spec +++ b/certora/specs/ESIMWallet.spec @@ -38,7 +38,7 @@ methods { function newRequestedOwner() external returns (address) envfree; function deviceWallet() external returns (address) envfree; function eSIMWalletFactory() external returns (address) envfree; - function dataBundlePriceCap() external returns (uint256) envfree; + function priceCapUSDCents() external returns (uint256) envfree; /// Nothing outside this contract is in the scene. Without this the device wallet and registry /// calls would havoc this wallet's own storage and every rule below would fail for the wrong @@ -258,16 +258,16 @@ rule theOwnerIsAlwaysTheDeviceWallet(method f) filtered { /// ceiling to zero, which hands the wallet to the registry default rather than to a figure the /// outgoing owner chose. Anything else on that path would be a second, unguarded writer. rule thePriceCeilingIsSetOnlyByItsSetter(method f) filtered { f -> !alwaysReverts(f) } { - uint256 capBefore = dataBundlePriceCap(); + uint256 capBefore = priceCapUSDCents(); env callEnv; calldataarg args; f(callEnv, args); - uint256 capAfter = dataBundlePriceCap(); + uint256 capAfter = priceCapUSDCents(); assert capAfter != capBefore => - (f.selector == sig:setDataBundlePriceCap(uint256).selector || + (f.selector == sig:setPriceCapUSDCents(uint256).selector || f.selector == sig:acceptOwnershipTransfer().selector), "the price ceiling moved through something other than its setter or a handover"; diff --git a/snapshots/DeviceWallet.Operations.json b/snapshots/DeviceWallet.Operations.json index 78652aa5..57bce8fa 100644 --- a/snapshots/DeviceWallet.Operations.json +++ b/snapshots/DeviceWallet.Operations.json @@ -1,9 +1,9 @@ { "addDeposit: 1 ether": "35038", - "deployESIMWallet: second wallet on the device": "357992", + "deployESIMWallet: second wallet on the device": "358239", "execute: ETH transfer to an EOA": "36295", "executeBatch: 3 ETH transfers": "79971", - "pullETH: 1 ether to the eSIM wallet": "13348", + "pullETH: 1 ether to the eSIM wallet": "13372", "toggleAccessToETH: grant": "23441", "toggleAccessToETH: revoke": "3541" } \ No newline at end of file diff --git a/snapshots/DeviceWalletFactory.Operations.json b/snapshots/DeviceWalletFactory.Operations.json index 2e0e21a0..7b83cb83 100644 --- a/snapshots/DeviceWalletFactory.Operations.json +++ b/snapshots/DeviceWalletFactory.Operations.json @@ -1,8 +1,8 @@ { "createAccount: first wallet, cold factory storage": "326317", "createAccount: second wallet, warm factory storage": "308817", - "deployDeviceWalletForUsers: batch of 1": "881549", - "deployDeviceWalletForUsers: batch of 1, funded": "889366", - "deployDeviceWalletForUsers: batch of 5": "4146137", - "postCreateAccount: registering a wallet": "173919" + "deployDeviceWalletForUsers: batch of 1": "881806", + "deployDeviceWalletForUsers: batch of 1, funded": "889623", + "deployDeviceWalletForUsers: batch of 5": "4147402", + "postCreateAccount: registering a wallet": "173975" } \ No newline at end of file diff --git a/snapshots/ESIMWallet.Operations.json b/snapshots/ESIMWallet.Operations.json index 221e16d8..294838ee 100644 --- a/snapshots/ESIMWallet.Operations.json +++ b/snapshots/ESIMWallet.Operations.json @@ -1,11 +1,11 @@ { - "acceptOwnershipTransfer": "5611", - "buyDataBundle: pulls from the device wallet": "80668", - "buyDataBundle: wallet already holds the price": "122259", - "deployESIMWallet: through the factory": "289326", - "populateHistory: 10 entries": "497912", - "requestTransferOwnership": "64168", - "setDataBundlePriceCap: back to the registry ceiling": "2286", - "setDataBundlePriceCap: set": "24286", - "setESIMUniqueIdentifier: first time": "27311" + "acceptOwnershipTransfer": "3529", + "buyDataBundle: pulls from the device wallet": "107831", + "buyDataBundle: wallet already holds the price": "156358", + "deployESIMWallet: through the factory": "289517", + "populateHistory: 10 entries": "489289", + "requestTransferOwnership": "64251", + "setESIMUniqueIdentifier: first time": "27227", + "setPriceCapUSDCents: back to the registry ceiling": "2543", + "setPriceCapUSDCents: set": "22443" } \ No newline at end of file diff --git a/snapshots/LazyWalletRegistry.Operations.json b/snapshots/LazyWalletRegistry.Operations.json index 4a375e31..ad01c779 100644 --- a/snapshots/LazyWalletRegistry.Operations.json +++ b/snapshots/LazyWalletRegistry.Operations.json @@ -1,14 +1,14 @@ { - "batchPopulateHistory: 1 device, 5 eSIMs": "674478", - "batchPopulateHistory: 5 devices, 22 eSIMs": "2623639", - "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1013045", - "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5002117", - "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9485757", - "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2762382", - "deployMoreESIMWalletsForLazyDevice: batch of 1": "462620", - "deployMoreESIMWalletsForLazyDevice: batch of 18": "8061655", - "deployMoreESIMWalletsForLazyDevice: batch of 5": "2249029", - "setHistoryForLazyWallet: 1 entry": "108840", - "setHistoryForLazyWallet: 10 entries": "508687", - "setHistoryForLazyWallet: 39 entries": "1938746" + "batchPopulateHistory: 1 device, 5 eSIMs": "668758", + "batchPopulateHistory: 5 devices, 22 eSIMs": "2596984", + "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1013162", + "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5002891", + "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9487261", + "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2762791", + "deployMoreESIMWalletsForLazyDevice: batch of 1": "462723", + "deployMoreESIMWalletsForLazyDevice: batch of 18": "8062999", + "deployMoreESIMWalletsForLazyDevice: batch of 5": "2249424", + "setHistoryForLazyWallet: 1 entry": "107184", + "setHistoryForLazyWallet: 10 entries": "493931", + "setHistoryForLazyWallet: 39 entries": "1881545" } \ No newline at end of file diff --git a/snapshots/ProtocolAdmin.Operations.json b/snapshots/ProtocolAdmin.Operations.json index 2d55383c..1ae5a484 100644 --- a/snapshots/ProtocolAdmin.Operations.json +++ b/snapshots/ProtocolAdmin.Operations.json @@ -1,14 +1,14 @@ { - "acceptOwnershipBatch: four contracts": "36491", + "acceptOwnershipBatch: four contracts": "36608", "cancel": "4694", - "execute: one call, delay served": "21198", + "execute: one call, delay served": "23558", "executeBatch: both wallet beacons": "69122", - "executeBatch: four calls, delay served": "47102", - "executeBatch: four proxy upgrades": "87423", + "executeBatch: four calls, delay served": "45642", + "executeBatch: four proxy upgrades": "87471", "revokeCancellersInstantly: one account": "11893", "revokeCancellersInstantly: three accounts": "27789", "schedule: one call": "32998", "scheduleBatch: four calls": "45546", "scheduleBatch: four proxy upgrades": "50310", - "unpauseInstantly: release a protocol wide pause": "9056" + "unpauseInstantly: release a protocol wide pause": "9058" } \ No newline at end of file diff --git a/snapshots/Registry.Operations.json b/snapshots/Registry.Operations.json index 99e6dd43..bb05adc6 100644 --- a/snapshots/Registry.Operations.json +++ b/snapshots/Registry.Operations.json @@ -1,13 +1,13 @@ { - "acceptAdminUpdate": "4962", - "assignESIMIdentifier: first time": "64871", - "pause": "31032", - "requestAdminUpdate": "27500", - "setDefaultDataBundlePriceCap": "13786", - "toggleESIMWalletStandbyStatus: off standby": "5332", - "toggleESIMWalletStandbyStatus: onto standby": "25232", - "unpause": "4100", - "updateDeviceWalletInfo: first device, cold storage": "122094", - "updateDeviceWalletInfo: second device, warm storage": "115594", - "updateVaultAddress": "14289" + "acceptAdminUpdate": "4964", + "assignESIMIdentifier: first time": "64764", + "pause": "13963", + "requestAdminUpdate": "10494", + "setDefaultPriceCapUSDCents": "13346", + "toggleESIMWalletStandbyStatus: off standby": "5365", + "toggleESIMWalletStandbyStatus: onto standby": "25265", + "unpause": "4102", + "updateDeviceWalletInfo: first device, cold storage": "122096", + "updateDeviceWalletInfo: second device, warm storage": "115596", + "updateVaultAddress": "14338" } \ No newline at end of file From 09fe5abfbd413c0c7ce608516535a62324fd2d22 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 18:17:26 +0530 Subject: [PATCH 08/75] Cut the comments back to plain language 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. --- contracts/CustomStructs.sol | 12 ++-- contracts/Errors.sol | 12 ++-- contracts/LazyWalletRegistry.sol | 15 ++--- contracts/Registry.sol | 63 +++++++---------- contracts/RegistryHelper.sol | 8 +-- contracts/esim-wallet/ESIMWallet.sol | 43 +++++------- contracts/payments/PaymentAdapter.sol | 67 ++++++++----------- env.sample | 4 +- scripts/deploy/Deploy.s.sol | 4 +- scripts/deploy/config/DeployConfig.sol | 4 +- test/foundry/fuzz-testing/ETHAmounts.t.sol | 6 +- .../invariant-testing/base/InvariantBase.sol | 4 +- .../handler/AdminHandler.sol | 12 ++-- .../handler/UpgradeManagerHandler.sol | 3 +- .../handler/WalletHandler.sol | 3 +- .../esim-wallet/ESIMWalletGuards.t.sol | 3 +- test/utils/DeployerBase.sol | 17 +++-- 17 files changed, 117 insertions(+), 163 deletions(-) diff --git a/contracts/CustomStructs.sol b/contracts/CustomStructs.sol index d7681483..7fcece89 100644 --- a/contracts/CustomStructs.sol +++ b/contracts/CustomStructs.sol @@ -2,9 +2,8 @@ pragma solidity 0.8.36; /// @notice Which contract, if any, saw the money for a data bundle move -/// @dev `DeviceWallet` is the only value the protocol can prove. The other two are the admin -/// stating what happened on a rail the contracts cannot see, so they are assertions and the -/// price cap is the only guard on them. +/// @dev Only `DeviceWallet` can be proven onchain. The other two are the admin's word, so the +/// price cap is the only check on them. enum Settlement { DeviceWallet, ExternalWallet, @@ -12,10 +11,9 @@ enum Settlement { } /// @notice Data Bundle related details stored in the eSIM wallet -/// @dev Two slots: `id` fills the first, then `priceUSDCents` and `settlement` pack into the -/// second. The provider's bundle id fits in 32 bytes, so a `string` would have paid for a -/// general case this protocol does not have. No purchase timestamp: the event log carries the -/// block timestamp already. +/// @dev Two slots: `id`, then `priceUSDCents` and `settlement` packed together. `id` is +/// `bytes32` because the provider's ids fit in 32 bytes and a `string` would cost an extra +/// slot on every entry. No timestamp field: the event log already has one. struct DataBundleDetails { bytes32 id; uint64 priceUSDCents; // 123456 reads as $1234.56 diff --git a/contracts/Errors.sol b/contracts/Errors.sol index fa602279..f070b6e5 100644 --- a/contracts/Errors.sol +++ b/contracts/Errors.sol @@ -116,12 +116,12 @@ interface Errors { error OnlyAssociatedESIMWallets(); error OnlyESIMWalletAdmin(); - // Registry, on the path that records a purchase the contracts did not witness + // Registry, on the path for purchases paid for outside the protocol error PaymentAdapterNotSet(); error PaymentAdapterUnchanged(address paymentAdapter); - // buyDataBundle is the only caller allowed to claim the protocol saw the money move + // Only buyDataBundle may claim the protocol saw the money move error SettlementNotAsserted(); - // The lazy registry still owes this eSIM history, so a new entry would land out of order + // Older history is still waiting to be copied, so a new entry would land out of order error HistoryNotFullyCopied(string eSIMIdentifier, uint256 outstanding); // PaymentAdapter @@ -130,11 +130,11 @@ interface Errors { error AssetNotAllowed(bytes32 asset); error AssetAlreadyRegistered(bytes32 asset); error AssetNotRegistered(bytes32 asset); - // quote scales cents by 10**decimals and divides by 100, so anything under two truncates + // quote divides by 100, so fewer than two decimals loses the cents error AssetDecimalsTooLow(bytes32 asset, uint8 decimals); - // No oracle, so a price only becomes a token amount for an asset already denominated in dollars + // No price feeds, so only a currency already in dollars can be converted from cents error AssetNeedsSwap(bytes32 asset); - // Record-only assets, fiat and non-EVM, carry no token address to move + // Fiat and non-EVM currencies have no token address, so nothing can be transferred error AssetNotSettleable(bytes32 asset); error PaymentReferenceAlreadyUsed(bytes32 paymentReference); error SettlementNotAvailable(); diff --git a/contracts/LazyWalletRegistry.sol b/contracts/LazyWalletRegistry.sol index 8a9adb54..7e16aa90 100644 --- a/contracts/LazyWalletRegistry.sol +++ b/contracts/LazyWalletRegistry.sol @@ -574,9 +574,8 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra } } - // These entries predate the wallet, so no contract in this protocol can have seen the - // money move. Letting the admin claim otherwise would make the settlement field mean - // nothing on the one path where it is the only evidence there is. + // These purchases happened before the wallet existed, so no contract here saw the + // money move. The admin must not be able to claim otherwise. if(_dataBundleDetails[i].settlement == Settlement.DeviceWallet) { revert Errors.SettlementNotAsserted(); } @@ -726,12 +725,10 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra return owner(); } - /// @notice How many stored history entries this eSIM still owes its deployed wallet - /// @dev The public getter on `deviceIdentifierToESIMDetails` takes an index rather than - /// returning a length, so the count cannot be worked out from outside this contract. - /// - /// Zero for an eSIM this contract never deployed a wallet for, which is the safe answer: - /// such a wallet has no stored history waiting to land after a new entry. + /// @notice How many history entries are still waiting to be copied into this eSIM's wallet + /// @dev Needed because the public getter on `deviceIdentifierToESIMDetails` takes an index and + /// never returns a length, so nothing outside this contract can count the entries. + /// Returns zero for an eSIM this contract never handled, which has nothing waiting anyway. /// @param _eSIMIdentifier eSIM being asked about /// @return Entries still waiting to be copied function outstandingHistoryEntries(string calldata _eSIMIdentifier) external view returns (uint256) { diff --git a/contracts/Registry.sol b/contracts/Registry.sol index 54c964ab..7aa7d66a 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -93,16 +93,14 @@ contract Registry is /// @notice Most an eSIM wallet may be charged for one data bundle, in USD cents, unless it sets /// its own limit - /// @dev Held here rather than only on each wallet because wallets are beacon proxies tracked by - /// a mapping with no enumerable list, so one write here is how a change reaches all of - /// them. Never zero: `initialize` and `setDefaultPriceCapUSDCents` both reject it, since a - /// zero here or on a wallet's own cap reads as "no ceiling" in - /// `ESIMWallet._requirePriceWithinCap`. + /// @dev Held here because there is no way to list every wallet and write into each one, so one + /// write here is how a change reaches all of them. Never zero: both setters reject it, + /// because zero on a wallet means "follow the registry" and would mean nothing here. uint64 public defaultPriceCapUSDCents; - /// @notice The protocol's authority on how a data bundle was paid for - /// @dev Holds the currency vocabulary and the spent payment references. This registry keeps a - /// pointer and a setter, the same shape it already uses for the lazy wallet registry. + /// @notice Contract holding the accepted currencies and the spent payment references + /// @dev A pointer and a setter, the same shape this registry already uses for the lazy wallet + /// registry. address public paymentAdapter; /// @notice Restricts a call to an eSIM wallet this registry has recorded @@ -359,10 +357,9 @@ contract Registry is emit DefaultPriceCapUSDCentsUpdated(_cap); } - /// @notice Points this registry at the contract that owns the currency vocabulary - /// @dev Owner and not admin. The adapter decides what may be paid in and holds the spent - /// payment references, so an admin able to re-point it could hand itself an empty set of - /// spent references and record every purchase again. + /// @notice Points this registry at the payment adapter + /// @dev Owner and not admin. The adapter holds the spent payment references, so an admin that + /// could swap it would get an empty set back and record every purchase a second time. /// @param _paymentAdapter Address of the payment adapter function setPaymentAdapter(address _paymentAdapter) external onlyOwner { if(_paymentAdapter == address(0)) revert Errors.ZeroAddress("_paymentAdapter"); @@ -373,32 +370,27 @@ contract Registry is } // --------------------------------------------------------------------------------------------- - // Purchases settled off the protocol's own rails + // Purchases paid for outside the protocol // --------------------------------------------------------------------------------------------- - /// @notice Spends a payment reference on behalf of an eSIM wallet paying through its own vault - /// @dev The adapter accepts this from the registry alone, so routing the wallet's call through - /// here keeps one reference from being spent once on each path. + /// @notice Spends a payment reference for an eSIM wallet buying with ETH + /// @dev The adapter only accepts this from the registry, so the wallet has to come through + /// here. One reference then cannot be spent once on each path. /// @param _paymentReference Hash tying the purchase to the offchain order behind it function consumePaymentReference(bytes32 _paymentReference) external onlyESIMWallet { _consumePaymentReference(_paymentReference); } - /// @notice Records a data bundle the user paid for somewhere the protocol cannot see - /// @dev The counterpart to `buyDataBundle`, for money that moved through Moonpay, a card or an - /// external wallet. No transfer happens here. What the admin asserts is bounded by the - /// price ceiling, by the payment reference being spendable once, and by the settlement - /// being anything other than `DeviceWallet`, which only `buyDataBundle` may claim. - /// - /// Written through this contract rather than by the adapter, because eSIM wallets accept - /// history from this address and nothing else. Giving them a second writer to trust is - /// what the routing in `populateLazyHistory` already exists to avoid. + /// @notice Records a data bundle paid for through Moonpay, a card or an external wallet + /// @dev No money moves here. Three things bound what the admin can state: the price ceiling, + /// the payment reference being spendable once, and the settlement not being + /// `DeviceWallet`. Written here and not by the adapter, because eSIM wallets accept + /// history from this address alone. /// @param _eSIMWallet Wallet the purchase belongs to /// @param _dataBundleDetail The purchase, priced in USD cents like everywhere else /// @param _asset Symbol of the currency the user paid in - /// @param _tokenAmount What the admin observed the user pay, in that currency's smallest unit. - /// Carried for reconciliation and never validated: it and the price both come from the - /// admin, so checking one against the other would be the admin checking itself. + /// @param _tokenAmount What the user actually paid, in that currency's smallest unit. Recorded + /// for offchain matching, never checked: it and the price both come from the admin. /// @param _paymentReference Hash of the Moonpay charge or the Stripe payment intent function recordSettledPurchase( address _eSIMWallet, @@ -414,8 +406,7 @@ contract Registry is if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); if(_dataBundleDetail.priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); - // Only the contract that saw the money reach the vault may say it did, and that contract is - // never this one. Without this the settlement field stops carrying the one thing it is for. + // This contract never sees the money move, so it must not be able to say it did. if(_dataBundleDetail.settlement == Settlement.DeviceWallet) { revert Errors.SettlementNotAsserted(); } @@ -439,11 +430,10 @@ contract Registry is ); } - /// @notice Refuses to append to a wallet the lazy registry has not finished copying history to - /// @dev Turns "call these three in this order" into something the contracts enforce. The - /// backend retries the whole onchain step on any failure, so an ordering rule that lives - /// only in an operational runbook does get violated, and the result is a transaction - /// history that is no longer in chronological order. + /// @notice Refuses a new entry while older history is still waiting to be copied in + /// @dev The new entry would land first and the older ones append after it, leaving the history + /// out of order. The backend retries the whole onchain step on failure, so this cannot be + /// left as an ordering rule for the caller to follow. /// @param _eSIMWallet Wallet being appended to function _requireLazyHistoryCopied(address _eSIMWallet) private view { if(lazyWalletRegistry == address(0)) return; @@ -455,12 +445,11 @@ contract Registry is if(outstanding != 0) revert Errors.HistoryNotFullyCopied(eSIMIdentifier, outstanding); } - /// @notice Spends a payment reference through the adapter function _consumePaymentReference(bytes32 _paymentReference) private { PaymentAdapter(_requirePaymentAdapter()).consumePaymentReference(_paymentReference); } - /// @notice The payment adapter, refusing to act while none is set + /// @notice The payment adapter, or a revert if none is set yet function _requirePaymentAdapter() private view returns (address) { address adapter = paymentAdapter; if(adapter == address(0)) revert Errors.PaymentAdapterNotSet(); diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index 805279c8..3cd9be5a 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -164,11 +164,9 @@ contract RegistryHelper { /// @notice Emitted when the owner points the registry at a payment adapter event PaymentAdapterUpdated(address indexed _paymentAdapter); - /// @notice Emitted for a purchase settled on a rail the protocol's contracts cannot see - /// @dev Emitted here rather than on the wallet so an indexer has one address to follow instead - /// of one per beacon proxy. `_tokenAmount` is what the admin observed the user pay, in - /// that currency's own smallest unit. Nothing validates it: both it and the price come - /// from the admin, so the price ceiling is the guard, not a comparison between the two. + /// @notice Emitted for a purchase paid for outside the protocol + /// @dev On the registry and not the wallet, so an indexer follows one address instead of one + /// per wallet. `_tokenAmount` is unchecked: it and the price both come from the admin. event DataBundleSettled( address indexed _eSIMWallet, bytes32 _dataBundleID, diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 7a827da1..c3af0d40 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -40,12 +40,9 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Most this wallet may be charged for one data bundle, in USD cents, or zero to follow /// the registry - /// @dev Declared here so it packs into the 12 spare bytes beside `newRequestedOwner`. Both are - /// cleared together on a handover, which makes that one SSTORE instead of two. Solidity - /// packs in declaration order, so moving this line costs the slot. - /// - /// Zero has to keep meaning "no limit of my own" rather than "no limit", which is why the - /// fallback lives on the registry rather than here. + /// @dev Declared here so it shares a slot with `newRequestedOwner`. Solidity packs in + /// declaration order, so moving this line costs that slot. A handover clears both, which + /// is then one write instead of two. Zero means "follow the registry", not "no ceiling". uint64 public priceCapUSDCents; /// @notice Emitted when the eSIM wallet is deployed @@ -64,9 +61,8 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade bytes32 indexed _paymentReference ); - /// @notice Emitted when a purchase settled somewhere the contracts cannot see is recorded here - /// @dev The registry emits the full record. This one exists so an indexer following a single - /// wallet sees the same entry the wallet's own history now holds. + /// @notice Emitted when a purchase paid for outside the protocol is recorded here + /// @dev The registry emits the full record. This one is for an indexer watching one wallet. event DataBundleSettlementRecorded( bytes32 _dataBundleID, uint64 _priceUSDCents, @@ -293,16 +289,14 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// knows the price. Any shortfall is pulled from the device wallet, which is why the price /// is checked against a ceiling the admin cannot raise. /// - /// The ceiling is in cents and the money that moves is in wei, and with no rate onchain - /// nothing here relates the two. So the ceiling bounds what may be recorded, not what may - /// be sent, and `_priceWei` is the admin's figure taken as given. That gap closes when the - /// ETH path is replaced by the token path, where the amount is derived from the price - /// rather than stated alongside it. - /// @param _dataBundleDetail Data bundle being bought. Its settlement field is ignored: this - /// function is the only one that can prove the money moved, so it fills that in itself. + /// The ceiling is in cents and the ETH sent is in wei, and nothing onchain converts + /// between them. So the ceiling limits what gets recorded, not what gets sent, and + /// `_priceWei` is taken on trust. The token path closes that gap by working the amount + /// out from the price instead. + /// @param _dataBundleDetail Data bundle being bought. Its settlement field is overwritten here. /// @param _priceWei ETH actually being sent to the vault - /// @param _paymentReference Ties this purchase to the offchain order, and is spent once, so a - /// retry of a call that already landed cannot charge the user twice + /// @param _paymentReference The offchain order id. Spent once, so a retry of a call that + /// already landed cannot charge the user twice. /// @return True if the transaction is successful function buyDataBundle( DataBundleDetails memory _dataBundleDetail, @@ -316,15 +310,11 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade if(_priceWei == 0) revert Errors.ZeroDataBundlePrice(); _requirePriceWithinCap(_dataBundleDetail.priceUSDCents, registry); - // The one path where the protocol sees the money reach the vault, so it is the one path - // allowed to say so. Overwritten rather than validated: the caller has no say either way. + // This is the only path that sees the money reach the vault, so it sets this itself. _dataBundleDetail.settlement = Settlement.DeviceWallet; registry.consumePaymentReference(_paymentReference); - // 1. msg.value is received by contract - // 2. if wallet balance is less than the price, pull ETH from device wallet - // 3. send the price to the vault uint256 walletBalance = address(this).balance; if (walletBalance < _priceWei) { @@ -350,10 +340,9 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade return true; } - /// @notice Appends a purchase the registry has already validated and settled elsewhere - /// @dev No money moves here. Everything that guards this entry, the price ceiling, the payment - /// reference and the rule that the settlement cannot claim to be witnessed, is checked on - /// the registry before the call arrives. + /// @notice Appends a purchase the registry has already checked + /// @dev No money moves here. The price ceiling, the payment reference and the settlement rule + /// are all checked on the registry before this call arrives. /// @param _dataBundleDetail The purchase to record function recordSettledPurchase(DataBundleDetails calldata _dataBundleDetail) external onlyRegistry { transactionHistory.push(_dataBundleDetail); diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index f7ec9069..87d992ef 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -10,9 +10,9 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; /// @notice One currency the protocol will price a data bundle in -/// @dev 23 bytes, so the whole struct is one slot with nine to spare. Fields may be added inside -/// those nine bytes. Growing past one slot changes the slot stride of every entry in the -/// mapping, and a live table has no migration path for that. +/// @dev 23 bytes, so it fits one slot with nine to spare. New fields have to stay inside those +/// nine bytes: a second slot moves every entry in the mapping, and a live table cannot be +/// moved. struct Asset { bool allowed; bool isDollarUnit; // USDC, USDT, DAI and USD are true. ETH, TON and ZEC are not @@ -20,42 +20,34 @@ struct Asset { address token; // ERC-20 address, or zero for fiat and non-EVM assets } -/// @notice The protocol's authority on how a data bundle was paid for -/// @dev Holds the currency vocabulary, turns a cent price into a token amount, and spends the -/// payment references that make a purchase impossible to record twice. It holds no purchase -/// records: those stay on the eSIM wallet that made them. -/// -/// Prices cross every contract boundary in USD cents and nowhere else. This contract is the -/// only place a cent figure becomes a token amount, which is what makes a decimal mismatch -/// impossible rather than merely something to check for. There are no price feeds anywhere in -/// the protocol, so an asset that is not already denominated in dollars has no answer here. -/// -/// UUPS rather than a contract the registry can be re-pointed at. `usedReferences` is replay -/// protection, and swapping it out for an empty one re-opens every reference already spent. +/// @notice Holds the accepted currencies, converts prices, and spends payment references +/// @dev Prices cross contract boundaries in USD cents only, and this is the one place a cent +/// figure becomes a token amount, so a decimals mismatch cannot happen rather than having to +/// be checked for. There are no price feeds, so a currency not already in dollars has no +/// conversion here. UUPS and not swappable: `usedReferences` is replay protection, and a +/// fresh copy would re-open every reference already spent. contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { /// @notice Cents per dollar, the divisor that takes a cent price down to whole units uint256 private constant CENTS_PER_DOLLAR = 100; - /// @notice Fewest decimals an asset may have before `quote` starts truncating - /// @dev The scale is `10 ** decimals / 100`, so anything under two decimals loses the cents. + /// @notice Fewest decimals a currency may have. `quote` divides by 100, so fewer loses the cents. uint8 private constant MIN_ASSET_DECIMALS = 2; /// @notice Registry contract address, the only caller allowed to spend a payment reference address public registry; /// @notice Currency the vault is meant to end up holding - /// @dev Read by nothing today. It is set at initialisation anyway so that adding the swap path - /// later is an implementation change rather than a migration transaction on every chain. + /// @dev Nothing reads it yet. Set at initialisation anyway, so adding the swap path later needs + /// no migration transaction on every chain. address public settlementToken; /// @notice Every currency the protocol will accept or record a payment in mapping(bytes32 symbol => Asset asset) public assets; /// @notice Payment references already spent, protocol-wide - /// @dev One reference is one offchain payment. Spending it is what makes recording a purchase - /// safe to retry: the backend retries the whole onchain step on any failure, so without - /// this a retry of a call that already landed writes the purchase a second time. + /// @dev One reference is one offchain payment. The backend retries the whole onchain step on + /// any failure, so without this a retry of a call that already landed records it twice. mapping(bytes32 paymentReference => bool used) public usedReferences; /// @notice Emitted when this contract is wired up @@ -116,8 +108,8 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab // --------------------------------------------------------------------------------------------- /// @notice Adds a currency the protocol will accept or record a payment in - /// @dev Owner and not admin. The admin names the price on every purchase, so letting it also - /// name the currencies would let it invent a token address to be paid in. + /// @dev Owner and not admin. The admin names the price on every purchase, so letting it add + /// currencies too would let it invent a token address to be paid into. /// @param _symbol Short ASCII symbol, "USDC" or "USD" or "TON" /// @param _asset The entry to write function registerAsset(bytes32 _symbol, Asset calldata _asset) external onlyOwner { @@ -141,13 +133,10 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab // Pricing // --------------------------------------------------------------------------------------------- - /// @notice Turns a price in USD cents into an amount of one currency's own smallest unit - /// @dev The only place in the protocol that does this. No caller ever states a token amount as - /// an authoritative figure, so there is no second number anywhere to reconcile this one - /// against, and no tolerance band to check it inside. - /// - /// An asset that is not denominated in dollars has no answer without a rate, and there is - /// no oracle here, so it reverts rather than guessing. + /// @notice Turns a price in USD cents into an amount of one currency's smallest unit + /// @dev The only place in the protocol that does this, so there is never a second figure to + /// check this one against. A currency not already in dollars needs a rate, and there are + /// no price feeds here, so it reverts instead of guessing. /// @param _symbol Currency the price is being expressed in /// @param _priceUSDCents Price in USD cents /// @return amountIn Amount in that currency's own smallest unit @@ -160,9 +149,9 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab return (uint256(_priceUSDCents) * 10 ** asset.decimals) / CENTS_PER_DOLLAR; } - /// @notice Reads back a currency entry, reverting if it was never registered - /// @dev Callers resolve the token address and the decimals through here rather than stating - /// them, which is what keeps them consistent across every record the protocol writes. + /// @notice Reads back a currency entry, reverting if it is not allowed + /// @dev Callers read the token address and decimals from here rather than passing them in, so + /// they stay the same across every record. /// @param _symbol Currency to read /// @return The stored entry function resolveAsset(bytes32 _symbol) external view returns (Asset memory) { @@ -177,8 +166,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab // --------------------------------------------------------------------------------------------- /// @notice Spends a payment reference, refusing one already spent - /// @dev Registry only, on both the witnessed and the asserted path, so one reference cannot be - /// spent once on each. + /// @dev Registry only, on both payment paths, so one reference cannot be spent once on each. /// @param _paymentReference Hash tying this purchase to the offchain payment behind it function consumePaymentReference(bytes32 _paymentReference) external onlyRegistry { if(_paymentReference == bytes32(0)) revert Errors.EmptyPaymentReference(); @@ -195,7 +183,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @notice Ownership of this contract is never renounced /// @dev The owner is the only caller `_authorizeUpgrade` accepts and the only one that can - /// change the currency vocabulary. Renouncing would freeze both for good. + /// change the list of currencies. Renouncing would freeze both for good. function renounceOwnership() public pure override { revert Errors.OwnershipCannotBeRenounced(); } @@ -219,9 +207,8 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab // --------------------------------------------------------------------------------------------- /// @notice Validates and stores one currency entry - /// @dev `decimals` doubles as the "was this ever registered" marker, so it is non-zero on every - /// entry including a withdrawn one. That is why a withdrawal sets `allowed` to false - /// rather than deleting the row. + /// @dev A non-zero `decimals` is what marks a symbol as registered, so withdrawing a currency + /// sets `allowed` to false instead of deleting the entry. function _writeAsset(bytes32 _symbol, Asset calldata _asset) private { if(_symbol == bytes32(0)) revert Errors.EmptyAssetSymbol(); if(_asset.decimals < MIN_ASSET_DECIMALS) { diff --git a/env.sample b/env.sample index 9c6abec6..e2f079f1 100644 --- a/env.sample +++ b/env.sample @@ -32,8 +32,8 @@ TIMELOCK_GUARDIANS= # every proposer can already cancel. TIMELOCK_CANCELLERS= -# Ceiling on a single data bundle purchase, in USD cents. 100000 reads as $1000. Rejected if zero: -# the registry reads zero as no ceiling at all. +# Ceiling on a single data bundle purchase, in USD cents. 100000 reads as $1000. Rejected if +# zero, because zero on a wallet means "follow the registry" and would mean nothing here. PRICE_CAP_USD_CENTS= # Currency the vault is meant to end up holding, normally USDC on the target chain. diff --git a/scripts/deploy/Deploy.s.sol b/scripts/deploy/Deploy.s.sol index 69b7fcde..c3329bf4 100644 --- a/scripts/deploy/Deploy.s.sol +++ b/scripts/deploy/Deploy.s.sol @@ -166,8 +166,8 @@ contract Deploy is Script { ) ); - // The registry refuses to record a settled purchase until it has an adapter to resolve the - // currency through, so the pointer is part of the deployment rather than of configuration. + // The registry cannot record a purchase without an adapter to read the currency from, so + // the pointer is part of the deployment rather than of configuration. Registry(deployed.registry).setPaymentAdapter(deployed.paymentAdapter); } diff --git a/scripts/deploy/config/DeployConfig.sol b/scripts/deploy/config/DeployConfig.sol index 05ab07d8..e4929b9c 100644 --- a/scripts/deploy/config/DeployConfig.sol +++ b/scripts/deploy/config/DeployConfig.sol @@ -100,8 +100,8 @@ library DeployConfig { config.priceCapUSDCents = uint64(vm.envUint("PRICE_CAP_USD_CENTS")); if(config.priceCapUSDCents == 0) revert MissingValue("PRICE_CAP_USD_CENTS"); - // The currency the vault is meant to hold. Nothing reads it until swaps exist, but the - // adapter takes it at initialisation so adding them later needs no migration transaction. + // Nothing reads this until swaps exist, but the adapter takes it at initialisation so + // adding them later needs no migration transaction. config.settlementToken = vm.envAddress("SETTLEMENT_TOKEN"); if(config.settlementToken == address(0)) revert MissingAddress("SETTLEMENT_TOKEN"); diff --git a/test/foundry/fuzz-testing/ETHAmounts.t.sol b/test/foundry/fuzz-testing/ETHAmounts.t.sol index b1401dfb..0f1a239d 100644 --- a/test/foundry/fuzz-testing/ETHAmounts.t.sol +++ b/test/foundry/fuzz-testing/ETHAmounts.t.sol @@ -15,8 +15,8 @@ import {FuzzBase} from "test/foundry/fuzz-testing/base/FuzzBase.sol"; /// price cap is honoured whichever of its two levels supplies it. And an amount larger than /// the balance behind it fails rather than moving a partial amount. /// -/// The ceiling is in cents and the ETH that moves is in wei, so the two are fuzzed -/// independently. Nothing onchain relates them without a rate. +/// The ceiling is in cents and the ETH is in wei, so the two are fuzzed separately. Nothing +/// onchain converts between them. /// /// The cap resolution is the part worth fuzzing rather than enumerating: the wallet's own cap /// wins when set, and the registry default applies when it is not. Zero on the wallet still @@ -27,7 +27,7 @@ contract ETHAmountsTest is FuzzBase { /// @dev Keeps fuzzed amounts inside what vm.deal can fund without the totals overflowing uint256 private constant MAX_FUZZED_ETH = 1_000_000 ether; - /// @dev $10,000,000 in cents. Large enough to exercise the ceiling, small enough to read. + /// @dev $10,000,000 in cents uint64 private constant MAX_FUZZED_CENTS = 1_000_000_000; function setUp() public override { diff --git a/test/foundry/invariant-testing/base/InvariantBase.sol b/test/foundry/invariant-testing/base/InvariantBase.sol index b9104aa8..64fde5d1 100644 --- a/test/foundry/invariant-testing/base/InvariantBase.sol +++ b/test/foundry/invariant-testing/base/InvariantBase.sol @@ -133,8 +133,8 @@ contract InvariantBase is Test { vm.startPrank(UPGRADE_MANAGER); registry.addOrUpdateLazyWalletRegistryAddress(address(lazyWalletRegistry)); registry.setPaymentAdapter(address(paymentAdapter)); - // A purchase spends a payment reference through the adapter, so a campaign with no - // currencies registered would see every purchase revert before it reached anything else. + // Every purchase spends a payment reference through the adapter, so with no currencies + // registered a campaign would see all of them revert before reaching anything else. paymentAdapter.registerAsset("USD", Asset({ allowed: true, isDollarUnit: true, diff --git a/test/foundry/invariant-testing/handler/AdminHandler.sol b/test/foundry/invariant-testing/handler/AdminHandler.sol index 26f24d51..3246b068 100644 --- a/test/foundry/invariant-testing/handler/AdminHandler.sol +++ b/test/foundry/invariant-testing/handler/AdminHandler.sol @@ -16,9 +16,8 @@ import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; /// is checking is whether that reach stops where the contracts say it stops. contract AdminHandler is HandlerBase { - /// @dev A payment reference is spendable once protocol-wide, so every purchase in a run - /// needs a fresh one or the second call reverts on replay rather than on what is - /// being tested. + /// @dev A reference can only be spent once, so without a fresh one per purchase the second + /// call reverts on replay instead of on what is being tested. uint256 private _refNonce; constructor(HandlerConfig memory config) HandlerBase(config) {} @@ -149,10 +148,9 @@ contract AdminHandler is HandlerBase { } /// @notice The admin charges an eSIM wallet for a data bundle - /// @dev Both figures are unbounded upward on purpose. The ceiling is the only thing standing - /// between the admin and a wallet's whole balance, so a run has to reach past it. They are - /// fuzzed separately because the ceiling is in cents and the ETH that moves is in wei, and - /// no contract relates the two. + /// @dev Both figures reach past the ceiling on purpose, since the ceiling is the only thing + /// between the admin and a wallet's whole balance. Fuzzed separately because the ceiling + /// is in cents and the ETH is in wei, and nothing converts between them. /// @param eSIMIndex Which eSIM wallet pays /// @param priceUSDCents What the purchase is recorded at /// @param priceWei What the wallet actually sends the vault diff --git a/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol b/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol index 05ff12c2..873fc47f 100644 --- a/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol +++ b/test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol @@ -106,8 +106,7 @@ contract UpgradeManagerHandler is HandlerBase { /// charge, so the ceiling is sometimes binding and sometimes not. /// @param seed Chooses the ceiling function setDefaultPriceCap(uint256 seed) external counted { - // Zero is in the ladder deliberately: the registry refuses it, and a run has to reach - // that refusal rather than only ever offering values it accepts. + // Zero is in the ladder so a run reaches the registry's refusal of it. uint64[4] memory ladder = [uint64(0), 1, 1_000, 100_000_000]; uint64 cap = ladder[bound(seed, 0, 3)]; diff --git a/test/foundry/invariant-testing/handler/WalletHandler.sol b/test/foundry/invariant-testing/handler/WalletHandler.sol index 164d5317..7f4f88a9 100644 --- a/test/foundry/invariant-testing/handler/WalletHandler.sol +++ b/test/foundry/invariant-testing/handler/WalletHandler.sol @@ -269,8 +269,7 @@ contract WalletHandler is HandlerBase { state.recordRevert("setESIMWalletPriceCap"); return; } - // Zero, one cent, ten dollars, a million dollars. Zero is in the ladder because it is - // the value that hands the wallet back to the registry ceiling. + // Zero is in the ladder because it is what hands the wallet back to the registry ceiling. uint64[4] memory ladder = [uint64(0), 1, 1_000, 100_000_000]; vm.prank(device); diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol index 27ec388f..63268fa6 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol @@ -64,7 +64,8 @@ contract ESIMWalletGuardsTest is DeployerBase { } /// @notice Buys a bundle at the given recorded price as the admin - /// @dev The ETH figure is fixed. The ceiling bounds the recorded price, not the amount sent. + /// @dev The ETH figure is fixed, since the ceiling limits the recorded price and not the + /// amount sent. /// @param _priceUSDCents The price to record function _buyAt(uint64 _priceUSDCents) internal { vm.prank(eSIMWalletAdmin); diff --git a/test/utils/DeployerBase.sol b/test/utils/DeployerBase.sol index 507fe768..137a71aa 100644 --- a/test/utils/DeployerBase.sol +++ b/test/utils/DeployerBase.sol @@ -71,8 +71,8 @@ contract DeployerBase is Test { bytes32 constant ASSET_USD = bytes32("USD"); bytes32 constant ASSET_ETH = bytes32("ETH"); - // Stands in wherever a test is about something other than the recorded price. Well under - // the registry default, so it never trips the cents ceiling by accident. + // Used wherever a test is about something other than the recorded price. Well under the + // registry default, so it never trips the ceiling by accident. uint64 constant TEST_PRICE_CENTS = 100; // $1.00 MockEntryPoint entryPoint; @@ -215,8 +215,8 @@ contract DeployerBase is Test { } /// @notice Registers the three currencies the suite prices in - /// @dev USD is the fiat entry, record-only with no token behind it. ETH is registered but not - /// denominated in dollars, which is what makes it the case `quote` has to refuse. + /// @dev USD has no token address, so nothing can be transferred in it. ETH is allowed but is + /// not in dollars, which makes it the case `quote` has to refuse. function registerDefaultAssets() public { paymentAdapter.registerAsset(ASSET_USDC, Asset({ allowed: true, @@ -238,9 +238,8 @@ contract DeployerBase is Test { })); } - /// @notice One purchase the admin asserts happened on a rail the contracts cannot see - /// @dev `buyDataBundle` overwrites the settlement with `DeviceWallet` itself, so this shape - /// suits both paths and neither has to state it. + /// @notice One purchase the admin says was paid for outside the protocol + /// @dev `buyDataBundle` overwrites the settlement itself, so this shape suits both paths. function bundle(bytes32 _id, uint64 _priceUSDCents) internal pure returns (DataBundleDetails memory) { return DataBundleDetails({ id: _id, @@ -255,8 +254,8 @@ contract DeployerBase is Test { } /// @notice A payment reference no earlier call in this test has spent - /// @dev A reference is spendable once protocol-wide, so any test buying more than one bundle - /// needs a fresh one each time. Tests about replay pass the same reference deliberately. + /// @dev A reference can only be spent once, so a test buying more than one bundle needs a + /// fresh one each time. Replay tests pass the same reference on purpose. function nextRef() internal returns (bytes32) { return keccak256(abi.encode("ref", ++_refNonce)); } From 0cf13c32b403d6222817f832de37973060c17c97 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 18:29:11 +0530 Subject: [PATCH 09/75] Test the currency table, cent conversion and payment references --- .../payments/PaymentAdapter.t.sol | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 test/foundry/unit-testing/payments/PaymentAdapter.t.sol diff --git a/test/foundry/unit-testing/payments/PaymentAdapter.t.sol b/test/foundry/unit-testing/payments/PaymentAdapter.t.sol new file mode 100644 index 00000000..34d2d91c --- /dev/null +++ b/test/foundry/unit-testing/payments/PaymentAdapter.t.sol @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "forge-std/Test.sol"; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import {Errors} from "contracts/Errors.sol"; +import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; + +import "test/utils/DeployerBase.sol"; + +/// @notice Covers the currency table, the cent to token conversion, and the payment references. +/// @dev The adapter that `DeployerBase` deploys already carries USDC, USD and ETH, so the tests +/// below reuse those three rather than registering their own. USD is the 2-decimal fiat entry +/// and ETH is the entry `quote` has to refuse. +contract PaymentAdapterTest is DeployerBase { + + bytes32 constant ASSET_DAI = bytes32("DAI"); + bytes32 constant ASSET_TON = bytes32("TON"); + + address daiToken = address(0x6B175474E89094C44Da98b954EedeAC495271d0F); + + /// @notice A dollar-denominated entry with the given decimals + function _dollarAsset(uint8 _decimals, address _token) internal pure returns (Asset memory) { + return Asset({allowed: true, isDollarUnit: true, decimals: _decimals, token: _token}); + } + + /// @notice Spends a reference the way the registry does + function _consumeAsRegistry(bytes32 _paymentReference) internal { + vm.prank(address(registry)); + paymentAdapter.consumePaymentReference(_paymentReference); + } + + /// @notice Reads one field back, since the public getter returns the struct flattened + function _allowedOf(bytes32 _symbol) internal view returns (bool allowed) { + (allowed,,,) = paymentAdapter.assets(_symbol); + } + + // --------------------------------------------------------------------------------------------- + // Initialisation + // --------------------------------------------------------------------------------------------- + + /// @notice The adapter knows the registry, the settlement token and its owner + function test_initialize_storesTheAddressesItWasGiven() public view { + assertEq(paymentAdapter.registry(), address(registry), "Registry address must be stored"); + assertEq(paymentAdapter.settlementToken(), settlementToken, "Settlement token must be stored"); + assertEq(paymentAdapter.owner(), upgradeManager, "Upgrade manager must own the adapter"); + } + + /// @notice The upgrade authority is read from the owner, not from a second copy + function test_upgradeManager_matchesTheOwner() public view { + assertEq(paymentAdapter.upgradeManager(), paymentAdapter.owner(), "Both must name one address"); + } + + /// @notice An adapter without a registry could never spend a reference + function test_initialize_rejectsAZeroRegistry() public { + PaymentAdapter implementation = new PaymentAdapter(); + + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, "_registry")); + new ERC1967Proxy( + address(implementation), + abi.encodeCall(implementation.initialize, (address(0), settlementToken, upgradeManager)) + ); + } + + /// @notice The settlement token is fixed at initialisation, so a zero cannot be corrected later + function test_initialize_rejectsAZeroSettlementToken() public { + PaymentAdapter implementation = new PaymentAdapter(); + + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, "_settlementToken")); + new ERC1967Proxy( + address(implementation), + abi.encodeCall(implementation.initialize, (address(registry), address(0), upgradeManager)) + ); + } + + /// @notice A zero owner would leave the currency table and the upgrade path both closed + function test_initialize_rejectsAZeroUpgradeManager() public { + PaymentAdapter implementation = new PaymentAdapter(); + + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, "_upgradeManager")); + new ERC1967Proxy( + address(implementation), + abi.encodeCall(implementation.initialize, (address(registry), settlementToken, address(0))) + ); + } + + /// @notice The live adapter cannot be initialised a second time + function test_initialize_cannotRunTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + paymentAdapter.initialize(address(registry), settlementToken, upgradeManager); + } + + /// @notice The implementation contract is locked, so nobody can own the address the proxy + /// delegates into + function test_implementationCannotBeInitialized() public { + PaymentAdapter implementation = new PaymentAdapter(); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + implementation.initialize(address(registry), settlementToken, upgradeManager); + + assertEq(implementation.owner(), address(0), "Implementation must have no owner"); + } + + // --------------------------------------------------------------------------------------------- + // Registering a currency + // --------------------------------------------------------------------------------------------- + + /// @notice A new currency is stored and announced + function test_registerAsset_storesTheEntry() public { + vm.expectEmit(true, true, true, true); + emit PaymentAdapter.AssetUpdated(ASSET_DAI, true, true, 18, daiToken); + + vm.prank(upgradeManager); + paymentAdapter.registerAsset(ASSET_DAI, _dollarAsset(18, daiToken)); + + (bool allowed, bool isDollarUnit, uint8 decimals, address token) = paymentAdapter.assets(ASSET_DAI); + assertTrue(allowed, "The new currency must be allowed"); + assertTrue(isDollarUnit, "The new currency must be marked as a dollar unit"); + assertEq(decimals, 18, "Decimals must be stored"); + assertEq(token, daiToken, "Token address must be stored"); + } + + /// @notice Only the owner adds currencies + /// @dev The admin names the price on every purchase. One that could add a currency too would + /// be naming the token address it gets paid into. + function test_registerAsset_rejectsTheAdmin() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", eSIMWalletAdmin)); + paymentAdapter.registerAsset(ASSET_DAI, _dollarAsset(18, daiToken)); + } + + /// @notice A symbol already in the table cannot be registered over + /// @dev Overwriting is `updateAsset`, so a typo cannot land on a currency already in use. + function test_registerAsset_rejectsASymbolAlreadyRegistered() public { + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetAlreadyRegistered.selector, ASSET_USDC)); + paymentAdapter.registerAsset(ASSET_USDC, _dollarAsset(6, settlementToken)); + } + + /// @notice An empty symbol is refused + function test_registerAsset_rejectsAnEmptySymbol() public { + vm.prank(upgradeManager); + vm.expectRevert(Errors.EmptyAssetSymbol.selector); + paymentAdapter.registerAsset(bytes32(0), _dollarAsset(6, settlementToken)); + } + + /// @notice Fewer than two decimals cannot carry a cent + /// @dev `quote` divides by 100, so a one-decimal currency would round every price down to a + /// figure the user was never charged. + function test_registerAsset_rejectsFewerThanTwoDecimals() public { + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetDecimalsTooLow.selector, ASSET_DAI, uint8(1))); + paymentAdapter.registerAsset(ASSET_DAI, _dollarAsset(1, daiToken)); + } + + /// @notice Zero decimals is refused for the same reason + /// @dev Also the value that marks a symbol as unregistered, so accepting it would leave an + /// entry `updateAsset` could never reach. + function test_registerAsset_rejectsZeroDecimals() public { + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetDecimalsTooLow.selector, ASSET_DAI, uint8(0))); + paymentAdapter.registerAsset(ASSET_DAI, _dollarAsset(0, daiToken)); + } + + // --------------------------------------------------------------------------------------------- + // Changing a currency + // --------------------------------------------------------------------------------------------- + + /// @notice An existing entry can be rewritten + function test_updateAsset_rewritesTheEntry() public { + vm.prank(upgradeManager); + paymentAdapter.updateAsset(ASSET_USDC, _dollarAsset(18, daiToken)); + + (,, uint8 decimals, address token) = paymentAdapter.assets(ASSET_USDC); + assertEq(decimals, 18, "Decimals must be rewritten"); + assertEq(token, daiToken, "Token address must be rewritten"); + } + + /// @notice Withdrawing a currency leaves the entry in place with `allowed` false + /// @dev Deleting it would set decimals back to zero, which reads as never registered, and + /// `registerAsset` would then accept the symbol again. + function test_updateAsset_withdrawsACurrency() public { + vm.prank(upgradeManager); + paymentAdapter.updateAsset(ASSET_USDC, Asset({ + allowed: false, + isDollarUnit: true, + decimals: 6, + token: settlementToken + })); + + assertFalse(_allowedOf(ASSET_USDC), "The currency must no longer be allowed"); + + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetAlreadyRegistered.selector, ASSET_USDC)); + paymentAdapter.registerAsset(ASSET_USDC, _dollarAsset(6, settlementToken)); + } + + /// @notice Only the owner changes currencies + function test_updateAsset_rejectsTheAdmin() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", eSIMWalletAdmin)); + paymentAdapter.updateAsset(ASSET_USDC, _dollarAsset(6, settlementToken)); + } + + /// @notice A symbol that was never registered cannot be updated + function test_updateAsset_rejectsAnUnregisteredSymbol() public { + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotRegistered.selector, ASSET_TON)); + paymentAdapter.updateAsset(ASSET_TON, _dollarAsset(9, address(0))); + } + + /// @notice The decimals floor applies to a change as well as to a new entry + function test_updateAsset_rejectsFewerThanTwoDecimals() public { + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetDecimalsTooLow.selector, ASSET_USDC, uint8(1))); + paymentAdapter.updateAsset(ASSET_USDC, _dollarAsset(1, settlementToken)); + } + + // --------------------------------------------------------------------------------------------- + // Pricing + // --------------------------------------------------------------------------------------------- + + /// @notice A dollar in cents is a dollar in USDC + function test_quote_convertsCentsToSixDecimals() public view { + assertEq(paymentAdapter.quote(ASSET_USDC, 100), 1e6, "$1.00 must be 1 USDC"); + } + + /// @notice The smallest price the protocol can express survives the conversion + function test_quote_keepsASingleCent() public view { + assertEq(paymentAdapter.quote(ASSET_USDC, 1), 10_000, "One cent must be 0.01 USDC"); + } + + /// @notice A two-decimal fiat entry converts one to one + function test_quote_convertsCentsToTwoDecimals() public view { + assertEq(paymentAdapter.quote(ASSET_USD, 1234), 1234, "Cents and a 2-decimal unit are the same"); + } + + /// @notice Zero cents quotes zero rather than reverting + /// @dev The zero price check belongs to the purchase paths, which reject it before quoting. + function test_quote_returnsZeroForAZeroPrice() public view { + assertEq(paymentAdapter.quote(ASSET_USDC, 0), 0, "Zero cents must quote zero"); + } + + /// @notice A currency the protocol does not know cannot be priced + function test_quote_rejectsAnUnregisteredCurrency() public { + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, ASSET_TON)); + paymentAdapter.quote(ASSET_TON, 100); + } + + /// @notice A withdrawn currency stops quoting immediately + function test_quote_rejectsAWithdrawnCurrency() public { + vm.prank(upgradeManager); + paymentAdapter.updateAsset(ASSET_USDC, Asset({ + allowed: false, + isDollarUnit: true, + decimals: 6, + token: settlementToken + })); + + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, ASSET_USDC)); + paymentAdapter.quote(ASSET_USDC, 100); + } + + /// @notice A currency that is not already in dollars needs a rate this contract does not have + function test_quote_rejectsACurrencyThatNeedsARate() public { + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNeedsSwap.selector, ASSET_ETH)); + paymentAdapter.quote(ASSET_ETH, 100); + } + + // --------------------------------------------------------------------------------------------- + // Reading a currency back + // --------------------------------------------------------------------------------------------- + + /// @notice Callers get the decimals and token address from the table, not from their own input + function test_resolveAsset_returnsTheStoredEntry() public view { + Asset memory asset = paymentAdapter.resolveAsset(ASSET_USDC); + + assertTrue(asset.allowed, "USDC must be allowed"); + assertTrue(asset.isDollarUnit, "USDC must be marked as a dollar unit"); + assertEq(asset.decimals, 6, "USDC must have six decimals"); + assertEq(asset.token, settlementToken, "USDC must carry its token address"); + } + + /// @notice A currency that needs a rate still resolves, since only pricing needs the rate + function test_resolveAsset_returnsACurrencyThatNeedsARate() public view { + Asset memory asset = paymentAdapter.resolveAsset(ASSET_ETH); + + assertFalse(asset.isDollarUnit, "ETH must not be marked as a dollar unit"); + assertEq(asset.decimals, 18, "ETH must have eighteen decimals"); + } + + /// @notice An unknown currency cannot be resolved + function test_resolveAsset_rejectsAnUnregisteredCurrency() public { + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, ASSET_TON)); + paymentAdapter.resolveAsset(ASSET_TON); + } + + // --------------------------------------------------------------------------------------------- + // Payment references + // --------------------------------------------------------------------------------------------- + + /// @notice Spending a reference marks it and announces it + function test_consumePaymentReference_marksTheReference() public { + bytes32 orderRef = paymentRef("first-order"); + assertFalse(paymentAdapter.usedReferences(orderRef), "A fresh reference must be unspent"); + + vm.expectEmit(true, true, true, true); + emit PaymentAdapter.PaymentReferenceConsumed(orderRef); + _consumeAsRegistry(orderRef); + + assertTrue(paymentAdapter.usedReferences(orderRef), "The reference must now be spent"); + } + + /// @notice The same reference cannot be spent twice + /// @dev The backend retries the whole onchain step on any failure, so without this a retry of + /// a call that already landed would record the purchase a second time. + function test_consumePaymentReference_rejectsAReferenceAlreadySpent() public { + bytes32 orderRef = paymentRef("retried-order"); + _consumeAsRegistry(orderRef); + + vm.prank(address(registry)); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, orderRef)); + paymentAdapter.consumePaymentReference(orderRef); + } + + /// @notice An empty reference is refused + /// @dev It ties nothing to an offchain payment, and the first caller to pass one would close + /// the value for everyone after it. + function test_consumePaymentReference_rejectsAnEmptyReference() public { + vm.prank(address(registry)); + vm.expectRevert(Errors.EmptyPaymentReference.selector); + paymentAdapter.consumePaymentReference(bytes32(0)); + } + + /// @notice Only the registry spends references + /// @dev Both purchase paths go through the registry, so one reference cannot be spent once on + /// each of them. + function test_consumePaymentReference_rejectsAnyoneButTheRegistry() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.OnlyRegistry.selector); + paymentAdapter.consumePaymentReference(paymentRef("admin-order")); + } + + /// @notice Not even the owner spends a reference + function test_consumePaymentReference_rejectsTheOwner() public { + vm.prank(upgradeManager); + vm.expectRevert(Errors.OnlyRegistry.selector); + paymentAdapter.consumePaymentReference(paymentRef("owner-order")); + } + + // --------------------------------------------------------------------------------------------- + // Ownership and upgrades + // --------------------------------------------------------------------------------------------- + + /// @notice Ownership cannot be given up + /// @dev The owner is the only address that can change the currency table or upgrade this + /// contract, so renouncing would freeze both for good. + function test_renounceOwnership_alwaysReverts() public { + vm.prank(upgradeManager); + vm.expectRevert(Errors.OwnershipCannotBeRenounced.selector); + paymentAdapter.renounceOwnership(); + } + + /// @notice Only the owner upgrades the adapter + function test_upgrade_rejectsAnyoneButTheOwner() public { + PaymentAdapter newImplementation = new PaymentAdapter(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", eSIMWalletAdmin)); + UUPSUpgradeable(address(paymentAdapter)).upgradeToAndCall(address(newImplementation), ""); + } + + /// @notice An upgrade keeps the spent references + /// @dev A fresh table would re-open every reference already spent, which is why this contract + /// is upgraded rather than replaced. + function test_upgrade_keepsTheSpentReferences() public { + bytes32 orderRef = paymentRef("pre-upgrade-order"); + _consumeAsRegistry(orderRef); + + PaymentAdapter newImplementation = new PaymentAdapter(); + + vm.prank(upgradeManager); + UUPSUpgradeable(address(paymentAdapter)).upgradeToAndCall(address(newImplementation), ""); + + assertTrue(paymentAdapter.usedReferences(orderRef), "The reference must still read as spent"); + } +} From 017fb19b090db685395f8d467e181cb099f9ad85 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 18:35:01 +0530 Subject: [PATCH 10/75] Hold settled purchases to the same price ceiling as bought ones 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. --- contracts/esim-wallet/ESIMWallet.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index c3af0d40..e2f50ab0 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -340,11 +340,15 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade return true; } - /// @notice Appends a purchase the registry has already checked - /// @dev No money moves here. The price ceiling, the payment reference and the settlement rule - /// are all checked on the registry before this call arrives. + /// @notice Appends a purchase paid for outside the protocol + /// @dev No money moves here. Nothing onchain saw this payment, so the ceiling is the only + /// limit on what the admin can write into a user's history. Checked here and not on the + /// registry because the wallet's own ceiling lives here. /// @param _dataBundleDetail The purchase to record function recordSettledPurchase(DataBundleDetails calldata _dataBundleDetail) external onlyRegistry { + // The modifier has already checked the caller is the registry + _requirePriceWithinCap(_dataBundleDetail.priceUSDCents, Registry(msg.sender)); + transactionHistory.push(_dataBundleDetail); emit DataBundleSettlementRecorded( From 96c0836ffe883820c3905d8d5f8508627db96111 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 18:35:01 +0530 Subject: [PATCH 11/75] Test settled purchases, the ordering guard and reference reuse --- .../registry/SettledPurchase.t.sol | 454 ++++++++++++++++++ .../upgrade/ImplementationLocks.t.sol | 2 +- 2 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 test/foundry/unit-testing/registry/SettledPurchase.t.sol diff --git a/test/foundry/unit-testing/registry/SettledPurchase.t.sol b/test/foundry/unit-testing/registry/SettledPurchase.t.sol new file mode 100644 index 00000000..0ab7db6d --- /dev/null +++ b/test/foundry/unit-testing/registry/SettledPurchase.t.sol @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "forge-std/Test.sol"; + +import "contracts/CustomStructs.sol"; +import {Errors} from "contracts/Errors.sol"; +import {Registry} from "contracts/Registry.sol"; +import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; + +import "test/utils/DeployerBase.sol"; + +/// @notice Covers purchases the admin says were paid for outside the protocol. +/// @dev These are the records nothing onchain can witness, so what they are bounded by is the whole +/// subject: the price ceiling, a payment reference that can only be spent once, a settlement +/// the admin is not allowed to claim, and the ordering guard that keeps a new entry from +/// landing ahead of history still waiting to be copied in. +contract SettledPurchaseTest is DeployerBase { + + uint256 private constant FULL_BATCH = 20; + + DeviceWallet deviceWallet; + MockESIMWallet eSIMWallet; + + /// @notice Deploys one device wallet with its first eSIM wallet, funded for purchases + function _deployWallets() internal { + string[] memory identifiers = new string[](1); + bytes32[2][] memory keys = new bytes32[2][](1); + uint256[] memory salts = new uint256[](1); + + identifiers[0] = customDeviceUniqueIdentifiers[0]; + keys[0] = pubKey1; + salts[0] = 1; + + vm.prank(eSIMWalletAdmin); + Wallets[] memory wallets = + deviceWalletFactory.deployDeviceWalletForUsers(identifiers, keys, salts, new uint256[](1)); + + deviceWallet = DeviceWallet(payable(wallets[0].deviceWallet)); + eSIMWallet = MockESIMWallet(payable(wallets[0].eSIMWallet)); + + vm.deal(address(deviceWallet), 10 ether); + vm.prank(address(deviceWallet)); + deviceWallet.toggleAccessToETH(address(eSIMWallet), true); + } + + /// @notice Records a purchase the admin says was paid for offchain + function _settle(bytes32 _id, uint64 _priceUSDCents, bytes32 _paymentReference) internal { + vm.prank(eSIMWalletAdmin); + registry.recordSettledPurchase( + address(eSIMWallet), + bundle(_id, _priceUSDCents), + ASSET_USDC, + 1e6, + _paymentReference + ); + } + + /// @notice Stores history for the fixture devices and deploys the first one's wallets + /// @dev The deployment sets identifiers only. History is copied in afterwards, which is the + /// window the ordering guard exists to cover. + function _lazyDeployWithHistoryOutstanding() internal returns (MockESIMWallet lazyWallet) { + vm.startPrank(eSIMWalletAdmin); + lazyWalletRegistry.batchPopulateHistory( + customDeviceUniqueIdentifiers, + customESIMUniqueIdentifiers, + customDataBundleDetails + ); + lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier( + pubKey1, + customDeviceUniqueIdentifiers[0], + 999, + 0, + FULL_BATCH + ); + vm.stopPrank(); + + return MockESIMWallet(payable(lazyWalletRegistry.lazyDeployedESIMWallet(customESIMUniqueIdentifiers[0][0]))); + } + + // --------------------------------------------------------------------------------------------- + // Recording a purchase + // --------------------------------------------------------------------------------------------- + + /// @notice A settled purchase lands in the wallet's history and is announced by the registry + function test_recordSettledPurchase_appendsToTheHistory() public { + _deployWallets(); + bytes32 orderRef = paymentRef("moonpay-charge"); + + vm.expectEmit(true, true, true, true); + emit RegistryHelper.DataBundleSettled( + address(eSIMWallet), + "DB_SETTLED", + TEST_PRICE_CENTS, + Settlement.Fiat, + ASSET_USDC, + settlementToken, + 1e6, + orderRef + ); + _settle("DB_SETTLED", TEST_PRICE_CENTS, orderRef); + + (bytes32 id, uint64 priceUSDCents, Settlement settlement) = eSIMWallet.transactionHistory(0); + assertEq(id, "DB_SETTLED", "The bundle id must be recorded"); + assertEq(priceUSDCents, TEST_PRICE_CENTS, "The price must be recorded in cents"); + assertTrue(settlement == Settlement.Fiat, "The settlement must be recorded as the admin stated it"); + } + + /// @notice A purchase paid from an external wallet records that settlement + function test_recordSettledPurchase_recordsAnExternalWallet() public { + _deployWallets(); + + vm.prank(eSIMWalletAdmin); + registry.recordSettledPurchase( + address(eSIMWallet), + DataBundleDetails({ + id: "DB_EXTERNAL", + priceUSDCents: TEST_PRICE_CENTS, + settlement: Settlement.ExternalWallet + }), + ASSET_USDC, + 1e6, + nextRef() + ); + + (,, Settlement settlement) = eSIMWallet.transactionHistory(0); + assertTrue(settlement == Settlement.ExternalWallet, "The settlement must be recorded as stated"); + } + + /// @notice No money moves, so the vault balance must not change + function test_recordSettledPurchase_movesNoETH() public { + _deployWallets(); + uint256 vaultBalanceBefore = vault.balance; + uint256 walletBalanceBefore = address(deviceWallet).balance; + + _settle("DB_NO_TRANSFER", TEST_PRICE_CENTS, nextRef()); + + assertEq(vault.balance, vaultBalanceBefore, "The vault must not be paid"); + assertEq(address(deviceWallet).balance, walletBalanceBefore, "The device wallet must not be charged"); + } + + /// @notice Only the admin records a settled purchase + function test_recordSettledPurchase_rejectsAnyoneButTheAdmin() public { + _deployWallets(); + + vm.prank(user1); + vm.expectRevert(Errors.OnlyAdmin.selector); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", TEST_PRICE_CENTS), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice A paused protocol records nothing + function test_recordSettledPurchase_rejectsWhilePaused() public { + _deployWallets(); + + vm.prank(eSIMWalletAdmin); + registry.pause(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.ProtocolPaused.selector); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", TEST_PRICE_CENTS), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice A wallet this registry never recorded gets no history + function test_recordSettledPurchase_rejectsAnUnknownWallet() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.NotAProtocolESIMWallet.selector, user1)); + registry.recordSettledPurchase(user1, bundle("DB_ID", TEST_PRICE_CENTS), ASSET_USDC, 1e6, nextRef()); + } + + /// @notice A purchase needs a bundle id + function test_recordSettledPurchase_rejectsAnEmptyBundleID() public { + _deployWallets(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.EmptyDataBundleID.selector); + registry.recordSettledPurchase( + address(eSIMWallet), bundle(bytes32(0), TEST_PRICE_CENTS), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice A purchase needs a price + function test_recordSettledPurchase_rejectsAZeroPrice() public { + _deployWallets(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.ZeroDataBundlePrice.selector); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", 0), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice The admin cannot claim a payment the protocol would have witnessed + /// @dev `Settlement.DeviceWallet` means the ETH reached the vault through `buyDataBundle`. + /// Nothing here saw that, so nothing here may say it happened. + function test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() public { + _deployWallets(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.SettlementNotAsserted.selector); + registry.recordSettledPurchase( + address(eSIMWallet), + DataBundleDetails({ + id: "DB_CLAIMED", + priceUSDCents: TEST_PRICE_CENTS, + settlement: Settlement.DeviceWallet + }), + ASSET_USDC, + 1e6, + nextRef() + ); + } + + /// @notice A currency the protocol does not accept cannot be recorded against + function test_recordSettledPurchase_rejectsAnUnknownCurrency() public { + _deployWallets(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, bytes32("TON"))); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", TEST_PRICE_CENTS), bytes32("TON"), 1e9, nextRef() + ); + } + + /// @notice Withdrawing a currency stops it being recorded against straight away + function test_recordSettledPurchase_rejectsAWithdrawnCurrency() public { + _deployWallets(); + + vm.prank(upgradeManager); + paymentAdapter.updateAsset(ASSET_USDC, Asset({ + allowed: false, + isDollarUnit: true, + decimals: 6, + token: settlementToken + })); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, ASSET_USDC)); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", TEST_PRICE_CENTS), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice Nothing here witnessed the payment, so the ceiling is what bounds the price + function test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() public { + _deployWallets(); + uint64 aboveTheCap = defaultPriceCapUSDCents + 1; + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector( + Errors.DataBundlePriceAboveCap.selector, aboveTheCap, defaultPriceCapUSDCents + )); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", aboveTheCap), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice A wallet's own ceiling wins over the registry default here too + function test_recordSettledPurchase_followsTheWalletsOwnCeiling() public { + _deployWallets(); + uint64 walletCap = 500; // $5.00 + + vm.prank(address(deviceWallet)); + eSIMWallet.setPriceCapUSDCents(walletCap); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector( + Errors.DataBundlePriceAboveCap.selector, walletCap + 1, walletCap + )); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", walletCap + 1), ASSET_USDC, 1e6, nextRef() + ); + } + + // --------------------------------------------------------------------------------------------- + // Payment references + // --------------------------------------------------------------------------------------------- + + /// @notice Recording a purchase spends its reference + function test_recordSettledPurchase_spendsTheReference() public { + _deployWallets(); + bytes32 orderRef = paymentRef("stripe-intent"); + + _settle("DB_ID", TEST_PRICE_CENTS, orderRef); + + assertTrue(paymentAdapter.usedReferences(orderRef), "The reference must be spent"); + } + + /// @notice A retried call cannot record the purchase twice + /// @dev The backend retries the whole onchain step on any failure, so the second attempt of a + /// call that already landed is the case this is here for. + function test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() public { + _deployWallets(); + bytes32 orderRef = paymentRef("retried-charge"); + _settle("DB_ID", TEST_PRICE_CENTS, orderRef); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, orderRef)); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", TEST_PRICE_CENTS), ASSET_USDC, 1e6, orderRef + ); + + assertEq(eSIMWallet.getTransactionHistory().length, 1, "The purchase must be recorded once"); + } + + /// @notice A reference spent buying with ETH cannot be spent again on the settled path + function test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() public { + _deployWallets(); + bytes32 orderRef = paymentRef("shared-order"); + + vm.prank(eSIMWalletAdmin); + eSIMWallet.buyDataBundle(bundle("DB_BOUGHT", TEST_PRICE_CENTS), 0.1 ether, orderRef); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, orderRef)); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_SETTLED", TEST_PRICE_CENTS), ASSET_USDC, 1e6, orderRef + ); + } + + /// @notice And the same reference cannot go the other way round either + /// @dev Both paths spend through the one adapter, which is what makes this hold. + function test_buyDataBundle_rejectsAReferenceSpentBySettlement() public { + _deployWallets(); + bytes32 orderRef = paymentRef("shared-order-reversed"); + _settle("DB_SETTLED", TEST_PRICE_CENTS, orderRef); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, orderRef)); + eSIMWallet.buyDataBundle(bundle("DB_BOUGHT", TEST_PRICE_CENTS), 0.1 ether, orderRef); + } + + /// @notice An empty reference ties the purchase to nothing + function test_recordSettledPurchase_rejectsAnEmptyReference() public { + _deployWallets(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.EmptyPaymentReference.selector); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_ID", TEST_PRICE_CENTS), ASSET_USDC, 1e6, bytes32(0) + ); + } + + /// @notice Only an eSIM wallet the registry knows can spend a reference through it + function test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.NotAProtocolESIMWallet.selector, eSIMWalletAdmin)); + registry.consumePaymentReference(paymentRef("forged-order")); + } + + // --------------------------------------------------------------------------------------------- + // The ordering guard + // --------------------------------------------------------------------------------------------- + + /// @notice A new purchase cannot land ahead of history still waiting to be copied in + /// @dev Otherwise the older entries append after the newer one and the wallet's history reads + /// out of order, which is the exact case a fiat user switching to crypto hits. + function test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() public { + MockESIMWallet lazyWallet = _lazyDeployWithHistoryOutstanding(); + string memory eSIMIdentifier = customESIMUniqueIdentifiers[0][0]; + uint256 outstanding = lazyWalletRegistry.outstandingHistoryEntries(eSIMIdentifier); + assertGt(outstanding, 0, "The fixture must leave history uncopied"); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector( + Errors.HistoryNotFullyCopied.selector, eSIMIdentifier, outstanding + )); + registry.recordSettledPurchase( + address(lazyWallet), bundle("DB_ID", TEST_PRICE_CENTS), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice Once the history is copied the purchase goes through and lands last + function test_recordSettledPurchase_acceptsOnceHistoryIsCopied() public { + MockESIMWallet lazyWallet = _lazyDeployWithHistoryOutstanding(); + string memory eSIMIdentifier = customESIMUniqueIdentifiers[0][0]; + + vm.prank(eSIMWalletAdmin); + lazyWalletRegistry.setHistoryForLazyWallet(eSIMIdentifier, FULL_BATCH); + assertEq( + lazyWalletRegistry.outstandingHistoryEntries(eSIMIdentifier), + 0, + "The copy must have caught up" + ); + + uint256 copiedEntries = lazyWallet.getTransactionHistory().length; + + vm.prank(eSIMWalletAdmin); + registry.recordSettledPurchase( + address(lazyWallet), bundle("DB_LAST", TEST_PRICE_CENTS), ASSET_USDC, 1e6, nextRef() + ); + + assertEq(lazyWallet.getTransactionHistory().length, copiedEntries + 1, "The purchase must be appended"); + (bytes32 id,,) = lazyWallet.transactionHistory(copiedEntries); + assertEq(id, "DB_LAST", "The new purchase must be the last entry"); + } + + /// @notice A wallet with no identifier has no lazy history to wait for + /// @dev Every eagerly deployed wallet is in this state until the admin names its eSIM. + function test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() public { + _deployWallets(); + assertEq(bytes(eSIMWallet.eSIMUniqueIdentifier()).length, 0, "The fixture must have no identifier"); + + _settle("DB_ID", TEST_PRICE_CENTS, nextRef()); + + assertEq(eSIMWallet.getTransactionHistory().length, 1, "The purchase must be recorded"); + } + + // --------------------------------------------------------------------------------------------- + // Pointing at the adapter + // --------------------------------------------------------------------------------------------- + + /// @notice The owner points the registry at an adapter + function test_setPaymentAdapter_storesTheAddress() public { + address newAdapter = makeAddr("newAdapter"); + + vm.expectEmit(true, true, true, true); + emit RegistryHelper.PaymentAdapterUpdated(newAdapter); + + vm.prank(upgradeManager); + registry.setPaymentAdapter(newAdapter); + + assertEq(registry.paymentAdapter(), newAdapter, "The adapter address must be stored"); + } + + /// @notice Only the owner points the registry at an adapter + /// @dev The adapter holds the spent references. An admin that could swap it would get an empty + /// set back and record every purchase a second time. + function test_setPaymentAdapter_rejectsTheAdmin() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", eSIMWalletAdmin)); + registry.setPaymentAdapter(makeAddr("newAdapter")); + } + + /// @notice A zero address would take every payment path down + function test_setPaymentAdapter_rejectsTheZeroAddress() public { + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, "_paymentAdapter")); + registry.setPaymentAdapter(address(0)); + } + + /// @notice Setting the same address again is refused + function test_setPaymentAdapter_rejectsTheAddressAlreadySet() public { + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector( + Errors.PaymentAdapterUnchanged.selector, address(paymentAdapter) + )); + registry.setPaymentAdapter(address(paymentAdapter)); + } +} diff --git a/test/foundry/unit-testing/upgrade/ImplementationLocks.t.sol b/test/foundry/unit-testing/upgrade/ImplementationLocks.t.sol index 5c7bb7ac..ff415b7b 100644 --- a/test/foundry/unit-testing/upgrade/ImplementationLocks.t.sol +++ b/test/foundry/unit-testing/upgrade/ImplementationLocks.t.sol @@ -35,7 +35,7 @@ contract ImplementationLocksTest is Test { attacker, attacker, IEntryPoint(attacker), - 1 ether + 100_000 ); assertEq(implementation.owner(), address(0), "Implementation must have no owner"); From 54a42617adfb49c6ccb09fc1320bdfded7aa78d3 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 18:36:45 +0530 Subject: [PATCH 12/75] Cap how many decimals a currency may be registered with Past 36 the largest price the protocol can express overflows the conversion, so the currency could be added and then never priced. --- contracts/Errors.sol | 2 ++ contracts/payments/PaymentAdapter.sol | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/contracts/Errors.sol b/contracts/Errors.sol index f070b6e5..d2932936 100644 --- a/contracts/Errors.sol +++ b/contracts/Errors.sol @@ -132,6 +132,8 @@ interface Errors { error AssetNotRegistered(bytes32 asset); // quote divides by 100, so fewer than two decimals loses the cents error AssetDecimalsTooLow(bytes32 asset, uint8 decimals); + // Past the ceiling, quote overflows and the currency can never be priced + error AssetDecimalsTooHigh(bytes32 asset, uint8 decimals); // No price feeds, so only a currency already in dollars can be converted from cents error AssetNeedsSwap(bytes32 asset); // Fiat and non-EVM currencies have no token address, so nothing can be transferred diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index 87d992ef..2b41d4af 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -34,6 +34,11 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @notice Fewest decimals a currency may have. `quote` divides by 100, so fewer loses the cents. uint8 private constant MIN_ASSET_DECIMALS = 2; + /// @notice Most decimals a currency may have + /// @dev Above this, the largest price the protocol can express overflows `quote` and the + /// currency can never be priced at all. Well past the 18 any real token uses. + uint8 private constant MAX_ASSET_DECIMALS = 36; + /// @notice Registry contract address, the only caller allowed to spend a payment reference address public registry; @@ -214,6 +219,9 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab if(_asset.decimals < MIN_ASSET_DECIMALS) { revert Errors.AssetDecimalsTooLow(_symbol, _asset.decimals); } + if(_asset.decimals > MAX_ASSET_DECIMALS) { + revert Errors.AssetDecimalsTooHigh(_symbol, _asset.decimals); + } assets[_symbol] = _asset; From dd8d7f4c41d4bb4c991799211db5684c6a107e4e Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 18:36:45 +0530 Subject: [PATCH 13/75] Fuzz the cent conversion over every price and every currency --- test/foundry/fuzz-testing/QuotePricing.t.sol | 211 +++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 test/foundry/fuzz-testing/QuotePricing.t.sol diff --git a/test/foundry/fuzz-testing/QuotePricing.t.sol b/test/foundry/fuzz-testing/QuotePricing.t.sol new file mode 100644 index 00000000..347e8720 --- /dev/null +++ b/test/foundry/fuzz-testing/QuotePricing.t.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "forge-std/Test.sol"; + +import {Errors} from "contracts/Errors.sol"; +import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; + +import "test/utils/DeployerBase.sol"; + +/// @notice Sweeps `quote` across every price the protocol can express and every currency it can +/// accept. +/// @dev Every price in the protocol passes through this one function, and it is the only place a +/// cent figure becomes a token amount, so there is no second figure anywhere to check its +/// answer against. The sweep is what stands in for that: the price range is the full `uint64` +/// the struct field carries, and the decimals range is every value the asset table will store. +/// +/// No wallets are deployed here. Only the adapter is under test and nothing below moves ETH. +contract QuotePricingTest is DeployerBase { + + /// @dev Matches MIN_ASSET_DECIMALS and MAX_ASSET_DECIMALS in the adapter, which are private + /// there. Registering outside this range is refused, so nothing else can be quoted. + uint8 private constant MIN_DECIMALS = 2; + uint8 private constant MAX_DECIMALS = 36; + + bytes32 private constant FUZZED = bytes32("FUZZ"); + + /// @notice Registers the fuzzed currency and returns the decimals it settled on + function _registerFuzzed(uint8 _decimals, bool _allowed, bool _isDollarUnit) + internal + returns (uint8 decimals) + { + decimals = uint8(bound(uint256(_decimals), MIN_DECIMALS, MAX_DECIMALS)); + + vm.prank(upgradeManager); + paymentAdapter.registerAsset(FUZZED, Asset({ + allowed: _allowed, + isDollarUnit: _isDollarUnit, + decimals: decimals, + token: settlementToken + })); + } + + // --------------------------------------------------------------------------------------------- + // Currencies already in dollars + // --------------------------------------------------------------------------------------------- + + /// @notice Any price in any registrable currency converts to cents times that currency's unit + function testFuzz_quote_convertsAnyPriceInAnyCurrency(uint64 _priceUSDCents, uint8 _decimals) public { + uint8 decimals = _registerFuzzed(_decimals, true, true); + + assertEq( + paymentAdapter.quote(FUZZED, _priceUSDCents), + (uint256(_priceUSDCents) * 10 ** decimals) / 100, + "The quote must be the cent price scaled into the currency's own unit" + ); + } + + /// @notice No price in any registrable currency overflows + /// @dev This is what the decimals ceiling is for. Without it a currency could be registered + /// that no price could ever be quoted in, and every purchase in it would revert. + function testFuzz_quote_neverRevertsForARegisteredCurrency(uint64 _priceUSDCents, uint8 _decimals) public { + _registerFuzzed(_decimals, true, true); + + try paymentAdapter.quote(FUZZED, _priceUSDCents) returns (uint256) {} + catch { + fail(); + } + } + + /// @notice Nothing is lost converting a cent price, whatever the currency + /// @dev The decimals floor is what makes this hold. At two decimals or more the division by a + /// hundred is exact, so the user is charged the price the protocol recorded. + function testFuzz_quote_losesNoCents(uint64 _priceUSDCents, uint8 _decimals) public { + uint8 decimals = _registerFuzzed(_decimals, true, true); + uint256 amountIn = paymentAdapter.quote(FUZZED, _priceUSDCents); + + assertEq( + (amountIn * 100) / 10 ** decimals, + uint256(_priceUSDCents), + "Converting back must give the price the protocol recorded" + ); + } + + /// @notice A higher price never quotes a smaller amount + function testFuzz_quote_risesWithThePrice(uint64 _lowerPrice, uint64 _higherPrice, uint8 _decimals) public { + vm.assume(_lowerPrice <= _higherPrice); + _registerFuzzed(_decimals, true, true); + + assertLe( + paymentAdapter.quote(FUZZED, _lowerPrice), + paymentAdapter.quote(FUZZED, _higherPrice), + "A higher price must never cost less" + ); + } + + /// @notice Two currencies with the same decimals quote the same amount for the same price + function testFuzz_quote_dependsOnlyOnTheDecimals(uint64 _priceUSDCents) public { + vm.startPrank(upgradeManager); + paymentAdapter.registerAsset(bytes32("USDT"), Asset({ + allowed: true, isDollarUnit: true, decimals: 6, token: address(0) + })); + vm.stopPrank(); + + assertEq( + paymentAdapter.quote(bytes32("USDT"), _priceUSDCents), + paymentAdapter.quote(ASSET_USDC, _priceUSDCents), + "Two six-decimal currencies must quote alike" + ); + } + + // --------------------------------------------------------------------------------------------- + // Currencies the adapter refuses to price + // --------------------------------------------------------------------------------------------- + + /// @notice A currency that is not already in dollars needs a rate, at any price + /// @dev There are no price feeds here, so refusing is the only honest answer. This is the case + /// the swap path in a later release fills in. + function testFuzz_quote_refusesACurrencyThatNeedsARate(uint64 _priceUSDCents, uint8 _decimals) public { + _registerFuzzed(_decimals, true, false); + + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNeedsSwap.selector, FUZZED)); + paymentAdapter.quote(FUZZED, _priceUSDCents); + } + + /// @notice A withdrawn currency never quotes, at any price + function testFuzz_quote_refusesAWithdrawnCurrency(uint64 _priceUSDCents, uint8 _decimals) public { + _registerFuzzed(_decimals, false, true); + + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, FUZZED)); + paymentAdapter.quote(FUZZED, _priceUSDCents); + } + + /// @notice Being withdrawn is checked before needing a rate, so the reason never flips + function testFuzz_quote_refusesAWithdrawnCurrencyThatAlsoNeedsARate(uint64 _priceUSDCents) public { + _registerFuzzed(18, false, false); + + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, FUZZED)); + paymentAdapter.quote(FUZZED, _priceUSDCents); + } + + /// @notice A symbol nobody registered never quotes + function testFuzz_quote_refusesAnUnknownSymbol(bytes32 _symbol, uint64 _priceUSDCents) public { + vm.assume(_symbol != ASSET_USDC && _symbol != ASSET_USD && _symbol != ASSET_ETH); + + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, _symbol)); + paymentAdapter.quote(_symbol, _priceUSDCents); + } + + /// @notice Withdrawing a currency stops it quoting from that call onwards + /// @dev The sweep is over the price, so this says the withdrawal holds for every price rather + /// than for the one a unit test would pick. + function testFuzz_quote_stopsAfterACurrencyIsWithdrawn(uint64 _priceUSDCents, uint8 _decimals) public { + uint8 decimals = _registerFuzzed(_decimals, true, true); + paymentAdapter.quote(FUZZED, _priceUSDCents); + + vm.prank(upgradeManager); + paymentAdapter.updateAsset(FUZZED, Asset({ + allowed: false, + isDollarUnit: true, + decimals: decimals, + token: settlementToken + })); + + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, FUZZED)); + paymentAdapter.quote(FUZZED, _priceUSDCents); + } + + // --------------------------------------------------------------------------------------------- + // The decimals the table accepts + // --------------------------------------------------------------------------------------------- + + /// @notice A currency that cannot carry a cent is refused + function testFuzz_registerAsset_refusesDecimalsBelowTheFloor(uint8 _decimals) public { + uint8 decimals = uint8(bound(uint256(_decimals), 0, MIN_DECIMALS - 1)); + + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetDecimalsTooLow.selector, FUZZED, decimals)); + paymentAdapter.registerAsset(FUZZED, Asset({ + allowed: true, isDollarUnit: true, decimals: decimals, token: settlementToken + })); + } + + /// @notice A currency no price could be quoted in is refused + function testFuzz_registerAsset_refusesDecimalsAboveTheCeiling(uint8 _decimals) public { + uint8 decimals = uint8(bound(uint256(_decimals), MAX_DECIMALS + 1, type(uint8).max)); + + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetDecimalsTooHigh.selector, FUZZED, decimals)); + paymentAdapter.registerAsset(FUZZED, Asset({ + allowed: true, isDollarUnit: true, decimals: decimals, token: settlementToken + })); + } + + /// @notice The same two bounds apply to changing a currency as to adding one + function testFuzz_updateAsset_refusesDecimalsOutsideTheBounds(uint8 _decimals) public { + _registerFuzzed(18, true, true); + vm.assume(_decimals < MIN_DECIMALS || _decimals > MAX_DECIMALS); + + bytes4 expected = _decimals < MIN_DECIMALS + ? Errors.AssetDecimalsTooLow.selector + : Errors.AssetDecimalsTooHigh.selector; + + vm.prank(upgradeManager); + vm.expectRevert(abi.encodeWithSelector(expected, FUZZED, _decimals)); + paymentAdapter.updateAsset(FUZZED, Asset({ + allowed: true, isDollarUnit: true, decimals: _decimals, token: settlementToken + })); + } +} From c970653c16afc0d6ebdd7bc576366c1aaf76a0e1 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 18:57:26 +0530 Subject: [PATCH 14/75] Add a payment handler and the two payment invariants A reference is spent at most once whichever path spends it, and a currency the table has withdrawn never returns a price. --- .../HandlerDistribution.t.sol | 16 ++ .../invariant-testing/PaymentInvariants.t.sol | 64 ++++++ .../invariant-testing/base/CampaignBase.sol | 13 +- .../invariant-testing/base/InvariantBase.sol | 16 +- .../handler/PaymentHandler.sol | 208 ++++++++++++++++++ 5 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 test/foundry/invariant-testing/PaymentInvariants.t.sol create mode 100644 test/foundry/invariant-testing/handler/PaymentHandler.sol diff --git a/test/foundry/invariant-testing/HandlerDistribution.t.sol b/test/foundry/invariant-testing/HandlerDistribution.t.sol index 48b61dbd..81dbb49a 100644 --- a/test/foundry/invariant-testing/HandlerDistribution.t.sol +++ b/test/foundry/invariant-testing/HandlerDistribution.t.sol @@ -35,6 +35,9 @@ contract HandlerDistributionTest is CampaignBase { /// @notice Offset placing switch destinations outside the identifiers the lazy path populates uint256 internal constant SWITCH_SEEDS = 100_000; + /// @notice Offset separating the settled path's payment references from the ETH path's + uint256 internal constant PAYMENT_REFERENCE_SEEDS = 64; + /// @notice Every entry point can reach the protocol and change its state function test_handlersReachEveryEntryPoint() public { // A batch consumes up to three consecutive seeds and each seed becomes an identifier, so @@ -74,6 +77,16 @@ contract HandlerDistributionTest is CampaignBase { walletHandler.toggleAccessToETH(round, true); walletHandler.setESIMWalletPriceCap(round, round); adminHandler.buyDataBundle(round, 100, 1 gwei); + // Each payment path is given its own block of reference seeds. Two calls presenting + // the same reference is a case the campaign covers, and the second one is refused, + // which is a revert rather than the count this drive is checking + paymentHandler.buyDataBundle(round, 100, 1 gwei, round); + paymentHandler.recordSettledPurchase(round, 100, round + PAYMENT_REFERENCE_SEEDS, 0, false); + paymentHandler.quote(0, 100); + // Withdrawn and put back inside the round, so the next round starts from the same + // currency table this one did + paymentHandler.updateAsset(1, false, true); + paymentHandler.updateAsset(1, true, true); walletHandler.pullETH(round, 1 gwei); // Removal comes before the transfer pair on purpose. Requesting a transfer detaches // the wallet on its way through, so a removal after it has nothing left to remove. @@ -132,6 +145,9 @@ contract HandlerDistributionTest is CampaignBase { _assertExercised("setESIMIdentifier"); _assertExercised("toggleAccessToETH"); _assertExercised("buyDataBundle"); + _assertExercised("recordSettledPurchase"); + _assertExercised("quote"); + _assertExercised("updateAsset"); _assertExercised("pullETH"); _assertExercised("removeESIMWallet"); _assertExercised("addESIMWallet"); diff --git a/test/foundry/invariant-testing/PaymentInvariants.t.sol b/test/foundry/invariant-testing/PaymentInvariants.t.sol new file mode 100644 index 00000000..83a1a628 --- /dev/null +++ b/test/foundry/invariant-testing/PaymentInvariants.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import {CampaignBase} from "test/foundry/invariant-testing/base/CampaignBase.sol"; + +/// @notice What has to stay true about payment references and the currency table. +/// @dev The whole campaign runs underneath these, so both hold while wallets are being +/// transferred, removed and moved onto new beacon logic. +contract PaymentInvariantsTest is CampaignBase { + + bytes32[3] private symbols = [bytes32("USD"), bytes32("USDC"), bytes32("ETH")]; + + /// @notice One payment reference pays for one purchase, whichever path spends it + /// @dev A reference is one offchain payment. The backend retries the whole onchain step on any + /// failure, so a reference that could be spent a second time is a user charged once and + /// billed twice. The two payment paths reach the adapter by different routes, which is + /// what makes this a question about the pair rather than about either one. + function invariant_aPaymentReferenceIsSpentAtMostOnce() public view { + assertFalse( + paymentHandler.ghost_referenceSpentTwice(), + "A payment reference paid for a second purchase" + ); + + uint256 count = paymentHandler.spentReferenceCount(); + for (uint256 i = 0; i < count; ++i) { + bytes32 paymentReference = paymentHandler.spentReferences(i); + + assertEq( + paymentHandler.ghost_spendCount(paymentReference), + 1, + "A payment reference was spent more than once" + ); + assertTrue( + paymentAdapter.usedReferences(paymentReference), + "A reference a purchase spent does not read as spent" + ); + } + } + + /// @notice A currency the table has withdrawn never returns a price + /// @dev The withdrawal is what stops a purchase being recorded against a currency the protocol + /// no longer accepts, so an answer that outlives it would leave the withdrawal meaning + /// nothing. + function invariant_aWithdrawnCurrencyNeverQuotes() public { + assertFalse( + paymentHandler.ghost_withdrawnCurrencyQuoted(), + "A withdrawn currency returned a price" + ); + + for (uint256 i = 0; i < symbols.length; ++i) { + (bool allowed,,,) = paymentAdapter.assets(symbols[i]); + if (allowed) continue; + + bool quoted = true; + try paymentAdapter.quote(symbols[i], 100) returns (uint256) {} + catch { + quoted = false; + } + + assertFalse(quoted, "A withdrawn currency returned a price"); + } + } +} diff --git a/test/foundry/invariant-testing/base/CampaignBase.sol b/test/foundry/invariant-testing/base/CampaignBase.sol index a316e8d9..4d0f7515 100644 --- a/test/foundry/invariant-testing/base/CampaignBase.sol +++ b/test/foundry/invariant-testing/base/CampaignBase.sol @@ -14,6 +14,7 @@ import {WalletHandler} from "test/foundry/invariant-testing/handler/WalletHandle import {AttackerHandler} from "test/foundry/invariant-testing/handler/AttackerHandler.sol"; import {UpgradeManagerHandler} from "test/foundry/invariant-testing/handler/UpgradeManagerHandler.sol"; +import {PaymentHandler} from "test/foundry/invariant-testing/handler/PaymentHandler.sol"; /// @notice The campaign every invariant file runs against. /// @dev Each invariant function is its own campaign with its own `setUp`, so splitting the @@ -35,8 +36,9 @@ abstract contract CampaignBase is InvariantBase { WalletHandler internal walletHandler; AttackerHandler internal attackerHandler; UpgradeManagerHandler internal upgradeManagerHandler; + PaymentHandler internal paymentHandler; - /// @notice Deploys the protocol, wires the three handlers to it and funds the campaign + /// @notice Deploys the protocol, wires the five handlers to it and funds the campaign function setUp() public virtual { _deployProtocol(); @@ -61,6 +63,12 @@ abstract contract CampaignBase is InvariantBase { walletHandler = new WalletHandler(config); attackerHandler = new AttackerHandler(config); + bytes32[] memory symbols = new bytes32[](3); + symbols[0] = bytes32("USD"); + symbols[1] = bytes32("USDC"); + symbols[2] = bytes32("ETH"); + paymentHandler = new PaymentHandler(config, paymentAdapter, symbols); + // The second implementations are built here rather than inside the handler so the beacon // swap starts from the same wallet logic the campaign deployed against upgradeManagerHandler = new UpgradeManagerHandler( @@ -80,6 +88,7 @@ abstract contract CampaignBase is InvariantBase { targetContract(address(walletHandler)); targetContract(address(attackerHandler)); targetContract(address(upgradeManagerHandler)); + targetContract(address(paymentHandler)); // Several modifiers admit `address(registry)` or `address(this)`, so a random sender that // lands on one of these passes access control by accident and reports a violation that @@ -94,6 +103,7 @@ abstract contract CampaignBase is InvariantBase { excludeSender(address(walletHandler)); excludeSender(address(attackerHandler)); excludeSender(address(upgradeManagerHandler)); + excludeSender(address(paymentHandler)); // The four actors are excluded for a different reason, and it is not optional. The runner // adjusts the balance of whichever address it picks as sender, so an actor used as a @@ -115,6 +125,7 @@ abstract contract CampaignBase is InvariantBase { function _heldETH() internal view returns (uint256) { uint256 held = address(adminHandler).balance + address(walletHandler).balance + address(attackerHandler).balance + address(upgradeManagerHandler).balance + + address(paymentHandler).balance + ADMIN.balance + ADMIN_SUCCESSOR.balance + UPGRADE_MANAGER.balance + VAULT.balance + ATTACKER.balance + address(deviceWalletFactory).balance diff --git a/test/foundry/invariant-testing/base/InvariantBase.sol b/test/foundry/invariant-testing/base/InvariantBase.sol index 64fde5d1..b04791f2 100644 --- a/test/foundry/invariant-testing/base/InvariantBase.sol +++ b/test/foundry/invariant-testing/base/InvariantBase.sol @@ -134,13 +134,27 @@ contract InvariantBase is Test { registry.addOrUpdateLazyWalletRegistryAddress(address(lazyWalletRegistry)); registry.setPaymentAdapter(address(paymentAdapter)); // Every purchase spends a payment reference through the adapter, so with no currencies - // registered a campaign would see all of them revert before reaching anything else. + // registered a campaign would see all of them revert before reaching anything else. ETH is + // the entry that needs a rate, so it is the one `quote` refuses on its own terms rather + // than because a run withdrew it. paymentAdapter.registerAsset("USD", Asset({ allowed: true, isDollarUnit: true, decimals: 2, token: address(0) })); + paymentAdapter.registerAsset("USDC", Asset({ + allowed: true, + isDollarUnit: true, + decimals: 6, + token: SETTLEMENT_TOKEN + })); + paymentAdapter.registerAsset("ETH", Asset({ + allowed: true, + isDollarUnit: false, + decimals: 18, + token: address(0) + })); deviceWalletFactory.addRegistryAddress(address(registry)); eSIMWalletFactory.addRegistryAddress(address(registry)); vm.stopPrank(); diff --git a/test/foundry/invariant-testing/handler/PaymentHandler.sol b/test/foundry/invariant-testing/handler/PaymentHandler.sol new file mode 100644 index 00000000..0d158f72 --- /dev/null +++ b/test/foundry/invariant-testing/handler/PaymentHandler.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "contracts/CustomStructs.sol"; +import {ESIMWallet} from "contracts/esim-wallet/ESIMWallet.sol"; +import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; + +import {HandlerBase, HandlerConfig} from "test/foundry/invariant-testing/handler/HandlerBase.sol"; + +/// @notice Drives both payment paths and the currency table against one another. +/// @dev The two paths spend payment references through the same adapter but reach it by different +/// routes, one from the wallet and one from the admin, so whether a reference can be spent +/// twice is a question about the pair rather than about either one. +/// +/// Ghost state sits on this handler rather than in `ProtocolState`, because this is the only +/// contract that writes it. A handler body that reverts takes its ghost writes with it, so a +/// reference is only counted once the spend it belongs to has actually landed. +contract PaymentHandler is HandlerBase { + + /// @notice How many payment references the run draws from + /// @dev Small on purpose. Drawing from the whole `bytes32` space would never present the same + /// reference twice, and a second call on one already spent is the only case worth + /// catching here. At this size a sequence collides several times over. + uint256 private constant REFERENCE_POOL = 128; + + PaymentAdapter internal immutable paymentAdapter; + + bytes32[] internal symbols; + + /// @notice How many times each reference has been spent by a call that went through + mapping(bytes32 paymentReference => uint256 spends) public ghost_spendCount; + + /// @notice Every reference a call has spent, listed once each + bytes32[] public spentReferences; + + /// @notice Set when a reference was spent by a second call that went through + bool public ghost_referenceSpentTwice; + + /// @notice Set when `quote` answered for a currency the table had already withdrawn + bool public ghost_withdrawnCurrencyQuoted; + + constructor(HandlerConfig memory config, PaymentAdapter _paymentAdapter, bytes32[] memory _symbols) + HandlerBase(config) + { + paymentAdapter = _paymentAdapter; + symbols = _symbols; + } + + /// @notice One of the four references the run draws from + function _reference(uint256 seed) internal pure returns (bytes32) { + return keccak256(abi.encode("payment-handler", seed % REFERENCE_POOL)); + } + + /// @notice One of the currencies the campaign registered + function _symbol(uint256 seed) internal view returns (bytes32) { + return symbols[bound(seed, 0, symbols.length - 1)]; + } + + /// @notice Counts a spend that went through, and notices a second one on the same reference + function _recordSpend(bytes32 _paymentReference) internal { + if (ghost_spendCount[_paymentReference] == 0) { + spentReferences.push(_paymentReference); + } else { + ghost_referenceSpentTwice = true; + } + + ghost_spendCount[_paymentReference] += 1; + } + + /// @notice The ceiling that applies to this wallet right now + /// @dev Prices are bound to it so the ceiling never decides the call. What decides it is the + /// reference, which is the subject here. + function _effectiveCap(address wallet) internal view returns (uint64) { + uint64 cap = ESIMWallet(payable(wallet)).priceCapUSDCents(); + return cap == 0 ? registry.defaultPriceCapUSDCents() : cap; + } + + /// @notice How many references this run has spent + function spentReferenceCount() external view returns (uint256) { + return spentReferences.length; + } + + // --------------------------------------------------------------------------------------------- + // The two payment paths + // --------------------------------------------------------------------------------------------- + + /// @notice A wallet buys a data bundle with ETH + /// @param eSIMIndex Picks the wallet + /// @param priceUSDCents Price to record, bound under whichever ceiling applies + /// @param priceWei ETH to send to the vault + /// @param referenceSeed Picks the payment reference from the pool + function buyDataBundle( + uint256 eSIMIndex, + uint64 priceUSDCents, + uint256 priceWei, + uint256 referenceSeed + ) external counted { + address wallet = _pickESIMWallet(eSIMIndex); + if (wallet == address(0)) { + state.recordRevert("buyDataBundle"); + return; + } + + bytes32 paymentReference = _reference(referenceSeed); + priceUSDCents = uint64(bound(priceUSDCents, 1, _effectiveCap(wallet))); + priceWei = bound(priceWei, 1, 1 ether); + + vm.prank(_currentAdmin()); + try ESIMWallet(payable(wallet)).buyDataBundle( + DataBundleDetails({ + id: "bundle", + priceUSDCents: priceUSDCents, + settlement: Settlement.Fiat + }), + priceWei, + paymentReference + ) { + _recordSpend(paymentReference); + state.recordCall("buyDataBundle"); + } catch { + state.recordRevert("buyDataBundle"); + } + } + + /// @notice The admin records a purchase paid for outside the protocol + /// @param eSIMIndex Picks the wallet + /// @param priceUSDCents Price to record, bound under whichever ceiling applies + /// @param referenceSeed Picks the payment reference from the pool + /// @param symbolSeed Picks the currency the user is said to have paid in + /// @param paidFromAWallet Whether the payment is claimed as an external wallet or as fiat + function recordSettledPurchase( + uint256 eSIMIndex, + uint64 priceUSDCents, + uint256 referenceSeed, + uint256 symbolSeed, + bool paidFromAWallet + ) external counted { + address wallet = _pickESIMWallet(eSIMIndex); + if (wallet == address(0)) { + state.recordRevert("recordSettledPurchase"); + return; + } + + bytes32 paymentReference = _reference(referenceSeed); + priceUSDCents = uint64(bound(priceUSDCents, 1, _effectiveCap(wallet))); + + vm.prank(_currentAdmin()); + try registry.recordSettledPurchase( + wallet, + DataBundleDetails({ + id: "settled", + priceUSDCents: priceUSDCents, + settlement: paidFromAWallet ? Settlement.ExternalWallet : Settlement.Fiat + }), + _symbol(symbolSeed), + uint256(priceUSDCents), + paymentReference + ) { + _recordSpend(paymentReference); + state.recordCall("recordSettledPurchase"); + } catch { + state.recordRevert("recordSettledPurchase"); + } + } + + // --------------------------------------------------------------------------------------------- + // The currency table + // --------------------------------------------------------------------------------------------- + + /// @notice The owner withdraws a currency or puts it back + /// @param symbolSeed Picks the currency + /// @param allowed Whether the currency is being allowed or withdrawn + /// @param isDollarUnit Whether it is being marked as already in dollars + function updateAsset(uint256 symbolSeed, bool allowed, bool isDollarUnit) external counted { + bytes32 symbol = _symbol(symbolSeed); + (,, uint8 decimals, address token) = paymentAdapter.assets(symbol); + + vm.prank(upgradeManager); + try paymentAdapter.updateAsset(symbol, Asset({ + allowed: allowed, + isDollarUnit: isDollarUnit, + decimals: decimals, + token: token + })) { + state.recordCall("updateAsset"); + } catch { + state.recordRevert("updateAsset"); + } + } + + /// @notice Asks for a price in one of the currencies + /// @dev The answer is checked against the table read in the same call. A withdrawn currency + /// that still answers is the stale success this is looking for. + /// @param symbolSeed Picks the currency + /// @param priceUSDCents Price to ask about + function quote(uint256 symbolSeed, uint64 priceUSDCents) external counted { + bytes32 symbol = _symbol(symbolSeed); + (bool allowed,,,) = paymentAdapter.assets(symbol); + + try paymentAdapter.quote(symbol, priceUSDCents) returns (uint256) { + if (!allowed) ghost_withdrawnCurrencyQuoted = true; + state.recordCall("quote"); + } catch { + state.recordRevert("quote"); + } + } +} From 8fde15f451823a81095c6e0baf6bd9a8b641c4d0 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 19:00:46 +0530 Subject: [PATCH 15/75] Verify the currency table and the payment references 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. --- certora/conf/full/PaymentAdapter.conf | 27 +++ certora/specs/ESIMWallet.spec | 8 +- certora/specs/PaymentAdapter.spec | 275 ++++++++++++++++++++++++++ certora/specs/Registry.spec | 6 +- snapshots/ESIMWallet.Operations.json | 4 +- 5 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 certora/conf/full/PaymentAdapter.conf create mode 100644 certora/specs/PaymentAdapter.spec diff --git a/certora/conf/full/PaymentAdapter.conf b/certora/conf/full/PaymentAdapter.conf new file mode 100644 index 00000000..ed7818a4 --- /dev/null +++ b/certora/conf/full/PaymentAdapter.conf @@ -0,0 +1,27 @@ +{ + "files": [ + "contracts/payments/PaymentAdapter.sol" + ], + "verify": "PaymentAdapter:certora/specs/PaymentAdapter.spec", + "solc": "solc", + "solc_via_ir": true, + "solc_optimize": "10000000", + "solc_evm_version": "osaka", + "packages": [ + "@openzeppelin/contracts=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts", + "@openzeppelin/contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts", + "@account-abstraction=lib/account-abstraction", + "solady/utils=lib/solady/src/utils", + "FreshCryptoLib=lib/FreshCryptoLib/solidity/src", + "p256-verifier=lib/p256-verifier/src", + "forge-std=lib/forge-std/src" + ], + "optimistic_loop": true, + "loop_iter": "3", + "optimistic_hashing": true, + "hashing_length_bound": "224", + "rule_sanity": "basic", + "wait_for_results": "all", + "msg": "PaymentAdapter currency table and payment references", + "function_finder_mode": "relaxed" +} diff --git a/certora/specs/ESIMWallet.spec b/certora/specs/ESIMWallet.spec index e5f872f3..16ec1bcb 100644 --- a/certora/specs/ESIMWallet.spec +++ b/certora/specs/ESIMWallet.spec @@ -38,7 +38,7 @@ methods { function newRequestedOwner() external returns (address) envfree; function deviceWallet() external returns (address) envfree; function eSIMWalletFactory() external returns (address) envfree; - function priceCapUSDCents() external returns (uint256) envfree; + function priceCapUSDCents() external returns (uint64) envfree; /// Nothing outside this contract is in the scene. Without this the device wallet and registry /// calls would havoc this wallet's own storage and every rule below would fail for the wrong @@ -258,16 +258,16 @@ rule theOwnerIsAlwaysTheDeviceWallet(method f) filtered { /// ceiling to zero, which hands the wallet to the registry default rather than to a figure the /// outgoing owner chose. Anything else on that path would be a second, unguarded writer. rule thePriceCeilingIsSetOnlyByItsSetter(method f) filtered { f -> !alwaysReverts(f) } { - uint256 capBefore = priceCapUSDCents(); + uint64 capBefore = priceCapUSDCents(); env callEnv; calldataarg args; f(callEnv, args); - uint256 capAfter = priceCapUSDCents(); + uint64 capAfter = priceCapUSDCents(); assert capAfter != capBefore => - (f.selector == sig:setPriceCapUSDCents(uint256).selector || + (f.selector == sig:setPriceCapUSDCents(uint64).selector || f.selector == sig:acceptOwnershipTransfer().selector), "the price ceiling moved through something other than its setter or a handover"; diff --git a/certora/specs/PaymentAdapter.spec b/certora/specs/PaymentAdapter.spec new file mode 100644 index 00000000..5533c049 --- /dev/null +++ b/certora/specs/PaymentAdapter.spec @@ -0,0 +1,275 @@ +/// PaymentAdapter: the currency table and the payment references. +/// +/// Two properties carry this contract. A currency the table has withdrawn must never return a +/// price, because the withdrawal is the only thing stopping a purchase being recorded against a +/// currency the protocol no longer accepts. And a payment reference must never be spendable twice, +/// because a reference is one offchain payment and the backend retries the whole onchain step on +/// any failure, so a reference that could be spent again is a user charged once and billed twice. +/// +/// Scope. This contract calls nothing out, so there is no cross-contract statement to make here. +/// The dispatch list is empty, which leaves the one delegate call, `upgradeToAndCall` reaching +/// `Address.functionDelegateCall`, on the NONDET default. Nothing below says anything about what a +/// new implementation does to this storage on the way through an upgrade. That is deliberate, since +/// the implementation is not known here, but it means the write-once rules cover every caller +/// except the one that replaces the code. +/// +/// The reference rules are the release 1 form of the property section 9 states over `settle`. There +/// is no `settle` yet, so what is provable now is the half that does not depend on it: a reference +/// is marked by one entry point, only for the registry, and never goes back. +/// +/// `renounceOwnership()` is overridden to revert unconditionally, so no rule body can complete on +/// it. It is filtered out of every parametric rule and pinned by `theRenouncePathAlwaysReverts` +/// instead. +/// +/// Reading the result, which the headline count gets backwards. `rule_sanity` adds `assert false` +/// to each rule and reports the verdict of that modified rule rather than a verdict on the check. A +/// record reading `Violated: --rule_not_vacuous` means the body was reachable, which +/// is the outcome wanted. Count the records carrying no sanity suffix and ignore the fraction. + +methods { + function assets(bytes32) external returns (bool, bool, uint8, address) envfree; + function usedReferences(bytes32) external returns (bool) envfree; + function quote(bytes32, uint64) external returns (uint256) envfree; + function registry() external returns (address) envfree; + function settlementToken() external returns (address) envfree; + function owner() external returns (address) envfree; + + /// Nothing outside this contract is in the scene. + unresolved external in _._ => DISPATCH [] default NONDET; +} + +/// Whether the table currently accepts this currency. +function currencyIsAllowed(bytes32 symbol) returns bool { + bool allowed; + bool isDollarUnit; + uint8 decimals; + address token; + allowed, isDollarUnit, decimals, token = assets(symbol); + + return allowed; +} + +/// A currency is registered once its decimals are non-zero, which is what the two setters read. +function currencyIsRegistered(bytes32 symbol) returns bool { + bool allowed; + bool isDollarUnit; + uint8 decimals; + address token; + allowed, isDollarUnit, decimals, token = assets(symbol); + + return decimals != 0; +} + +/// The one method no rule body can complete on. +definition alwaysReverts(method f) returns bool = + f.selector == sig:renounceOwnership().selector; + +/// The renounce path is closed. +/// +/// What the filter above gives up, stated directly. The owner is the only caller +/// `_authorizeUpgrade` accepts and the only one that can change the currency table, so renouncing +/// would freeze both for good. +rule theRenouncePathAlwaysReverts() { + env callEnv; + renounceOwnership@withrevert(callEnv); + + assert lastReverted, "the adapter owner was able to renounce ownership"; +} + +// --------------------------------------------------------------------------------------------- +// The currency table +// --------------------------------------------------------------------------------------------- + +/// A withdrawn currency never returns a price. +/// +/// The property section 9 asks for. Stated as a revert rather than as a zero answer, because zero +/// is a real answer for a zero price and a caller cannot tell the two apart. +rule aWithdrawnCurrencyNeverQuotes(bytes32 symbol, uint64 priceUSDCents) { + require !currencyIsAllowed(symbol); + + quote@withrevert(symbol, priceUSDCents); + + assert lastReverted, "a currency the table does not allow returned a price"; +} + +/// And no call leaves the contract in a state where one would. +/// +/// The rule above is about one state. This one says no method reaches a state it does not hold in, +/// which is what makes it a property of the contract rather than of a starting point. +rule noMethodLetsAWithdrawnCurrencyQuote(method f, bytes32 symbol, uint64 priceUSDCents) + filtered { f -> !alwaysReverts(f) } +{ + env callEnv; + calldataarg args; + f(callEnv, args); + + require !currencyIsAllowed(symbol); + quote@withrevert(symbol, priceUSDCents); + + assert lastReverted, "a call left a withdrawn currency able to return a price"; +} + +/// The currency table moves through its two setters and nothing else. +/// +/// Parametric, so an entry point added later has to satisfy it without anyone remembering to +/// extend a test. +rule theCurrencyTableMovesOnlyThroughItsSetters(method f, bytes32 symbol) + filtered { f -> !alwaysReverts(f) } +{ + bool allowedBefore = currencyIsAllowed(symbol); + bool registeredBefore = currencyIsRegistered(symbol); + + env callEnv; + calldataarg args; + f(callEnv, args); + + assert (currencyIsAllowed(symbol) != allowedBefore + || currencyIsRegistered(symbol) != registeredBefore) => + (f.selector == sig:registerAsset(bytes32, PaymentAdapter.Asset).selector + || f.selector == sig:updateAsset(bytes32, PaymentAdapter.Asset).selector), + "the currency table changed through something other than its two setters"; +} + +/// Only the owner changes the currency table. +/// +/// The admin names the price on every purchase. One that could also add a currency would be naming +/// the token address it gets paid into. +rule onlyTheOwnerChangesTheCurrencyTable(method f, bytes32 symbol) + filtered { f -> !alwaysReverts(f) } +{ + bool allowedBefore = currencyIsAllowed(symbol); + bool registeredBefore = currencyIsRegistered(symbol); + address ownerBefore = owner(); + + env callEnv; + calldataarg args; + require callEnv.msg.sender != 0; + f(callEnv, args); + + assert (currencyIsAllowed(symbol) != allowedBefore + || currencyIsRegistered(symbol) != registeredBefore) => + callEnv.msg.sender == ownerBefore, + "the currency table was changed by someone other than the owner"; +} + +/// A symbol once registered stays registered. +/// +/// Withdrawing a currency sets `allowed` to false and leaves the entry in place. If the entry could +/// go back to unregistered, `registerAsset` would accept the symbol again and a withdrawn currency +/// could be brought back with different decimals and a different token address. +rule aRegisteredSymbolIsNeverUnregistered(method f, bytes32 symbol) + filtered { f -> !alwaysReverts(f) } +{ + require currencyIsRegistered(symbol); + + env callEnv; + calldataarg args; + f(callEnv, args); + + assert currencyIsRegistered(symbol), "a registered currency went back to unregistered"; +} + +// --------------------------------------------------------------------------------------------- +// Payment references +// --------------------------------------------------------------------------------------------- + +/// A spent reference is never unspent. +/// +/// The half of the double-spend property that does not depend on the settlement path. If a +/// reference could go back, the retry the mapping exists to refuse would go through. +rule aSpentReferenceIsNeverUnspent(method f, bytes32 paymentReference) + filtered { f -> !alwaysReverts(f) } +{ + require usedReferences(paymentReference); + + env callEnv; + calldataarg args; + f(callEnv, args); + + assert usedReferences(paymentReference), "a spent payment reference went back to unspent"; +} + +/// A reference is spent by one entry point, and only for the registry. +/// +/// Both payment paths reach this contract through the registry, so this is what makes one reference +/// unable to be spent once on each of them. +rule aReferenceIsSpentOnlyByTheRegistry(method f, bytes32 paymentReference) + filtered { f -> !alwaysReverts(f) } +{ + require !usedReferences(paymentReference); + address registryBefore = registry(); + + env callEnv; + calldataarg args; + require callEnv.msg.sender != 0; + f(callEnv, args); + + assert usedReferences(paymentReference) => + (f.selector == sig:consumePaymentReference(bytes32).selector + && callEnv.msg.sender == registryBefore), + "a payment reference was spent by something other than the registry"; +} + +/// Spending a reference refuses one already spent. +/// +/// The check itself, stated on its own so a change to it fails here rather than only showing up as +/// a different rule going quiet. +rule spendingARefusesAReferenceAlreadySpent(bytes32 paymentReference) { + require usedReferences(paymentReference); + + env callEnv; + consumePaymentReference@withrevert(callEnv, paymentReference); + + assert lastReverted, "a payment reference was spent a second time"; +} + +/// Spending one reference leaves every other alone. +/// +/// Without this the rules above would hold while a single call marked the whole mapping, which +/// would close every reference the protocol has yet to use. +rule spendingAReferenceTouchesNoOther(method f, bytes32 spent, bytes32 other) + filtered { f -> !alwaysReverts(f) } +{ + require spent != other; + require !usedReferences(other); + + env callEnv; + consumePaymentReference(callEnv, spent); + + assert !usedReferences(other), "spending one payment reference marked another"; +} + +// --------------------------------------------------------------------------------------------- +// Write-once state +// --------------------------------------------------------------------------------------------- + +/// The registry pointer is set once and never moves. +/// +/// It is the whole of the authorisation on `consumePaymentReference`, so a second writer would be a +/// second way to spend references. +rule theRegistryPointerMovesOnlyAtInitialization(method f) filtered { + f -> !alwaysReverts(f) + && f.selector != sig:initialize(address, address, address).selector +} { + address registryBefore = registry(); + + env callEnv; + calldataarg args; + f(callEnv, args); + + assert registry() == registryBefore, "the registry pointer moved outside initialization"; +} + +/// The settlement token is set once and never moves. +rule theSettlementTokenMovesOnlyAtInitialization(method f) filtered { + f -> !alwaysReverts(f) + && f.selector != sig:initialize(address, address, address).selector +} { + address tokenBefore = settlementToken(); + + env callEnv; + calldataarg args; + f(callEnv, args); + + assert settlementToken() == tokenBefore, "the settlement token moved outside initialization"; +} diff --git a/certora/specs/Registry.spec b/certora/specs/Registry.spec index ddc17925..01a4ee23 100644 --- a/certora/specs/Registry.spec +++ b/certora/specs/Registry.spec @@ -259,7 +259,7 @@ rule aValidDeviceWalletNeverBecomesInvalid(method f, address d) filtered { f -> /// wrong for two of them. This one is genuinely write-once. rule theEntryPointMovesOnlyAtInitialization(method f) filtered { f -> !alwaysReverts(f) - && f.selector != sig:initialize(address, address, address, address, address, address, uint256).selector + && f.selector != sig:initialize(address, address, address, address, address, address, uint64).selector } { address entryPointBefore = entryPoint(); @@ -278,7 +278,7 @@ rule theEntryPointMovesOnlyAtInitialization(method f) filtered { /// pointing somewhere the money never went. rule theVaultMovesOnlyThroughItsSetter(method f) filtered { f -> !alwaysReverts(f) - && f.selector != sig:initialize(address, address, address, address, address, address, uint256).selector + && f.selector != sig:initialize(address, address, address, address, address, address, uint64).selector } { address vaultBefore = vault(); @@ -313,7 +313,7 @@ rule theVaultIsNeverSetToZero(address newVault) { /// `disableAdmin` for a reason that has nothing to do with the address moving. rule theAdminMovesOnlyToTheNominatedAddress(method f) filtered { f -> !alwaysReverts(f) - && f.selector != sig:initialize(address, address, address, address, address, address, uint256).selector + && f.selector != sig:initialize(address, address, address, address, address, address, uint64).selector } { address adminBefore = adminOfRecord(); address nominated = newRequestedAdmin(); diff --git a/snapshots/ESIMWallet.Operations.json b/snapshots/ESIMWallet.Operations.json index 294838ee..350b63cc 100644 --- a/snapshots/ESIMWallet.Operations.json +++ b/snapshots/ESIMWallet.Operations.json @@ -1,7 +1,7 @@ { "acceptOwnershipTransfer": "3529", - "buyDataBundle: pulls from the device wallet": "107831", - "buyDataBundle: wallet already holds the price": "156358", + "buyDataBundle: pulls from the device wallet": "107892", + "buyDataBundle: wallet already holds the price": "156419", "deployESIMWallet: through the factory": "289517", "populateHistory: 10 entries": "489289", "requestTransferOwnership": "64251", From 38b1ddcce12dc33bb15b48e83c1f7cf0e96784da Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 19:43:02 +0530 Subject: [PATCH 16/75] Record the prover job the adapter rules were verified at --- certora/specs/PaymentAdapter.spec | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/certora/specs/PaymentAdapter.spec b/certora/specs/PaymentAdapter.spec index 5533c049..ba281110 100644 --- a/certora/specs/PaymentAdapter.spec +++ b/certora/specs/PaymentAdapter.spec @@ -25,6 +25,10 @@ /// to each rule and reports the verdict of that modified rule rather than a verdict on the check. A /// record reading `Violated: --rule_not_vacuous` means the body was reachable, which /// is the outcome wanted. Count the records carrying no sanity suffix and ignore the fraction. +/// +/// Verified 2026-08-28 at +/// `prover.certora.com/output/7200817/464cd89895e440c7845d7bb5e4cbd4f5`: 146 records verified, zero +/// assert failures, every violated record carrying a `-rule_not_vacuous` suffix. methods { function assets(bytes32) external returns (bool, bool, uint8, address) envfree; From 4d29fb6ba47a49feb1773115254f2614bc43bdfb Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 20:14:29 +0530 Subject: [PATCH 17/75] Measure the payment path and refresh the gas baselines 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. --- .gas-snapshot | 996 ++++++++++-------- snapshots/PaymentAdapter.Operations.json | 8 + snapshots/Registry.Operations.json | 2 + .../gas/ESIMWallet.Operations.gas.t.sol | 2 +- .../gas/PaymentAdapter.Operations.gas.t.sol | 76 ++ .../foundry/gas/Registry.Operations.gas.t.sol | 31 +- 6 files changed, 646 insertions(+), 469 deletions(-) create mode 100644 snapshots/PaymentAdapter.Operations.json create mode 100644 test/foundry/gas/PaymentAdapter.Operations.gas.t.sol diff --git a/.gas-snapshot b/.gas-snapshot index ed437b53..d5b391b3 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,501 +1,563 @@ -Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 970816) -Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926184) -Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 931578) -Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1020733) -Account4337Test:test_executeBatch_runsEveryCall() (gas: 1003637) -Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977558) -Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 930869) -Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 928951) -Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924077) -Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924437) -Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 962922) -Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 928591) -Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 928685) -Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007442) -Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966379) -Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970085) -Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964189) -AdminRotationTest:test_acceptAdminUpdate() (gas: 49342) -AdminRotationTest:test_acceptAdminUpdate_afterRevoke() (gas: 50669) -AdminRotationTest:test_acceptAdminUpdate_clearsASuspension() (gas: 65961) -AdminRotationTest:test_acceptAdminUpdate_currentAdmin() (gas: 59638) -AdminRotationTest:test_acceptAdminUpdate_reachesEveryReader() (gas: 67980) -AdminRotationTest:test_acceptAdminUpdate_withoutRequest() (gas: 19753) -AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175944) -AdminRotationTest:test_disableAdmin_endsThePauseLoopAgainstACompromisedKey() (gas: 108656) -AdminRotationTest:test_disableAdmin_rejectsARepeat() (gas: 46562) -AdminRotationTest:test_disableAdmin_rejectsAnyoneButTheOwner() (gas: 38635) -AdminRotationTest:test_enableAdmin_leavesAnOutstandingHandoverAlone() (gas: 305951) -AdminRotationTest:test_enableAdmin_rejectsAnyoneButTheOwner() (gas: 56368) -AdminRotationTest:test_enableAdmin_rejectsWhenNotDisabled() (gas: 21023) -AdminRotationTest:test_enableAdmin_restoresTheGates() (gas: 305163) -AdminRotationTest:test_requestAdminUpdate() (gas: 54233) -AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentLiftsASuspension() (gas: 40498) -AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 307808) -AdminRotationTest:test_requestAdminUpdate_rejectsAnyoneButTheOwner() (gas: 45770) -AdminRotationTest:test_requestAdminUpdate_revoke() (gas: 46364) -AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1228018) -AdminRotationTest:test_requestAdminUpdate_zeroAddress() (gas: 19568) -AdminStorageLayoutTest:test_layout_aDoneOperationNeverReadsReady() (gas: 8042) +Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 971095) +Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926463) +Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 931857) +Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1021012) +Account4337Test:test_executeBatch_runsEveryCall() (gas: 1003894) +Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977837) +Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 931148) +Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 929230) +Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924334) +Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924694) +Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 963201) +Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 928870) +Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 928964) +Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007721) +Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966658) +Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970342) +Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964446) +AdminRotationTest:test_acceptAdminUpdate() (gas: 47187) +AdminRotationTest:test_acceptAdminUpdate_afterRevoke() (gas: 48918) +AdminRotationTest:test_acceptAdminUpdate_clearsASuspension() (gas: 49065) +AdminRotationTest:test_acceptAdminUpdate_currentAdmin() (gas: 42653) +AdminRotationTest:test_acceptAdminUpdate_reachesEveryReader() (gas: 68118) +AdminRotationTest:test_acceptAdminUpdate_withoutRequest() (gas: 19779) +AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175401) +AdminRotationTest:test_disableAdmin_endsThePauseLoopAgainstACompromisedKey() (gas: 64565) +AdminRotationTest:test_disableAdmin_rejectsARepeat() (gas: 29532) +AdminRotationTest:test_disableAdmin_rejectsAnyoneButTheOwner() (gas: 38707) +AdminRotationTest:test_enableAdmin_leavesAnOutstandingHandoverAlone() (gas: 283045) +AdminRotationTest:test_enableAdmin_rejectsAnyoneButTheOwner() (gas: 39370) +AdminRotationTest:test_enableAdmin_rejectsWhenNotDisabled() (gas: 21074) +AdminRotationTest:test_enableAdmin_restoresTheGates() (gas: 282241) +AdminRotationTest:test_requestAdminUpdate() (gas: 37239) +AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentLiftsASuspension() (gas: 36137) +AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 285017) +AdminRotationTest:test_requestAdminUpdate_rejectsAnyoneButTheOwner() (gas: 45957) +AdminRotationTest:test_requestAdminUpdate_revoke() (gas: 43550) +AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1221763) +AdminRotationTest:test_requestAdminUpdate_zeroAddress() (gas: 19658) +AdminStorageLayoutTest:test_layout_aDoneOperationNeverReadsReady() (gas: 8064) AdminStorageLayoutTest:test_layout_accessControlRolesReadSlotZero() (gas: 16215) -AdminStorageLayoutTest:test_layout_minDelayReadsSlotTwo() (gas: 7157) -AdminStorageLayoutTest:test_layout_operationTimestampsReadSlotOne() (gas: 7609) -AdminStorageLayoutTest:test_layout_readinessComesOnlyFromTheTimestamp() (gas: 8985) -AdminStorageLayoutTest:test_layout_theContractAddsNoStorageOfItsOwn() (gas: 7842) -AdminStorageLayoutTest:test_layout_theDelayFloorIsNotInStorage() (gas: 8675) -AdminStorageLayoutTest:test_layout_theGuardianRoleLivesInTheSameMappingAsTheRest() (gas: 17447) -AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2232114) -AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 3721914) -AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3466846) -AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2303754) -AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12325101) -AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3582065) -AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 12374294) -AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5217398) -AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 3755094) -AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 3694986) -AdminUpgradesTest:test_upgrade_rejectsAProposerActingDirectly() (gas: 3695008) -AdminUpgradesTest:test_upgrade_rejectsTheAccountThatUsedToOwnTheProxy() (gas: 3695377) +AdminStorageLayoutTest:test_layout_minDelayReadsSlotTwo() (gas: 7179) +AdminStorageLayoutTest:test_layout_operationTimestampsReadSlotOne() (gas: 7631) +AdminStorageLayoutTest:test_layout_readinessComesOnlyFromTheTimestamp() (gas: 9007) +AdminStorageLayoutTest:test_layout_theContractAddsNoStorageOfItsOwn() (gas: 7864) +AdminStorageLayoutTest:test_layout_theDelayFloorIsNotInStorage() (gas: 8697) +AdminStorageLayoutTest:test_layout_theGuardianRoleLivesInTheSameMappingAsTheRest() (gas: 17469) +AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2248578) +AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 4252888) +AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3466902) +AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2320196) +AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12932702) +AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3658751) +AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 12981881) +AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5233849) +AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 4286072) +AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 4225962) +AdminUpgradesTest:test_upgrade_rejectsAProposerActingDirectly() (gas: 4225984) +AdminUpgradesTest:test_upgrade_rejectsTheAccountThatUsedToOwnTheProxy() (gas: 4226331) Deployer:test_deviceWalletFactory_ownable2Step() (gas: 44710) -Deployer:test_deviceWalletFactory_setUp() (gas: 53717) -Deployer:test_eSIMWalletFactory_ownable2Step() (gas: 44672) -Deployer:test_eSIMWalletFactory_setUp() (gas: 27298) -Deployer:test_lazyWalletRegistry_setUp() (gas: 20953) -Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45678) -Deployer:test_registry_ownable2step() (gas: 47109) -Deployer:test_registry_setUp() (gas: 54681) -DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3655049) -DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3391892) -DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3449700) -DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3393406) -DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3397635) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3429542) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3399091) -DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3386596) -DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3402125) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471252) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3392232) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPullETH() (gas: 3687749) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3388159) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3393345) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3456112) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3394865) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3416414) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3401534) -DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3654459) -DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoETHAccess() (gas: 1269864) -DeviceWalletETHTest:test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() (gas: 3391915) -DeviceWalletETHTest:test_deployESIMWallet_cannotGrantETHAccess() (gas: 3682959) -DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3400654) -DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3453301) -DeviceWalletETHTest:test_getVaultAddress() (gas: 3394028) -DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3416287) -DeviceWalletETHTest:test_pullETH() (gas: 3403340) -DeviceWalletETHTest:test_pullETH_revertsWhilePaused() (gas: 3419859) -DeviceWalletETHTest:test_pullETH_revokedESIMWallet() (gas: 3393680) -DeviceWalletETHTest:test_pullETH_unauthorise() (gas: 3395402) -DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4054961) -DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 3912823) -DeviceWalletETHTest:test_toggleAccessToETH_grant_deviceWalletHasETH() (gas: 3561900) -DeviceWalletETHTest:test_toggleAccessToETH_grant_userHasETH() (gas: 3555598) -DeviceWalletETHTest:test_toggleAccessToETH_revoke_deviceWalletHasETH() (gas: 3398527) -DeviceWalletETHTest:test_toggleAccessToETH_revoke_eSIMWalletHasETH() (gas: 3507771) -DeviceWalletETHTest:test_toggleAccessToETH_revoke_userHasETH() (gas: 3515644) -DeviceWalletETHTest:test_toggleAccessToETH_unauthorised() (gas: 3394834) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4549432) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936440) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960500) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977141) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931433) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930002) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 945902) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_withoutAdminOrRegistry() (gas: 76738) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959333) +Deployer:test_deviceWalletFactory_setUp() (gas: 53722) +Deployer:test_eSIMWalletFactory_ownable2Step() (gas: 44690) +Deployer:test_eSIMWalletFactory_setUp() (gas: 27320) +Deployer:test_lazyWalletRegistry_setUp() (gas: 21019) +Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45819) +Deployer:test_registry_ownable2step() (gas: 47346) +Deployer:test_registry_setUp() (gas: 54915) +DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3711117) +DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3392780) +DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3448920) +DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3394294) +DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3398523) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3430647) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3400141) +DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3387504) +DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3403184) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3470248) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3393269) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPullETH() (gas: 3688808) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3389122) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3394275) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3456798) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3395775) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3417574) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3402590) +DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3710525) +DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoETHAccess() (gas: 1270390) +DeviceWalletETHTest:test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() (gas: 3392825) +DeviceWalletETHTest:test_deployESIMWallet_cannotGrantETHAccess() (gas: 3684115) +DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3401562) +DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3437177) +DeviceWalletETHTest:test_getVaultAddress() (gas: 3395051) +DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3417665) +DeviceWalletETHTest:test_pullETH() (gas: 3404294) +DeviceWalletETHTest:test_pullETH_revertsWhilePaused() (gas: 3420902) +DeviceWalletETHTest:test_pullETH_revokedESIMWallet() (gas: 3394634) +DeviceWalletETHTest:test_pullETH_unauthorise() (gas: 3396332) +DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4112993) +DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 3970607) +DeviceWalletETHTest:test_toggleAccessToETH_grant_deviceWalletHasETH() (gas: 3617633) +DeviceWalletETHTest:test_toggleAccessToETH_grant_userHasETH() (gas: 3611278) +DeviceWalletETHTest:test_toggleAccessToETH_revoke_deviceWalletHasETH() (gas: 3456080) +DeviceWalletETHTest:test_toggleAccessToETH_revoke_eSIMWalletHasETH() (gas: 3563440) +DeviceWalletETHTest:test_toggleAccessToETH_revoke_userHasETH() (gas: 3571324) +DeviceWalletETHTest:test_toggleAccessToETH_unauthorised() (gas: 3395764) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4550894) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936747) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960779) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977425) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931797) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930315) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946334) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_withoutAdminOrRegistry() (gas: 76765) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959612) DeviceWalletFactoryConfigTest:test_addRegistryAddress_onlyOnce() (gas: 22234) -DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutAdmin() (gas: 37763) -DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutOwner() (gas: 21169) -DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3845657) -DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation_admin() (gas: 36128) -DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 565672) -DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller() (gas: 382209) -DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller_cannotCallPostCreateAccount() (gas: 362996) -DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579289) -DeviceWalletFactoryCreateAccountTest:test_createAccount_forwardsValueToAnAlreadyDeployedWallet() (gas: 391096) -DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369798) -DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351655) -DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 927870) -DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885188) -DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886624) -DeviceWalletFactoryGuardsTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 3556193) -DeviceWalletFactoryGuardsTest:test_createAccount_rejectsAnEmptyDeviceIdentifier() (gas: 19689) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsADepositAboveTheETHSent() (gas: 53929) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 937807) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 935730) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortDepositArray() (gas: 57994) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortKeyArray() (gas: 52040) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortSaltArray() (gas: 57157) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyBatch() (gas: 35709) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyDeviceIdentifier() (gas: 57759) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975503) -DeviceWalletFactoryGuardsTest:test_eSIMWalletAdmin_isEmptyUntilTheRegistryIsAdded() (gas: 3565213) -DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroESIMWalletFactory() (gas: 3141634) +DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutAdmin() (gas: 37790) +DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutOwner() (gas: 21191) +DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3845919) +DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation_admin() (gas: 36155) +DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 565934) +DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller() (gas: 382309) +DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller_cannotCallPostCreateAccount() (gas: 363001) +DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579551) +DeviceWalletFactoryCreateAccountTest:test_createAccount_forwardsValueToAnAlreadyDeployedWallet() (gas: 391118) +DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369820) +DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351677) +DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928150) +DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885323) +DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886786) +DeviceWalletFactoryGuardsTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 3556215) +DeviceWalletFactoryGuardsTest:test_createAccount_rejectsAnEmptyDeviceIdentifier() (gas: 19711) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsADepositAboveTheETHSent() (gas: 53937) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938152) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936076) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortDepositArray() (gas: 58027) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortKeyArray() (gas: 52070) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortSaltArray() (gas: 57168) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyBatch() (gas: 35714) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyDeviceIdentifier() (gas: 57816) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975785) +DeviceWalletFactoryGuardsTest:test_eSIMWalletAdmin_isEmptyUntilTheRegistryIsAdded() (gas: 3565235) +DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroESIMWalletFactory() (gas: 3141656) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 3141332) -DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3142122) -DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroVerifier() (gas: 3141833) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539200) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412087) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 917675) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressThatIsNotADeviceWallet() (gas: 88095) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressWithNoCode() (gas: 75321) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnEmptyDeviceIdentifier() (gas: 42104) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnIdentifierTheWalletDoesNotCarry() (gas: 408159) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnOffCurveOwnerKey() (gas: 368096) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsTheWrongSalt() (gas: 391740) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914569) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 917664) -DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheCurrentImplementation() (gas: 31755) -DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheZeroAddress() (gas: 19599) +DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3142144) +DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroVerifier() (gas: 3141855) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539381) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412189) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 917962) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressThatIsNotADeviceWallet() (gas: 88221) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressWithNoCode() (gas: 75397) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnEmptyDeviceIdentifier() (gas: 42131) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnIdentifierTheWalletDoesNotCarry() (gas: 408236) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnOffCurveOwnerKey() (gas: 368123) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsTheWrongSalt() (gas: 391816) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914834) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 917975) +DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheCurrentImplementation() (gas: 31777) +DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheZeroAddress() (gas: 19621) DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnOffCurveOwnerKey() (gas: 41069) -DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnOutOfFieldOwnerKey() (gas: 38574) +DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnOutOfFieldOwnerKey() (gas: 38597) DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnZeroOwnerKeyComponent() (gas: 42931) -DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnOffCurveOwnerKey() (gas: 84696) -DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOwnerKey() (gas: 83819) -DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnOffCurveOwnerKey() (gas: 19804) +DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnOffCurveOwnerKey() (gas: 84774) +DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOwnerKey() (gas: 83875) +DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnOffCurveOwnerKey() (gas: 19826) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnZeroOwnerKey() (gas: 19342) -DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 960959) -DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 953800) +DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961267) +DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954081) DeviceWalletGuardsTest:test_init_rejectsAZeroESIMWalletFactory() (gas: 117215) -DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117788) -DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115125) -DeviceWalletGuardsTest:test_pullETH_rejectsAZeroAmount() (gas: 954315) -DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 971894) -DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963077) -DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 956728) -DeviceWalletGuardsTest:test_toggleAccessToETH_rejectsAnUnknownESIMWallet() (gas: 961049) -DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3410091) -DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4420681) -DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3425857) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3400362) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3411110) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3400334) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3400077) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3400402) -DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128348) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458368) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149006) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1131591) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120002) -DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3392791) -DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion() (gas: 30652) +DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117810) +DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115147) +DeviceWalletGuardsTest:test_pullETH_rejectsAZeroAmount() (gas: 954615) +DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972275) +DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963377) +DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957004) +DeviceWalletGuardsTest:test_toggleAccessToETH_rejectsAnUnknownESIMWallet() (gas: 961325) +DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3411126) +DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4421847) +DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3426970) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3401337) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3412141) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3401340) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3401083) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3401408) +DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128595) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458615) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149253) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1131838) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120249) +DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3393931) +DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion() (gas: 30674) ESIMWalletFactoryTest:test_addRegistryAddress_onlyOnce() (gas: 25701) -ESIMWalletFactoryTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 2412257) -ESIMWalletFactoryTest:test_addRegistryAddress_withoutOwner() (gas: 21188) -ESIMWalletFactoryTest:test_deployESIMWallet() (gas: 343467) -ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837388) -ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324337) -ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() (gas: 331443) -ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 616811) -ESIMWalletFactoryTest:test_deployESIMWallet_unauthorised() (gas: 35337) -ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065344) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3243668) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation_rejectsTheZeroAddress() (gas: 18246) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation_unauthorised() (gas: 20990) -ESIMWalletGuardsTest:test_buyDataBundle_rejectsAZeroPrice() (gas: 985029) -ESIMWalletGuardsTest:test_buyDataBundle_rejectsAnEmptyDataBundleID() (gas: 985066) -ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105517) -ESIMWalletGuardsTest:test_initialize_rejectsAZeroFactory() (gas: 104683) -ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979169) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 977623) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1038935) -ESIMWalletGuardsTest:test_setDataBundlePriceCap_clearingItReturnsToTheRegistryDefault() (gas: 1143185) -ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2397519) -ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2461867) -ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2428883) -ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2401686) -ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2410893) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2396614) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2398769) -ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1509789) -ESIMWalletTest:test_buyDataBundle_allFundsFromUser() (gas: 1650369) -ESIMWalletTest:test_buyDataBundle_followsTheRotatedAdmin() (gas: 1671200) -ESIMWalletTest:test_buyDataBundle_noFundsFromESIMWallet() (gas: 1677690) -ESIMWalletTest:test_buyDataBundle_partialFundsFromESIMWallet() (gas: 1678791) -ESIMWalletTest:test_buyDataBundle_partialFundsFromUserAndESIMWallet() (gas: 1691586) -ESIMWalletTest:test_buyDataBundle_recordsTheHistoryBeforeTheVaultIsPaid() (gas: 1839565) -ESIMWalletTest:test_buyDataBundle_rejectsAPriceAboveTheRegistryDefault() (gas: 1531078) -ESIMWalletTest:test_buyDataBundle_revertsAboveTheRegistryDefault() (gas: 1539919) -ESIMWalletTest:test_buyDataBundle_revertsAboveTheWalletCap() (gas: 1547621) -ESIMWalletTest:test_buyDataBundle_revertsWhilePaused() (gas: 1671436) -ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2444639) -ESIMWalletTest:test_buyDataBundle_theWalletCapOverridesTheRegistryDefault() (gas: 1690613) -ESIMWalletTest:test_owner() (gas: 1505032) -ESIMWalletTest:test_populateHistory() (gas: 1804181) -ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1925903) -ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1850792) -ESIMWalletTest:test_requestTransferOwnership() (gas: 2404128) -ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2424224) -ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3245917) -ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3262420) -ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2422423) -ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2433571) -ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1516516) -ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1509952) -ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1516718) -ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2477306) -ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1507922) -ESIMWalletTest:test_setDataBundlePriceCap_rejectsTheAdmin() (gas: 1511970) -ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533033) -ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1510821) -ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1507659) -ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1509973) -ESIMWalletTest:test_transferOwnership() (gas: 1507792) -GuardianPowersTest:test_disableAdminInstantly_cannotChooseAReplacement() (gas: 36580) -GuardianPowersTest:test_disableAdminInstantly_cannotReinstate() (gas: 95313) -GuardianPowersTest:test_disableAdminInstantly_emitsTheSuspension() (gas: 56366) -GuardianPowersTest:test_disableAdminInstantly_endsThePauseLoop() (gas: 80998) -GuardianPowersTest:test_disableAdminInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 35569) -GuardianPowersTest:test_disableAdminInstantly_suspendsWithoutWaiting() (gas: 60644) -GuardianPowersTest:test_disableAndNominate_rejectsEveryCallerButTheTimelock() (gas: 34201) -GuardianPowersTest:test_disableAndNominate_stripsAndNominatesOnceTheDelayIsServed() (gas: 101738) -GuardianPowersTest:test_execute_closesToOutsidersOnceOpenExecutionIsRevoked() (gas: 110056) -GuardianPowersTest:test_guardian_canStillExecuteOnceOpenExecutionIsClosed() (gas: 134048) +ESIMWalletFactoryTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 2412279) +ESIMWalletFactoryTest:test_addRegistryAddress_withoutOwner() (gas: 21210) +ESIMWalletFactoryTest:test_deployESIMWallet() (gas: 343525) +ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837687) +ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324772) +ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() (gas: 331706) +ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617193) +ESIMWalletFactoryTest:test_deployESIMWallet_unauthorised() (gas: 35389) +ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065366) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3260027) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation_rejectsTheZeroAddress() (gas: 18268) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation_unauthorised() (gas: 21012) +ESIMWalletGuardsTest:test_buyDataBundle_rejectsAZeroPrice() (gas: 1007430) +ESIMWalletGuardsTest:test_buyDataBundle_rejectsAnEmptyDataBundleID() (gas: 1007556) +ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105686) +ESIMWalletGuardsTest:test_initialize_rejectsAZeroFactory() (gas: 104808) +ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979398) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 977902) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039184) +ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1203193) +ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2396173) +ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2460668) +ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2429672) +ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2400963) +ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411607) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395731) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397479) +ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510146) +ESIMWalletTest:test_buyDataBundle_allFundsFromUser() (gas: 1705669) +ESIMWalletTest:test_buyDataBundle_followsTheRotatedAdmin() (gas: 1727975) +ESIMWalletTest:test_buyDataBundle_noFundsFromESIMWallet() (gas: 1767754) +ESIMWalletTest:test_buyDataBundle_partialFundsFromESIMWallet() (gas: 1768844) +ESIMWalletTest:test_buyDataBundle_partialFundsFromUserAndESIMWallet() (gas: 1781678) +ESIMWalletTest:test_buyDataBundle_recordsTheHistoryBeforeTheVaultIsPaid() (gas: 1865665) +ESIMWalletTest:test_buyDataBundle_rejectsAPriceAboveTheRegistryDefault() (gas: 1548747) +ESIMWalletTest:test_buyDataBundle_revertsAboveTheRegistryDefault() (gas: 1559115) +ESIMWalletTest:test_buyDataBundle_revertsAboveTheWalletCap() (gas: 1568468) +ESIMWalletTest:test_buyDataBundle_revertsWhilePaused() (gas: 1728893) +ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2462333) +ESIMWalletTest:test_buyDataBundle_theWalletCapOverridesTheRegistryDefault() (gas: 1747293) +ESIMWalletTest:test_owner() (gas: 1505411) +ESIMWalletTest:test_populateHistory() (gas: 1795240) +ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906487) +ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844040) +ESIMWalletTest:test_requestTransferOwnership() (gas: 2404864) +ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425082) +ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3244889) +ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263568) +ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423234) +ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434550) +ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1516923) +ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510309) +ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517097) +ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2476129) +ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508279) +ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533199) +ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511038) +ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508005) +ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510319) +ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1510867) +ESIMWalletTest:test_transferOwnership() (gas: 1508171) +GuardianPowersTest:test_disableAdminInstantly_cannotChooseAReplacement() (gas: 36672) +GuardianPowersTest:test_disableAdminInstantly_cannotReinstate() (gas: 95421) +GuardianPowersTest:test_disableAdminInstantly_emitsTheSuspension() (gas: 39312) +GuardianPowersTest:test_disableAdminInstantly_endsThePauseLoop() (gas: 64087) +GuardianPowersTest:test_disableAdminInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 35593) +GuardianPowersTest:test_disableAdminInstantly_suspendsWithoutWaiting() (gas: 43602) +GuardianPowersTest:test_disableAndNominate_rejectsEveryCallerButTheTimelock() (gas: 34230) +GuardianPowersTest:test_disableAndNominate_stripsAndNominatesOnceTheDelayIsServed() (gas: 101897) +GuardianPowersTest:test_execute_closesToOutsidersOnceOpenExecutionIsRevoked() (gas: 110078) +GuardianPowersTest:test_guardian_canStillExecuteOnceOpenExecutionIsClosed() (gas: 134114) GuardianPowersTest:test_guardian_cannotCancel() (gas: 55808) -GuardianPowersTest:test_guardian_cannotGrantItselfAnythingDirectly() (gas: 18512) +GuardianPowersTest:test_guardian_cannotGrantItselfAnythingDirectly() (gas: 18534) GuardianPowersTest:test_guardian_cannotSchedule() (gas: 20992) -GuardianPowersTest:test_guardian_holdsNeitherOfTheRolesItActsAgainst() (gas: 20724) -GuardianPowersTest:test_revokeCancellersInstantly_emitsOnePerAccount() (gas: 31905) -GuardianPowersTest:test_revokeCancellersInstantly_leavesTheProposerRoleAlone() (gas: 64598) -GuardianPowersTest:test_revokeCancellersInstantly_mayLeaveNoCancellersAtAll() (gas: 86062) -GuardianPowersTest:test_revokeCancellersInstantly_rejectsACanceller() (gas: 18377) -GuardianPowersTest:test_revokeCancellersInstantly_rejectsARepeatedAccount() (gas: 22088) -GuardianPowersTest:test_revokeCancellersInstantly_rejectsAnAccountThatNeverHeldTheRole() (gas: 19534) -GuardianPowersTest:test_revokeCancellersInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 18245) -GuardianPowersTest:test_revokeCancellersInstantly_revertsTheWholeBatchOnOneBadAccount() (gas: 30933) -GuardianPowersTest:test_revokeCancellersInstantly_stripsAWholeSignerSetAtOnce() (gas: 48729) -GuardianPowersTest:test_revokeCancellersInstantly_takesTheVetoAway() (gas: 68312) -GuardianPowersTest:test_unpauseInstantly_emitsTheRelease() (gas: 44880) -GuardianPowersTest:test_unpauseInstantly_isHarmlessWhenNothingIsPaused() (gas: 33318) -GuardianPowersTest:test_unpauseInstantly_reachesAContractAddedLater() (gas: 92864) -GuardianPowersTest:test_unpauseInstantly_rejectsACanceller() (gas: 17332) -GuardianPowersTest:test_unpauseInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 18168) +GuardianPowersTest:test_guardian_holdsNeitherOfTheRolesItActsAgainst() (gas: 20746) +GuardianPowersTest:test_revokeCancellersInstantly_emitsOnePerAccount() (gas: 31927) +GuardianPowersTest:test_revokeCancellersInstantly_leavesTheProposerRoleAlone() (gas: 64620) +GuardianPowersTest:test_revokeCancellersInstantly_mayLeaveNoCancellersAtAll() (gas: 86084) +GuardianPowersTest:test_revokeCancellersInstantly_rejectsACanceller() (gas: 18399) +GuardianPowersTest:test_revokeCancellersInstantly_rejectsARepeatedAccount() (gas: 22110) +GuardianPowersTest:test_revokeCancellersInstantly_rejectsAnAccountThatNeverHeldTheRole() (gas: 19556) +GuardianPowersTest:test_revokeCancellersInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 18267) +GuardianPowersTest:test_revokeCancellersInstantly_revertsTheWholeBatchOnOneBadAccount() (gas: 30955) +GuardianPowersTest:test_revokeCancellersInstantly_stripsAWholeSignerSetAtOnce() (gas: 48747) +GuardianPowersTest:test_revokeCancellersInstantly_takesTheVetoAway() (gas: 68334) +GuardianPowersTest:test_unpauseInstantly_emitsTheRelease() (gas: 41521) +GuardianPowersTest:test_unpauseInstantly_isHarmlessWhenNothingIsPaused() (gas: 33368) +GuardianPowersTest:test_unpauseInstantly_reachesAContractAddedLater() (gas: 92886) +GuardianPowersTest:test_unpauseInstantly_rejectsACanceller() (gas: 17354) +GuardianPowersTest:test_unpauseInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 18190) GuardianPowersTest:test_unpauseInstantly_rejectsTheProposer() (gas: 17244) -GuardianPowersTest:test_unpauseInstantly_releasesAPauseWithoutWaiting() (gas: 45379) -GuardianPowersTest:test_unpauseInstantly_revertsOnATargetWithNoCode() (gas: 20165) -IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1947951) -IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1106686) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2594066) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928358) -IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 987706) -IdentifierCollisionTest:test_assignESIMIdentifier_rejectsAWalletTheProtocolDoesNotKnow() (gas: 33947) -IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1226104) -IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008369) -IdentifierCollisionTest:test_postCreateAccount_cannotClaimAReservedDeviceIdentifier() (gas: 564076) -IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1230028) -IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1829350) +GuardianPowersTest:test_unpauseInstantly_releasesAPauseWithoutWaiting() (gas: 42192) +GuardianPowersTest:test_unpauseInstantly_revertsOnATargetWithNoCode() (gas: 20187) +IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1943419) +IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1105682) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2594911) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928639) +IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 987791) +IdentifierCollisionTest:test_assignESIMIdentifier_rejectsAWalletTheProtocolDoesNotKnow() (gas: 33971) +IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1225034) +IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008424) +IdentifierCollisionTest:test_postCreateAccount_cannotClaimAReservedDeviceIdentifier() (gas: 562941) +IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1228858) +IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1829694) ImplementationLocksTest:test_DeviceWalletFactory_implementationCannotBeInitialized() (gas: 3065558) ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3333988) ImplementationLocksTest:test_ESIMWalletFactory_implementationCannotBeInitialized() (gas: 1991966) -ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2131517) -ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3515024) -ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 3692568) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2179840) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2169888) -LazySaltCollisionTest:test_getCounterFactualAddress_matchesTheDeployedAddress() (gas: 325274) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortDataBundleArray() (gas: 35461) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortESIMIdentifierArray() (gas: 34699) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyDeviceIdentifier() (gas: 35500) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyESIMIdentifier() (gas: 40861) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsUnpairedESIMsAndBundles() (gas: 40543) -LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsABatchOutsideTheCap() (gas: 216299) -LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsADepositThatDoesNotMatchTheETHSent() (gas: 209298) -LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsASaltWithNoRoomForTheESIMWallets() (gas: 205016) -LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsADeviceItNeverDeployed() (gas: 199573) -LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsANonAdminCaller() (gas: 34196) -LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroRegistry() (gas: 3668545) -LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3669045) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyESIMIdentifier() (gas: 33229) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyNewDeviceIdentifier() (gas: 33345) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfADifferentLength() (gas: 198022) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfTheSameLength() (gas: 199212) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsSwitchingToTheSameDevice() (gas: 198500) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenTheIdentifierIsNotFound() (gas: 84388) -LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3263279) -LazyWalletRegistryTest:test_batchPopulateHistory_acceptsAnIdentifierAtTheLimit() (gas: 205925) -LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4491825) -LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6307236) -LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2979876) -LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3326548) -LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3357163) -LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1611621) -LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongDeviceIdentifier() (gas: 46618) -LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongESIMIdentifier() (gas: 60807) -LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 241098) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6263162) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1687858) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6127596) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2208815) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27298613) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12158867) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6095187) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutAdmin() (gas: 44636) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutESIMIdentifier() (gas: 51592) -LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6110466) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3535648) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_refusesADeviceWithNoFirstBatch() (gas: 464635) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2376235) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6264737) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3268175) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4496897) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_unregisteredIdentfier() (gas: 17751) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2065623) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2532310) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1349208) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1350919) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnESIMItNeverDeployed() (gas: 332246) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1349031) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1558785) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6889443) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2793091) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2063402) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3370283) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1606626) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6289334) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6286463) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_unregistered() (gas: 37008) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_withoutAdmin() (gas: 32438) -NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157847) -NemesisIdentifierSquatPoC:test_NM007_extraESIMWalletsGiveNoRouteIn() (gas: 361289) -NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143195) -NemesisIdentifierSquatPoC:test_NM007_theOldClaimSelectorIsUnreachable() (gas: 13736) -NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55593) -NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 66320) -NemesisPausePoC:test_NM005_suspendingTheAdminRemovesThePauseLever() (gas: 66285) -NemesisPausePoC:test_NM005_theProtocolStaysUnpausedWhileThePauseIsUnreachable() (gas: 48163) -OwnershipHandoverTest:test_accept_doesNothingForAnEmptyBatch() (gas: 2491179) -OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2674702) -OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671575) -OwnershipHandoverTest:test_accept_rejectsATargetThatOfferedNothing() (gas: 2499982) -OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647369) -OwnershipHandoverTest:test_handover_cannotEndWithNoOwnerAtAll() (gas: 74801) -OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292043) -OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2698975) -OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218182) -OwnershipHandoverTest:test_handover_rejectsATransferProposedByAnyoneButTheAdminContract() (gas: 32770) -OwnershipHandoverTest:test_pause_canAlsoBeReleasedThroughTheDelayIfNobodyIsInAHurry() (gas: 82901) -OwnershipHandoverTest:test_pause_staysWithTheAdminKeyWhileTheReleaseMovesToTheContract() (gas: 48238) +ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2134274) +ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3591688) +ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 4224587) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2177908) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168001) +LazySaltCollisionTest:test_getCounterFactualAddress_matchesTheDeployedAddress() (gas: 325465) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortDataBundleArray() (gas: 35457) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortESIMIdentifierArray() (gas: 34648) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyDeviceIdentifier() (gas: 35399) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyESIMIdentifier() (gas: 40731) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsUnpairedESIMsAndBundles() (gas: 40471) +LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsABatchOutsideTheCap() (gas: 215151) +LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsADepositThatDoesNotMatchTheETHSent() (gas: 208055) +LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsASaltWithNoRoomForTheESIMWallets() (gas: 203819) +LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsADeviceItNeverDeployed() (gas: 198334) +LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsANonAdminCaller() (gas: 34223) +LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroRegistry() (gas: 3744186) +LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3744686) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyESIMIdentifier() (gas: 33278) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyNewDeviceIdentifier() (gas: 33394) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfADifferentLength() (gas: 196802) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfTheSameLength() (gas: 198014) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsSwitchingToTheSameDevice() (gas: 197302) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenTheIdentifierIsNotFound() (gas: 84432) +LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3229497) +LazyWalletRegistryTest:test_batchPopulateHistory_acceptsAnIdentifierAtTheLimit() (gas: 204715) +LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4422448) +LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6267984) +LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2943885) +LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3287905) +LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3321324) +LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1607201) +LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongDeviceIdentifier() (gas: 46482) +LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongESIMIdentifier() (gas: 60738) +LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 235233) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6230035) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1673772) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6094296) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2203446) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27255623) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12138157) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6061837) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutAdmin() (gas: 44658) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutESIMIdentifier() (gas: 51638) +LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6077115) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3530501) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_refusesADeviceWithNoFirstBatch() (gas: 461277) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2373117) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6231634) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3234439) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4427566) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_unregisteredIdentfier() (gas: 17775) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2032590) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2519509) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1346260) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1347971) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnESIMItNeverDeployed() (gas: 329181) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1346083) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1551255) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6764343) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2771610) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2046927) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3333481) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1602282) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6255567) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6252650) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_unregistered() (gas: 37057) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_withoutAdmin() (gas: 32465) +NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157722) +NemesisIdentifierSquatPoC:test_NM007_extraESIMWalletsGiveNoRouteIn() (gas: 361543) +NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143142) +NemesisIdentifierSquatPoC:test_NM007_theOldClaimSelectorIsUnreachable() (gas: 13826) +NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55505) +NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 49456) +NemesisPausePoC:test_NM005_suspendingTheAdminRemovesThePauseLever() (gas: 49382) +NemesisPausePoC:test_NM005_theProtocolStaysUnpausedWhileThePauseIsUnreachable() (gas: 31162) +OwnershipHandoverTest:test_accept_doesNothingForAnEmptyBatch() (gas: 2491228) +OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2674937) +OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671837) +OwnershipHandoverTest:test_accept_rejectsATargetThatOfferedNothing() (gas: 2500053) +OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647631) +OwnershipHandoverTest:test_handover_cannotEndWithNoOwnerAtAll() (gas: 74874) +OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292387) +OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2699318) +OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218369) +OwnershipHandoverTest:test_handover_rejectsATransferProposedByAnyoneButTheAdminContract() (gas: 32910) +OwnershipHandoverTest:test_pause_canAlsoBeReleasedThroughTheDelayIfNobodyIsInAHurry() (gas: 82980) +OwnershipHandoverTest:test_pause_staysWithTheAdminKeyWhileTheReleaseMovesToTheContract() (gas: 45797) P256VerificationTest:test_verifySignature_acceptsARealAssertion() (gas: 32871) P256VerificationTest:test_verifySignature_rejectsAnAssertionMadeForAnotherChallenge() (gas: 24383) P256VerificationTest:test_verify_bothPathsAcceptAValidSignature() (gas: 236037) P256VerificationTest:test_verify_bothPathsRejectACorruptedSignature() (gas: 239570) P256VerificationTest:test_verify_theFallbackCostsFarMoreThanThePrecompile() (gas: 239733) -ProtocolAdminTest:test_cancel_leavesThePayloadSchedulableAgain() (gas: 93462) +PaymentAdapterTest:test_consumePaymentReference_marksTheReference() (gas: 46249) +PaymentAdapterTest:test_consumePaymentReference_rejectsAReferenceAlreadySpent() (gas: 45249) +PaymentAdapterTest:test_consumePaymentReference_rejectsAnEmptyReference() (gas: 18468) +PaymentAdapterTest:test_consumePaymentReference_rejectsAnyoneButTheRegistry() (gas: 18346) +PaymentAdapterTest:test_consumePaymentReference_rejectsTheOwner() (gas: 18192) +PaymentAdapterTest:test_implementationCannotBeInitialized() (gas: 1339077) +PaymentAdapterTest:test_initialize_cannotRunTwice() (gas: 23521) +PaymentAdapterTest:test_initialize_rejectsAZeroRegistry() (gas: 1412889) +PaymentAdapterTest:test_initialize_rejectsAZeroSettlementToken() (gas: 1412996) +PaymentAdapterTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 1413606) +PaymentAdapterTest:test_initialize_storesTheAddressesItWasGiven() (gas: 25831) +PaymentAdapterTest:test_quote_convertsCentsToSixDecimals() (gas: 14387) +PaymentAdapterTest:test_quote_convertsCentsToTwoDecimals() (gas: 13195) +PaymentAdapterTest:test_quote_keepsASingleCent() (gas: 13683) +PaymentAdapterTest:test_quote_rejectsACurrencyThatNeedsARate() (gas: 16119) +PaymentAdapterTest:test_quote_rejectsAWithdrawnCurrency() (gas: 31762) +PaymentAdapterTest:test_quote_rejectsAnUnregisteredCurrency() (gas: 16806) +PaymentAdapterTest:test_quote_returnsZeroForAZeroPrice() (gas: 13699) +PaymentAdapterTest:test_registerAsset_rejectsASymbolAlreadyRegistered() (gas: 24147) +PaymentAdapterTest:test_registerAsset_rejectsAnEmptySymbol() (gas: 22630) +PaymentAdapterTest:test_registerAsset_rejectsFewerThanTwoDecimals() (gas: 24228) +PaymentAdapterTest:test_registerAsset_rejectsTheAdmin() (gas: 21684) +PaymentAdapterTest:test_registerAsset_rejectsZeroDecimals() (gas: 24042) +PaymentAdapterTest:test_registerAsset_storesTheEntry() (gas: 52154) +PaymentAdapterTest:test_renounceOwnership_alwaysReverts() (gas: 16646) +PaymentAdapterTest:test_resolveAsset_rejectsAnUnregisteredCurrency() (gas: 17552) +PaymentAdapterTest:test_resolveAsset_returnsACurrencyThatNeedsARate() (gas: 13920) +PaymentAdapterTest:test_resolveAsset_returnsTheStoredEntry() (gas: 16770) +PaymentAdapterTest:test_updateAsset_rejectsAnUnregisteredSymbol() (gas: 21989) +PaymentAdapterTest:test_updateAsset_rejectsFewerThanTwoDecimals() (gas: 24858) +PaymentAdapterTest:test_updateAsset_rejectsTheAdmin() (gas: 22570) +PaymentAdapterTest:test_updateAsset_rewritesTheEntry() (gas: 31284) +PaymentAdapterTest:test_updateAsset_withdrawsACurrency() (gas: 34361) +PaymentAdapterTest:test_upgradeManager_matchesTheOwner() (gas: 13917) +PaymentAdapterTest:test_upgrade_keepsTheSpentReferences() (gas: 1379237) +PaymentAdapterTest:test_upgrade_rejectsAnyoneButTheOwner() (gas: 1343546) +ProtocolAdminTest:test_cancel_leavesThePayloadSchedulableAgain() (gas: 96360) ProtocolAdminTest:test_cancel_rejectsAnAccountHoldingNeitherRole() (gas: 56421) ProtocolAdminTest:test_cancel_rejectsTheGuardian() (gas: 56377) -ProtocolAdminTest:test_cancel_worksForAProposerAndForACancellerAlike() (gas: 110044) -ProtocolAdminTest:test_construction_acceptsAnEmptyCancellerSet() (gas: 2396186) -ProtocolAdminTest:test_construction_grantsTheRolesItSaysItDoes() (gas: 40788) -ProtocolAdminTest:test_construction_keepsRoleAdministrationInsideTheContract() (gas: 28961) -ProtocolAdminTest:test_construction_leavesExecutionOpenToEveryone() (gas: 9266) -ProtocolAdminTest:test_construction_rejectsACancellerThatIsAlsoAProposer() (gas: 197517) +ProtocolAdminTest:test_cancel_worksForAProposerAndForACancellerAlike() (gas: 110061) +ProtocolAdminTest:test_construction_acceptsAnEmptyCancellerSet() (gas: 2396208) +ProtocolAdminTest:test_construction_grantsTheRolesItSaysItDoes() (gas: 40782) +ProtocolAdminTest:test_construction_keepsRoleAdministrationInsideTheContract() (gas: 28983) +ProtocolAdminTest:test_construction_leavesExecutionOpenToEveryone() (gas: 9288) +ProtocolAdminTest:test_construction_rejectsACancellerThatIsAlsoAProposer() (gas: 197539) ProtocolAdminTest:test_construction_rejectsADelayUnderTheFloor() (gas: 202323) -ProtocolAdminTest:test_construction_rejectsAGuardianThatCanAlsoCancel() (gas: 224446) -ProtocolAdminTest:test_construction_rejectsAGuardianThatIsAlsoAProposer() (gas: 282452) -ProtocolAdminTest:test_construction_rejectsAnEmptyGuardianSet() (gas: 200461) -ProtocolAdminTest:test_construction_rejectsTheZeroAddressInEveryRoleList() (gas: 606219) -ProtocolAdminTest:test_construction_spreadsTheCancelPowerWiderThanTheProposerSet() (gas: 48453) -ProtocolAdminTest:test_constructor_rejectsAnEmptyProposerList() (gas: 99242) -ProtocolAdminTest:test_executeBatch_appliesEveryCallOrNone() (gas: 98415) +ProtocolAdminTest:test_construction_rejectsAGuardianThatCanAlsoCancel() (gas: 224468) +ProtocolAdminTest:test_construction_rejectsAGuardianThatIsAlsoAProposer() (gas: 282474) +ProtocolAdminTest:test_construction_rejectsAnEmptyGuardianSet() (gas: 200483) +ProtocolAdminTest:test_construction_rejectsTheZeroAddressInEveryRoleList() (gas: 606241) +ProtocolAdminTest:test_construction_spreadsTheCancelPowerWiderThanTheProposerSet() (gas: 48466) +ProtocolAdminTest:test_constructor_rejectsAnEmptyProposerList() (gas: 99264) +ProtocolAdminTest:test_executeBatch_appliesEveryCallOrNone() (gas: 98494) ProtocolAdminTest:test_executeBatch_rejectsMismatchedArrayLengths() (gas: 16864) -ProtocolAdminTest:test_executeBatch_revertsTheWholeBatchWhenOneCallFails() (gas: 99899) -ProtocolAdminTest:test_execute_allowsAnyoneOnceTheDelayHasPassed() (gas: 82797) +ProtocolAdminTest:test_executeBatch_revertsTheWholeBatchWhenOneCallFails() (gas: 99994) +ProtocolAdminTest:test_execute_allowsAnyoneOnceTheDelayHasPassed() (gas: 83527) ProtocolAdminTest:test_execute_rejectsAnOperationStillInsideItsDelay() (gas: 56256) ProtocolAdminTest:test_execute_rejectsAnOperationThatWasNeverScheduled() (gas: 18891) ProtocolAdminTest:test_getMinDelay_startsAtTheConstructorValue() (gas: 9359) -ProtocolAdminTest:test_ownership_movesTheUpgradeAuthorityWithIt() (gas: 29383) -ProtocolAdminTest:test_ownership_reachesAllFourContracts() (gas: 53636) -ProtocolAdminTest:test_roles_cannotBeGrantedDirectlyByAnyone() (gas: 20630) -ProtocolAdminTest:test_roles_rotateWithoutTouchingTheProtocolContracts() (gas: 171453) -ProtocolAdminTest:test_schedule_rejectsAnAccountWithoutTheProposerRole() (gas: 17713) -ProtocolAdminTest:test_schedule_rejectsTheGuardian() (gas: 18692) -ProtocolAdminTest:test_updateDelay_appliesOnceItHasBeenScheduledAndRun() (gas: 66037) -ProtocolAdminTest:test_updateDelay_cannotBringTheDelayBelowTheFloor() (gas: 69021) -ProtocolAdminTest:test_updateDelay_rejectsACallerThatIsNotTheContractItself() (gas: 12136) -RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheAdmin() (gas: 25377) -RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheZeroAddress() (gas: 18719) -RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295050) -RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1271570) -RegistryGuardsTest:test_bindESIMWallet_rejectsACallerThatIsNotADeviceWallet() (gas: 20694) -RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297255) -RegistryGuardsTest:test_deployLazyWallet_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 30777) -RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 926731) -RegistryGuardsTest:test_deployMoreLazyESIMWallets_rejectsACallerOtherThanTheLazyRegistry() (gas: 23494) -RegistryGuardsTest:test_initialize_rejectsAZeroAdmin() (gas: 3815376) -RegistryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 3814535) -RegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3815401) -RegistryGuardsTest:test_initialize_rejectsAZeroVault() (gas: 3814457) -RegistryGuardsTest:test_populateLazyHistory_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 21047) -RegistryGuardsTest:test_populateLazyHistory_rejectsAnAddressThatIsNotAProtocolESIMWallet() (gas: 23387) -RegistryGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsACallerThatIsNotADeviceWallet() (gas: 21578) -RegistryGuardsTest:test_updateDeviceWalletInfo_rejectsACallerOtherThanTheFactory() (gas: 30070) -RegistryGuardsTest:test_updateDeviceWalletOwnerKey_rejectsACallerThatIsNotADeviceWallet() (gas: 23368) -RegistryInitializeTest:test_initialize_recordsBothFactories() (gas: 20788) -RegistryInitializeTest:test_initialize_recordsThePriceCap() (gas: 15569) -RegistryInitializeTest:test_initialize_revertsWhenDeviceWalletFactoryIsZero() (gas: 3814883) -RegistryInitializeTest:test_initialize_revertsWhenESIMWalletFactoryIsZero() (gas: 3814616) -RegistryInitializeTest:test_initialize_revertsWhenPriceCapIsZero() (gas: 3814061) -RegistryInitializeTest:test_initialize_revertsWhenVaultIsZero() (gas: 3814650) -RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1773050) -RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 921916) -RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1771600) -RegistryTest:test_pause_onlyTheAdminCanTripIt() (gas: 63975) -RegistryTest:test_pause_survivesAnAdminRotation() (gas: 72023) -RegistryTest:test_requireNotPaused_revertsOnlyWhilePaused() (gas: 45427) -RegistryTest:test_setDefaultDataBundlePriceCap_rejectsTheAdmin() (gas: 40064) -RegistryTest:test_setDefaultDataBundlePriceCap_rejectsZero() (gas: 18138) -RegistryTest:test_unpause_onlyTheOwnerCanReleaseIt() (gas: 40756) -RegistryTest:test_updateVaultAddress() (gas: 30855) -RegistryTest:test_updateVaultAddress_rejectsTheAdmin() (gas: 39885) -RegistryTest:test_updateVaultAddress_rejectsTheCurrentAddress() (gas: 23224) -RegistryTest:test_updateVaultAddress_rejectsTheZeroAddress() (gas: 25987) -RenounceOwnershipTest:test_renounceOwnership_revertsOnDeviceWalletFactory() (gas: 30214) +ProtocolAdminTest:test_ownership_movesTheUpgradeAuthorityWithIt() (gas: 29476) +ProtocolAdminTest:test_ownership_reachesAllFourContracts() (gas: 53707) +ProtocolAdminTest:test_roles_cannotBeGrantedDirectlyByAnyone() (gas: 20652) +ProtocolAdminTest:test_roles_rotateWithoutTouchingTheProtocolContracts() (gas: 171502) +ProtocolAdminTest:test_schedule_rejectsAnAccountWithoutTheProposerRole() (gas: 17735) +ProtocolAdminTest:test_schedule_rejectsTheGuardian() (gas: 18714) +ProtocolAdminTest:test_updateDelay_appliesOnceItHasBeenScheduledAndRun() (gas: 66059) +ProtocolAdminTest:test_updateDelay_cannotBringTheDelayBelowTheFloor() (gas: 69043) +ProtocolAdminTest:test_updateDelay_rejectsACallerThatIsNotTheContractItself() (gas: 12158) +RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheAdmin() (gas: 25405) +RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheZeroAddress() (gas: 18743) +RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295340) +RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272120) +RegistryGuardsTest:test_bindESIMWallet_rejectsACallerThatIsNotADeviceWallet() (gas: 20718) +RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297559) +RegistryGuardsTest:test_deployLazyWallet_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 30823) +RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927036) +RegistryGuardsTest:test_deployMoreLazyESIMWallets_rejectsACallerOtherThanTheLazyRegistry() (gas: 23540) +RegistryGuardsTest:test_initialize_rejectsAZeroAdmin() (gas: 4345432) +RegistryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 4344569) +RegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 4345457) +RegistryGuardsTest:test_initialize_rejectsAZeroVault() (gas: 4346491) +RegistryGuardsTest:test_populateLazyHistory_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 20927) +RegistryGuardsTest:test_populateLazyHistory_rejectsAnAddressThatIsNotAProtocolESIMWallet() (gas: 23245) +RegistryGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsACallerThatIsNotADeviceWallet() (gas: 21630) +RegistryGuardsTest:test_updateDeviceWalletInfo_rejectsACallerOtherThanTheFactory() (gas: 30094) +RegistryGuardsTest:test_updateDeviceWalletOwnerKey_rejectsACallerThatIsNotADeviceWallet() (gas: 23398) +RegistryInitializeTest:test_initialize_recordsBothFactories() (gas: 20817) +RegistryInitializeTest:test_initialize_recordsThePriceCap() (gas: 16094) +RegistryInitializeTest:test_initialize_revertsWhenDeviceWalletFactoryIsZero() (gas: 4344836) +RegistryInitializeTest:test_initialize_revertsWhenESIMWalletFactoryIsZero() (gas: 4344569) +RegistryInitializeTest:test_initialize_revertsWhenPriceCapIsZero() (gas: 4346107) +RegistryInitializeTest:test_initialize_revertsWhenVaultIsZero() (gas: 4346706) +RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1771627) +RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922278) +RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770164) +RegistryTest:test_pause_onlyTheAdminCanTripIt() (gas: 47066) +RegistryTest:test_pause_survivesAnAdminRotation() (gas: 55219) +RegistryTest:test_requireNotPaused_revertsOnlyWhilePaused() (gas: 28433) +RegistryTest:test_setDefaultPriceCapUSDCents_rejectsTheAdmin() (gas: 38373) +RegistryTest:test_setDefaultPriceCapUSDCents_rejectsZero() (gas: 17849) +RegistryTest:test_unpause_onlyTheOwnerCanReleaseIt() (gas: 36448) +RegistryTest:test_updateVaultAddress() (gas: 31024) +RegistryTest:test_updateVaultAddress_rejectsTheAdmin() (gas: 40101) +RegistryTest:test_updateVaultAddress_rejectsTheCurrentAddress() (gas: 23282) +RegistryTest:test_updateVaultAddress_rejectsTheZeroAddress() (gas: 26180) +RenounceOwnershipTest:test_renounceOwnership_revertsOnDeviceWalletFactory() (gas: 30236) RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletFactory() (gas: 29394) -RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 923607) -RenounceOwnershipTest:test_renounceOwnership_revertsOnLazyWalletRegistry() (gas: 21941) -RenounceOwnershipTest:test_renounceOwnership_revertsOnRegistry() (gas: 21963) -RoleRecoveryTest:test_grantRole_keepsALaterGuardianExecutingOnceExecutionIsClosed() (gas: 236615) -RoleRecoveryTest:test_grantRole_pairsExecutionWithAGuardianGrantedLater() (gas: 122541) -RoleRecoveryTest:test_grantRole_rejectsMakingACancellerAGuardian() (gas: 71070) +RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 923884) +RenounceOwnershipTest:test_renounceOwnership_revertsOnLazyWalletRegistry() (gas: 22029) +RenounceOwnershipTest:test_renounceOwnership_revertsOnRegistry() (gas: 22063) +RoleRecoveryTest:test_grantRole_keepsALaterGuardianExecutingOnceExecutionIsClosed() (gas: 236681) +RoleRecoveryTest:test_grantRole_pairsExecutionWithAGuardianGrantedLater() (gas: 122563) +RoleRecoveryTest:test_grantRole_rejectsMakingACancellerAGuardian() (gas: 71092) RoleRecoveryTest:test_grantRole_rejectsMakingAGuardianACanceller() (gas: 71729) -RoleRecoveryTest:test_grantRole_rejectsMakingAProposerAGuardian() (gas: 68718) -RoleRecoveryTest:test_grantRole_stillAllowsAnUnrelatedGrant() (gas: 94715) -RoleRecoveryTest:test_recovery_aCompromisedCancellerCannotBlockItsOwnEviction() (gas: 86106) -RoleRecoveryTest:test_recovery_aCompromisedProposerIsEvictedByTheBackupProposer() (gas: 113088) -RoleRecoveryTest:test_recovery_aHostileGuardianIsStillEvictable() (gas: 109854) -RoleRecoveryTest:test_recovery_aKeyholderCanStepDownWithoutWaitingForAnyone() (gas: 30553) -RoleRecoveryTest:test_recovery_addingTheCancelPowerBackTakesTheFullDelay() (gas: 93066) -RoleRecoveryTest:test_recovery_anInstantRevocationBeatsAScheduledGrant() (gas: 94355) -RoleRecoveryTest:test_recovery_hasNoRouteAtAllWithASingleProposer() (gas: 2448776) -RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 145085) -RoleRecoveryTest:test_recovery_nobodyCanStepDownOnAnotherAccountsBehalf() (gas: 18981) +RoleRecoveryTest:test_grantRole_rejectsMakingAProposerAGuardian() (gas: 68740) +RoleRecoveryTest:test_grantRole_stillAllowsAnUnrelatedGrant() (gas: 94737) +RoleRecoveryTest:test_recovery_aCompromisedCancellerCannotBlockItsOwnEviction() (gas: 86128) +RoleRecoveryTest:test_recovery_aCompromisedProposerIsEvictedByTheBackupProposer() (gas: 113113) +RoleRecoveryTest:test_recovery_aHostileGuardianIsStillEvictable() (gas: 109876) +RoleRecoveryTest:test_recovery_aKeyholderCanStepDownWithoutWaitingForAnyone() (gas: 30556) +RoleRecoveryTest:test_recovery_addingTheCancelPowerBackTakesTheFullDelay() (gas: 93088) +RoleRecoveryTest:test_recovery_anInstantRevocationBeatsAScheduledGrant() (gas: 94377) +RoleRecoveryTest:test_recovery_hasNoRouteAtAllWithASingleProposer() (gas: 2448798) +RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 145205) +RoleRecoveryTest:test_recovery_nobodyCanStepDownOnAnotherAccountsBehalf() (gas: 19003) RoleRecoveryTest:test_revokeRole_evictsAGuardianCompletelyInOneBatch() (gas: 92280) -RoleRecoveryTest:test_revokeRole_leavesAnEvictedGuardianAbleToExecute() (gas: 76645) +RoleRecoveryTest:test_revokeRole_leavesAnEvictedGuardianAbleToExecute() (gas: 76667) +SettledPurchaseTest:test_buyDataBundle_rejectsAReferenceSpentBySettlement() (gas: 1119794) +SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19325) +SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1131410) +SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6294286) +SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1111532) +SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1076067) +SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1130996) +SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1127392) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002073) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1056523) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1167471) +SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1118024) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037467) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002252) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1002829) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 1001181) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021066) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49208) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1004881) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6082700) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009619) +SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1106149) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24201) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20780) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20532) +SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29750) StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21377) -StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544232) -StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16007) -StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 853329) -StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34351) -StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 49911) -UpgradeAuthorityTest:test_upgradeManager_acceptsAContractAsTheUpgradeAuthority() (gas: 3872894) -UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWalletRegistry() (gas: 38661) -UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39632) -UpgradeAuthorityTest:test_upgradeManager_matchesTheOwner() (gas: 29265) -UpgradeAuthorityTest:test_upgradeManager_theRetiredOwnerCannotUpgrade() (gas: 3714298) -UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975493) -UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 966985) -UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976006) -UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938020) -UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 975669) -UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 946928) +StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544280) +StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16029) +StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 852859) +StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34475) +StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 50368) +UpgradeAuthorityTest:test_upgradeManager_acceptsAContractAsTheUpgradeAuthority() (gas: 4404048) +UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWalletRegistry() (gas: 38767) +UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39810) +UpgradeAuthorityTest:test_upgradeManager_matchesTheOwner() (gas: 29385) +UpgradeAuthorityTest:test_upgradeManager_theRetiredOwnerCannotUpgrade() (gas: 4245476) +UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975772) +UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967264) +UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976285) +UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938299) +UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 975948) +UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 947207) WebAuthnTest:test_verifySignature_rejectsARegistrationCeremonyType() (gas: 20365) WebAuthnTest:test_verifySignature_rejectsAbsentUserPresentFlag() (gas: 29039) WebAuthnTest:test_verifySignature_rejectsAbsentUserVerifiedFlagWhenRequired() (gas: 28673) diff --git a/snapshots/PaymentAdapter.Operations.json b/snapshots/PaymentAdapter.Operations.json new file mode 100644 index 00000000..cf6bdc40 --- /dev/null +++ b/snapshots/PaymentAdapter.Operations.json @@ -0,0 +1,8 @@ +{ + "consumePaymentReference: a reference not yet spent": "30821", + "quote: 2 decimals": "3242", + "quote: 6 decimals": "7742", + "registerAsset: a currency not in the table": "32911", + "resolveAsset": "7969", + "updateAsset: withdrawing a currency": "16122" +} \ No newline at end of file diff --git a/snapshots/Registry.Operations.json b/snapshots/Registry.Operations.json index bb05adc6..08c85f77 100644 --- a/snapshots/Registry.Operations.json +++ b/snapshots/Registry.Operations.json @@ -2,6 +2,8 @@ "acceptAdminUpdate": "4964", "assignESIMIdentifier: first time": "64764", "pause": "13963", + "recordSettledPurchase: first for the wallet": "124759", + "recordSettledPurchase: second for the same wallet": "87859", "requestAdminUpdate": "10494", "setDefaultPriceCapUSDCents": "13346", "toggleESIMWalletStandbyStatus: off standby": "5365", diff --git a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol index e3cf623e..055fc503 100644 --- a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol +++ b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol @@ -73,7 +73,7 @@ contract ESIMWalletOperationsGasTest is GasBase { _deploy(); vm.prank(address(wallet)); - eSIMWallet.setPriceCapUSDCents(5 ether); + eSIMWallet.setPriceCapUSDCents(50_000); vm.snapshotGasLastCall(NAMESPACE, "setPriceCapUSDCents: set"); vm.prank(address(wallet)); diff --git a/test/foundry/gas/PaymentAdapter.Operations.gas.t.sol b/test/foundry/gas/PaymentAdapter.Operations.gas.t.sol new file mode 100644 index 00000000..ef3908a9 --- /dev/null +++ b/test/foundry/gas/PaymentAdapter.Operations.gas.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "contracts/CustomStructs.sol"; +import {Asset} from "contracts/payments/PaymentAdapter.sol"; + +import {GasBase} from "test/foundry/gas/base/GasBase.sol"; + +/// @notice Gas for the currency table and the payment references. +/// @dev Every purchase on both paths spends a reference and reads a currency, so what is measured +/// here is added to each of them. The reference write is the one that matters: it is a cold +/// SSTORE that can never warm up, since a reference is spent once and never touched again. +/// The two setters are the owner's and run once per currency, so they are here as a record +/// rather than as something to tune. +contract PaymentAdapterOperationsGasTest is GasBase { + + string internal NAMESPACE = "PaymentAdapter.Operations"; + + /// @dev A symbol the fixture has not already registered, so the write is a fresh entry. + bytes32 internal constant ASSET_DAI = bytes32("DAI"); + + /// @notice Adding a currency to the table + function test_registerAsset() public { + vm.prank(upgradeManager); + paymentAdapter.registerAsset(ASSET_DAI, Asset({ + allowed: true, + isDollarUnit: true, + decimals: 18, + token: user1 + })); + vm.snapshotGasLastCall(NAMESPACE, "registerAsset: a currency not in the table"); + } + + /// @notice Withdrawing a currency already in the table + /// @dev The entry stays and only `allowed` moves, so this is a warm write to a slot that is + /// already non-zero rather than a delete. + function test_updateAsset() public { + vm.prank(upgradeManager); + paymentAdapter.updateAsset(ASSET_USDC, Asset({ + allowed: false, + isDollarUnit: true, + decimals: 6, + token: settlementToken + })); + vm.snapshotGasLastCall(NAMESPACE, "updateAsset: withdrawing a currency"); + } + + /// @notice Converting a cent price into a currency's smallest unit + /// @dev Two currencies because the exponent is read from the entry. Same work either way, and + /// the pair says so rather than leaving it to be assumed. + function test_quote() public { + paymentAdapter.quote(ASSET_USDC, TEST_PRICE_CENTS); + vm.snapshotGasLastCall(NAMESPACE, "quote: 6 decimals"); + + paymentAdapter.quote(ASSET_USD, TEST_PRICE_CENTS); + vm.snapshotGasLastCall(NAMESPACE, "quote: 2 decimals"); + } + + /// @notice Reading back a currency entry + /// @dev Every settled purchase does this once, to pick up the token address the payment is + /// recorded against. + function test_resolveAsset() public { + paymentAdapter.resolveAsset(ASSET_USDC); + vm.snapshotGasLastCall(NAMESPACE, "resolveAsset"); + } + + /// @notice Spending a payment reference + /// @dev The cold SSTORE both payment paths carry. Nothing ever reads the slot again, so this + /// is the floor under every purchase and there is no warm case to measure against it. + function test_consumePaymentReference() public { + vm.prank(address(registry)); + paymentAdapter.consumePaymentReference(paymentRef("gas-consume")); + vm.snapshotGasLastCall(NAMESPACE, "consumePaymentReference: a reference not yet spent"); + } +} diff --git a/test/foundry/gas/Registry.Operations.gas.t.sol b/test/foundry/gas/Registry.Operations.gas.t.sol index 42e08812..27a6cdb6 100644 --- a/test/foundry/gas/Registry.Operations.gas.t.sol +++ b/test/foundry/gas/Registry.Operations.gas.t.sol @@ -84,10 +84,39 @@ contract RegistryOperationsGasTest is GasBase { /// @notice Moving the default price cap new eSIM wallets inherit function test_setDefaultPriceCapUSDCents() public { vm.prank(upgradeManager); - registry.setDefaultPriceCapUSDCents(2 ether); + registry.setDefaultPriceCapUSDCents(200_000); vm.snapshotGasLastCall(NAMESPACE, "setDefaultPriceCapUSDCents"); } + /// @notice Recording a purchase paid for outside the protocol + /// @dev The counterpart to `buyDataBundle` in the eSIM wallet file. No ETH moves here, so the + /// gap between the two is what the transfer and the vault payment cost. Second call as + /// well, because the first one warms the adapter and the wallet and the admin's batches + /// are many of these in a row. + function test_recordSettledPurchase() public { + (, MockESIMWallet eSIMWallet) = _deployDeviceWallet(customDeviceUniqueIdentifiers[0], 0, 8702); + + vm.prank(eSIMWalletAdmin); + registry.recordSettledPurchase( + address(eSIMWallet), + bundle("DB_GAS_SETTLED_1", TEST_PRICE_CENTS), + ASSET_USDC, + 1_000_000, + paymentRef("gas-settled-1") + ); + vm.snapshotGasLastCall(NAMESPACE, "recordSettledPurchase: first for the wallet"); + + vm.prank(eSIMWalletAdmin); + registry.recordSettledPurchase( + address(eSIMWallet), + bundle("DB_GAS_SETTLED_2", TEST_PRICE_CENTS), + ASSET_USDC, + 1_000_000, + paymentRef("gas-settled-2") + ); + vm.snapshotGasLastCall(NAMESPACE, "recordSettledPurchase: second for the same wallet"); + } + /// @notice Pointing every data bundle payment at a different vault function test_updateVaultAddress() public { vm.prank(upgradeManager); From 0be794fa0e11f29cdbd9a1e2cf821f74002ce2a1 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 20:25:30 +0530 Subject: [PATCH 18/75] Baseline the adapter's storage and pin the packed slots The ceiling now shares a slot with an address on both the registry and the wallet, and nothing pinned those bytes. --- scripts/checks/storage-layouts.py | 1 + storage-layouts/ESIMWallet.json | 10 +-- storage-layouts/PaymentAdapter.json | 33 +++++++ storage-layouts/Registry.json | 13 ++- .../unit-testing/upgrade/StorageLayout.t.sol | 87 ++++++++++++++++++- 5 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 storage-layouts/PaymentAdapter.json diff --git a/scripts/checks/storage-layouts.py b/scripts/checks/storage-layouts.py index 034072a5..644d2c1f 100644 --- a/scripts/checks/storage-layouts.py +++ b/scripts/checks/storage-layouts.py @@ -38,6 +38,7 @@ "ESIMWallet", "Account4337", "ProtocolAdmin", + "PaymentAdapter", "P256Verifier", ) diff --git a/storage-layouts/ESIMWallet.json b/storage-layouts/ESIMWallet.json index 033cee50..8f8b4e2f 100644 --- a/storage-layouts/ESIMWallet.json +++ b/storage-layouts/ESIMWallet.json @@ -37,11 +37,11 @@ "type": "t_address" }, { - "slot": "5", - "offset": 0, - "bytes": "32", - "label": "dataBundlePriceCap", - "type": "t_uint256" + "slot": "4", + "offset": 20, + "bytes": "8", + "label": "priceCapUSDCents", + "type": "t_uint64" } ] } diff --git a/storage-layouts/PaymentAdapter.json b/storage-layouts/PaymentAdapter.json new file mode 100644 index 00000000..ffe2c31c --- /dev/null +++ b/storage-layouts/PaymentAdapter.json @@ -0,0 +1,33 @@ +{ + "contract": "contracts/payments/PaymentAdapter.sol:PaymentAdapter", + "storage": [ + { + "slot": "0", + "offset": 0, + "bytes": "20", + "label": "registry", + "type": "t_address" + }, + { + "slot": "1", + "offset": 0, + "bytes": "20", + "label": "settlementToken", + "type": "t_address" + }, + { + "slot": "2", + "offset": 0, + "bytes": "32", + "label": "assets", + "type": "t_mapping(t_bytes32,t_struct(Asset)_storage)" + }, + { + "slot": "3", + "offset": 0, + "bytes": "32", + "label": "usedReferences", + "type": "t_mapping(t_bytes32,t_bool)" + } + ] +} diff --git a/storage-layouts/Registry.json b/storage-layouts/Registry.json index 86c68a6a..835c78ba 100644 --- a/storage-layouts/Registry.json +++ b/storage-layouts/Registry.json @@ -127,12 +127,19 @@ "label": "adminDisabled", "type": "t_bool" }, + { + "slot": "64", + "offset": 22, + "bytes": "8", + "label": "defaultPriceCapUSDCents", + "type": "t_uint64" + }, { "slot": "65", "offset": 0, - "bytes": "32", - "label": "defaultDataBundlePriceCap", - "type": "t_uint256" + "bytes": "20", + "label": "paymentAdapter", + "type": "t_address" } ] } diff --git a/test/foundry/unit-testing/upgrade/StorageLayout.t.sol b/test/foundry/unit-testing/upgrade/StorageLayout.t.sol index ce7698da..89a4e09e 100644 --- a/test/foundry/unit-testing/upgrade/StorageLayout.t.sol +++ b/test/foundry/unit-testing/upgrade/StorageLayout.t.sol @@ -111,8 +111,19 @@ contract StorageLayoutTest is DeployerBase { (, uint64 price,) = _wallet.transactionHistory(0); assertEq(price, 0xB1, "ESIMWallet.transactionHistory must read slot 3"); - vm.store(_target, bytes32(uint256(4)), bytes32(uint256(uint160(SENTINEL)))); + // Slot 4 carries both, the address in the low 20 bytes and the ceiling in the 8 above it. + // Written as one word with a different value in each field, so a getter reading the wrong + // one cannot come back with the value the other was given. + vm.store(_target, bytes32(uint256(4)), _walletSlotFour(SENTINEL, 4242)); assertEq(_wallet.newRequestedOwner(), SENTINEL, "ESIMWallet.newRequestedOwner must read slot 4"); + assertEq(_wallet.priceCapUSDCents(), 4242, "ESIMWallet.priceCapUSDCents must read slot 4 byte 20"); + } + + /// @notice Slot 4 of an eSIM wallet, with both packed fields set + /// @dev The offsets in the baseline count from the low end of the word, so the address sits in + /// the low 160 bits and the ceiling starts where it ends. + function _walletSlotFour(address _newOwner, uint64 _cap) private pure returns (bytes32) { + return bytes32(uint256(uint160(_newOwner)) | (uint256(_cap) << 160)); } function test_layout_registrySlotsAreUnchanged() public { @@ -200,8 +211,43 @@ contract StorageLayoutTest is DeployerBase { ); assertEq(registry.upgradeManager(), registry.owner(), "Registry.upgradeManager must not read slot 63"); - vm.store(_target, bytes32(uint256(64)), bytes32(uint256(uint160(SENTINEL)))); + // Four fields share slot 64. One word with a distinct value in each, so a getter reading + // the wrong field cannot pass by picking up its neighbour's value. + vm.store(_target, bytes32(uint256(64)), _registrySlotSixtyFour(SENTINEL, true, false, 4242)); assertEq(registry.newRequestedAdmin(), SENTINEL, "Registry.newRequestedAdmin must read slot 64"); + assertEq(registry.paused(), true, "Registry.paused must read slot 64 byte 20"); + assertEq(registry.adminDisabled(), false, "Registry.adminDisabled must read slot 64 byte 21"); + + _assertRegistryPackedTail(_target); + } + + /// @dev Split out because the assertions are cumulative on the stack. + function _assertRegistryPackedTail(address _target) private { + assertEq( + registry.defaultPriceCapUSDCents(), + 4242, + "Registry.defaultPriceCapUSDCents must read slot 64 byte 22" + ); + + vm.store(_target, bytes32(uint256(65)), bytes32(uint256(uint160(SENTINEL)))); + assertEq(registry.paymentAdapter(), SENTINEL, "Registry.paymentAdapter must read slot 65"); + } + + /// @notice Slot 64 of the registry, with all four packed fields set + /// @dev Offsets count from the low end of the word: the address takes the low 160 bits, the two + /// flags one byte each above it, then the ceiling. + function _registrySlotSixtyFour( + address _newAdmin, + bool _paused, + bool _adminDisabled, + uint64 _cap + ) private pure returns (bytes32) { + return bytes32( + uint256(uint160(_newAdmin)) + | (uint256(_paused ? 1 : 0) << 160) + | (uint256(_adminDisabled ? 1 : 0) << 168) + | (uint256(_cap) << 176) + ); } function test_layout_lazyWalletRegistrySlotsAreUnchanged() public { @@ -326,4 +372,41 @@ contract StorageLayoutTest is DeployerBase { "ESIMWalletFactory.isESIMWalletDeployed must read slot 2" ); } + + /// @notice Slot holding `_key`'s entry in the bytes32-keyed mapping declared at `_slot`. + function _symbolEntry(bytes32 _key, uint256 _slot) private pure returns (bytes32) { + return keccak256(abi.encode(_key, _slot)); + } + + function test_layout_paymentAdapterSlotsAreUnchanged() public { + address target = address(paymentAdapter); + + vm.store(target, bytes32(uint256(0)), bytes32(uint256(uint160(SENTINEL)))); + assertEq(paymentAdapter.registry(), SENTINEL, "PaymentAdapter.registry must read slot 0"); + + vm.store(target, bytes32(uint256(1)), bytes32(uint256(uint160(SENTINEL)))); + assertEq(paymentAdapter.settlementToken(), SENTINEL, "PaymentAdapter.settlementToken must read slot 1"); + + _assertPaymentAdapterMappings(target); + } + + /// @dev Split out because the assertions are cumulative on the stack. + function _assertPaymentAdapterMappings(address _target) private { + // The whole entry is one slot, so the four fields are written as one word. Allowed and + // in dollars in the low two bytes, then the decimals, then the token address. + bytes32 entry = bytes32( + uint256(1) | (uint256(1) << 8) | (uint256(6) << 16) | (uint256(uint160(SENTINEL)) << 24) + ); + vm.store(_target, _symbolEntry(ASSET_USD, 2), entry); + (bool allowed,, uint8 decimals, address token) = paymentAdapter.assets(ASSET_USD); + assertTrue(allowed, "PaymentAdapter.assets must read slot 2"); + assertEq(decimals, 6, "PaymentAdapter.assets decimals must read slot 2"); + assertEq(token, SENTINEL, "PaymentAdapter.assets token must read slot 2"); + + vm.store(_target, _symbolEntry(bytes32("pin"), 3), bytes32(uint256(1))); + assertTrue( + paymentAdapter.usedReferences(bytes32("pin")), + "PaymentAdapter.usedReferences must read slot 3" + ); + } } From 8beadd0b1a8aace2c51a85d392e3893269ebb4f3 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 20:25:36 +0530 Subject: [PATCH 19/75] Record what a deployed proxy reads after the ceiling moved 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. --- .gas-snapshot | 19 +- .../upgrade/PriceCapSlotMigration.t.sol | 181 ++++++++++++++++++ 2 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol diff --git a/.gas-snapshot b/.gas-snapshot index d5b391b3..45e153e4 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -424,6 +424,12 @@ PaymentAdapterTest:test_updateAsset_withdrawsACurrency() (gas: 34361) PaymentAdapterTest:test_upgradeManager_matchesTheOwner() (gas: 13917) PaymentAdapterTest:test_upgrade_keepsTheSpentReferences() (gas: 1379237) PaymentAdapterTest:test_upgrade_rejectsAnyoneButTheOwner() (gas: 1343546) +PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011216) +PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1178811) +PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982600) +PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1135158) +PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814231) +PriceCapSlotMigrationTest:test_migration_theOldCeilingSlotIsReadAsTheAdapter() (gas: 11352) ProtocolAdminTest:test_cancel_leavesThePayloadSchedulableAgain() (gas: 96360) ProtocolAdminTest:test_cancel_rejectsAnAccountHoldingNeitherRole() (gas: 56421) ProtocolAdminTest:test_cancel_rejectsTheGuardian() (gas: 56377) @@ -541,12 +547,13 @@ SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 2 SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20780) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20532) SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29750) -StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21377) -StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544280) -StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16029) -StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 852859) -StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34475) -StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 50368) +StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21373) +StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544294) +StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16047) +StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 854990) +StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34461) +StorageLayoutTest:test_layout_paymentAdapterSlotsAreUnchanged() (gas: 18564) +StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 57163) UpgradeAuthorityTest:test_upgradeManager_acceptsAContractAsTheUpgradeAuthority() (gas: 4404048) UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWalletRegistry() (gas: 38767) UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39810) diff --git a/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol b/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol new file mode 100644 index 00000000..8a4956ea --- /dev/null +++ b/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "contracts/CustomStructs.sol"; +import {Errors} from "contracts/Errors.sol"; + +import "test/utils/DeployerBase.sol"; + +/// @notice What the deployed proxies read after the cents change, and why the upgrade needs two +/// transactions behind it. +/// @dev The price ceiling moved from its own slot to eight bytes packed above an address, on both +/// the registry and the eSIM wallet, and the registry's old ceiling slot is now the payment +/// adapter pointer. Neither move breaks a compile and neither fails any other test, because +/// every test starts from a proxy this code initialised. A proxy the old code initialised is +/// the case nothing else covers, and the tests below are that case. +/// +/// Each one writes the slot the way the old deployment left it and reads back through the new +/// getters. They pass by asserting what the protocol actually does, not what it should do, so +/// they are a record of the migration that is owed rather than a check that it happened. +contract PriceCapSlotMigrationTest is DeployerBase { + + /// @dev Registry slot 64 packs `newRequestedAdmin`, `paused`, `adminDisabled` and the ceiling. + uint256 private constant REGISTRY_PACKED_SLOT = 64; + + /// @dev Held the old ceiling in wei. Holds the payment adapter pointer now. + uint256 private constant REGISTRY_ADAPTER_SLOT = 65; + + /// @dev eSIM wallet slot 4 packs `newRequestedOwner` and the wallet's own ceiling. + uint256 private constant WALLET_PACKED_SLOT = 4; + + /// @dev What a deployed registry holds in slot 65: a ceiling of 0.05 ETH in wei. + uint256 private constant OLD_CAP_IN_WEI = 0.05 ether; + + DeviceWallet private deviceWallet; + MockESIMWallet private eSIMWallet; + + function _deployWallets() private { + string[] memory identifiers = new string[](1); + bytes32[2][] memory keys = new bytes32[2][](1); + uint256[] memory salts = new uint256[](1); + + identifiers[0] = customDeviceUniqueIdentifiers[0]; + keys[0] = pubKey1; + salts[0] = 7300; + + vm.prank(eSIMWalletAdmin); + Wallets[] memory wallets = + deviceWalletFactory.deployDeviceWalletForUsers(identifiers, keys, salts, new uint256[](1)); + + deviceWallet = DeviceWallet(payable(wallets[0].deviceWallet)); + eSIMWallet = MockESIMWallet(payable(wallets[0].eSIMWallet)); + + vm.deal(address(deviceWallet), 100 ether); + vm.prank(address(deviceWallet)); + deviceWallet.toggleAccessToETH(address(eSIMWallet), true); + } + + /// @notice Clears the eight ceiling bytes and leaves the rest of the slot alone + /// @dev The old code never wrote them, so on a deployed proxy they are zero whatever else the + /// slot holds. + function _clearCeilingBytes(address _target, uint256 _slot, uint256 _bitOffset) private { + uint256 word = uint256(vm.load(_target, bytes32(_slot))); + vm.store(_target, bytes32(_slot), bytes32(word & ~(uint256(type(uint64).max) << _bitOffset))); + } + + // --------------------------------------------------------------------------------------------- + // The ceiling + // --------------------------------------------------------------------------------------------- + + /// @notice Both ceilings read zero on a proxy the old code initialised + /// @dev The registry's moved into slot 64 and the wallet's into slot 4, in bytes neither + /// contract ever wrote before, so both come back as a slot that was never set. + function test_migration_bothCeilingsReadZero() public { + _deployWallets(); + + _clearCeilingBytes(address(registry), REGISTRY_PACKED_SLOT, 176); + _clearCeilingBytes(address(eSIMWallet), WALLET_PACKED_SLOT, 160); + + assertEq(registry.defaultPriceCapUSDCents(), 0, "The registry ceiling is not carried over"); + assertEq(eSIMWallet.priceCapUSDCents(), 0, "The wallet ceiling is not carried over"); + } + + /// @notice Two zero ceilings mean no ceiling, not a ceiling of zero + /// @dev Zero on the wallet hands the decision to the registry and zero on the registry means + /// unlimited, so an upgraded deployment accepts any price until someone calls + /// `setDefaultPriceCapUSDCents`. That call is the migration, and this is what it costs to + /// skip it. + function test_migration_anUnsetCeilingAcceptsAnyPrice() public { + _deployWallets(); + + _clearCeilingBytes(address(registry), REGISTRY_PACKED_SLOT, 176); + _clearCeilingBytes(address(eSIMWallet), WALLET_PACKED_SLOT, 160); + + vm.prank(eSIMWalletAdmin); + eSIMWallet.buyDataBundle(bundle("DB_UNCAPPED", type(uint64).max), 1 ether, nextRef()); + + (, uint64 recorded,) = eSIMWallet.transactionHistory(0); + assertEq(recorded, type(uint64).max, "A price of $184 quadrillion was recorded with no ceiling set"); + } + + /// @notice Setting the registry ceiling is enough to close it again + /// @dev Every wallet reads the registry when its own is zero, so one owner call covers the + /// whole deployment and no per-wallet transaction is needed. + function test_migration_settingTheRegistryCeilingCoversEveryWallet() public { + _deployWallets(); + + _clearCeilingBytes(address(registry), REGISTRY_PACKED_SLOT, 176); + _clearCeilingBytes(address(eSIMWallet), WALLET_PACKED_SLOT, 160); + + vm.prank(upgradeManager); + registry.setDefaultPriceCapUSDCents(defaultPriceCapUSDCents); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert( + abi.encodeWithSelector( + Errors.DataBundlePriceAboveCap.selector, type(uint64).max, defaultPriceCapUSDCents + ) + ); + eSIMWallet.buyDataBundle(bundle("DB_UNCAPPED", type(uint64).max), 1 ether, nextRef()); + } + + // --------------------------------------------------------------------------------------------- + // The adapter pointer + // --------------------------------------------------------------------------------------------- + + /// @notice The old ceiling slot is read as the payment adapter + /// @dev Slot 65 held a ceiling in wei and now holds an address, so a deployed registry comes up + /// pointing at the low twenty bytes of whatever that number was. + function test_migration_theOldCeilingSlotIsReadAsTheAdapter() public { + vm.store(address(registry), bytes32(REGISTRY_ADAPTER_SLOT), bytes32(OLD_CAP_IN_WEI)); + + assertEq( + registry.paymentAdapter(), + address(uint160(OLD_CAP_IN_WEI)), + "The leftover ceiling is read as an address" + ); + } + + /// @notice A leftover ceiling defeats the guard that would have caught it + /// @dev `PaymentAdapterNotSet` only fires on a zero pointer. A non-zero one that holds no code + /// gets called instead, so every purchase reverts with nothing to say why until the owner + /// calls `setPaymentAdapter`. + function test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() public { + _deployWallets(); + vm.store(address(registry), bytes32(REGISTRY_ADAPTER_SLOT), bytes32(OLD_CAP_IN_WEI)); + + assertEq(address(uint160(OLD_CAP_IN_WEI)).code.length, 0, "The leftover address holds no code"); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(); + registry.recordSettledPurchase( + address(eSIMWallet), + bundle("DB_STRANDED", TEST_PRICE_CENTS), + ASSET_USDC, + 0, + nextRef() + ); + } + + /// @notice Pointing the registry at the adapter clears it + /// @dev The pointer is not write-once, so the leftover is recoverable with one owner call. + function test_migration_settingTheAdapterRecoversFromTheLeftover() public { + _deployWallets(); + vm.store(address(registry), bytes32(REGISTRY_ADAPTER_SLOT), bytes32(OLD_CAP_IN_WEI)); + + vm.prank(upgradeManager); + registry.setPaymentAdapter(address(paymentAdapter)); + + vm.prank(eSIMWalletAdmin); + registry.recordSettledPurchase( + address(eSIMWallet), + bundle("DB_RECOVERED", TEST_PRICE_CENTS), + ASSET_USDC, + 0, + nextRef() + ); + + assertEq(eSIMWallet.getTransactionHistory().length, 1, "The purchase is recorded once the pointer is right"); + } +} From d4417373c6b3073734116a9def07995d8dd8f4a6 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 20:37:49 +0530 Subject: [PATCH 20/75] Register the two currencies at configure time 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. --- env.sample | 10 ++- scripts/deploy/Configure.s.sol | 113 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/env.sample b/env.sample index e2f079f1..0f1f1fb6 100644 --- a/env.sample +++ b/env.sample @@ -36,7 +36,15 @@ TIMELOCK_CANCELLERS= # zero, because zero on a wallet means "follow the registry" and would mean nothing here. PRICE_CAP_USD_CENTS= -# Currency the vault is meant to end up holding, normally USDC on the target chain. +# Currency the vault is meant to end up holding, normally USDC on the target chain. Configure.s.sol +# registers it under the symbol USDC and reads its decimals off the token itself. +# +# This address is different on every chain, and the wrong chain's can still hold code: on OP Sepolia +# the Base Sepolia address below is a contract that is not a token at all. Both verified with +# `cast call` on 2026-08-28, symbol USDC and 6 decimals: +# base-sepolia 0x036CbD53842c5426634e7929541eC2318f3dCF7e +# op-sepolia 0x5fd84259d66Cd46123540766Be93DFE6D43130D7 +# Verify any other chain's the same way before filling it in. SETTLEMENT_TOKEN= # --------------------------------------------------------------------------------------------- diff --git a/scripts/deploy/Configure.s.sol b/scripts/deploy/Configure.s.sol index 6e93156a..cba0ae04 100644 --- a/scripts/deploy/Configure.s.sol +++ b/scripts/deploy/Configure.s.sol @@ -1,12 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.36; +// Interfaces +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + // Contracts import {Script} from "forge-std/Script.sol"; import {console} from "forge-std/console.sol"; import {Registry} from "../../contracts/Registry.sol"; import {DeviceWalletFactory} from "../../contracts/device-wallet/DeviceWalletFactory.sol"; import {ESIMWalletFactory} from "../../contracts/esim-wallet/ESIMWalletFactory.sol"; +import {PaymentAdapter, Asset} from "../../contracts/payments/PaymentAdapter.sol"; // Config import {DeployConfig} from "./config/DeployConfig.sol"; @@ -27,12 +31,32 @@ import {DeploymentRecord} from "./config/DeploymentRecord.sol"; /// transaction would leave this script unable to finish the run it started. contract Configure is Script { + /// @notice Symbol the protocol prices fiat payments in + /// @dev No token address, so nothing can be transferred in it. It exists so a purchase settled + /// in cash or on a card has a currency to be recorded against. + bytes32 private constant ASSET_USD = bytes32("USD"); + + /// @notice Symbol the settlement token is registered under + bytes32 private constant ASSET_USDC = bytes32("USDC"); + + /// @notice Decimals a fiat dollar is expressed in, which is cents + uint8 private constant USD_DECIMALS = 2; + /// @notice A wiring call did not take effect error NotWired(string what, address expected, address actual); /// @notice The registry is already bound to a different address than the one recorded error BoundElsewhere(string what, address recorded, address bound); + /// @notice A currency is registered against a different token than the one configured + error AssetBoundElsewhere(bytes32 symbol, address expected, address actual); + + /// @notice The settlement token address holds no code on this chain + error SettlementTokenNotDeployed(address token, uint256 chainId); + + /// @notice The settlement token did not answer `decimals()` + error SettlementTokenNotTokenLike(address token); + /// @notice Wires the protocol together and records that it is configured /// @dev Broadcasts as the deployer, which is still the owner of all four singletons. function run() external { @@ -42,6 +66,12 @@ contract Configure is Script { address lazyWalletRegistry = DeploymentRecord.readAddress("LazyWalletRegistryProxy"); address deviceWalletFactory = DeploymentRecord.readAddress("DeviceWalletFactoryProxy"); address eSIMWalletFactory = DeploymentRecord.readAddress("ESIMWalletFactoryProxy"); + address paymentAdapter = DeploymentRecord.readAddress("PaymentAdapterProxy"); + + // Read before anything is broadcast. The settlement token address is per chain, and the + // one for the wrong chain can still hold code, so asking it for its decimals is what tells + // the two apart. + uint8 settlementDecimals = _settlementTokenDecimals(config.settlementToken); vm.startBroadcast(config.deployerPrivateKey); @@ -49,10 +79,12 @@ contract Configure is Script { _bindRegistryToFactory("ESIMWalletFactory", eSIMWalletFactory, registryAddress); _bindLazyWalletRegistry(registryAddress, lazyWalletRegistry); _setPriceCap(registryAddress, config.priceCapUSDCents); + _registerCurrencies(paymentAdapter, config.settlementToken, settlementDecimals); vm.stopBroadcast(); _verify(registryAddress, lazyWalletRegistry, deviceWalletFactory, eSIMWalletFactory); + _verifyCurrencies(paymentAdapter, config.settlementToken); DeploymentRecord.writeStatus("configured", true); console.log(""); @@ -106,6 +138,87 @@ contract Configure is Script { console.log("Registry: price cap set to", cap); } + /// @notice The settlement token's own decimals, checked to be a token at all + /// @dev Read from the token rather than assumed to be six. USDC is at a different address on + /// every chain and the address for the wrong one can still hold code, so a copied value + /// has to fail here rather than register a currency nothing can be paid in. Runs before + /// the broadcast, since a revert inside one leaves the earlier calls of the run sent. + function _settlementTokenDecimals(address token) private view returns (uint8) { + if(token.code.length == 0) revert SettlementTokenNotDeployed(token, block.chainid); + + try IERC20Metadata(token).decimals() returns (uint8 decimals) { + return decimals; + } catch { + revert SettlementTokenNotTokenLike(token); + } + } + + /// @notice Puts the two currencies the protocol prices in into the adapter's table + /// @dev An adapter with an empty table prices nothing, so every purchase on both paths reverts + /// until this has run. USD carries no token address: a fiat payment happens somewhere the + /// contracts cannot see, and the entry exists only to record what it was priced in. + function _registerCurrencies(address paymentAdapter, address settlementToken, uint8 settlementDecimals) + private + { + _registerCurrency(paymentAdapter, ASSET_USD, Asset({ + allowed: true, + isDollarUnit: true, + decimals: USD_DECIMALS, + token: address(0) + })); + + _registerCurrency(paymentAdapter, ASSET_USDC, Asset({ + allowed: true, + isDollarUnit: true, + decimals: settlementDecimals, + token: settlementToken + })); + } + + /// @notice Adds one currency, unless it is already there + /// @dev `registerAsset` refuses a symbol already in the table, so a re-run after a partial one + /// has to skip rather than call it again. A symbol registered against a different token is + /// a mismatch this script must not paper over: `updateAsset` would change the address + /// every future payment is recorded into. + function _registerCurrency(address paymentAdapter, bytes32 symbol, Asset memory asset) private { + (bool allowed,, uint8 decimals, address token) = PaymentAdapter(paymentAdapter).assets(symbol); + + if(decimals != 0) { + if(token != asset.token) revert AssetBoundElsewhere(symbol, asset.token, token); + + console.log(string.concat("PaymentAdapter: ", _symbolName(symbol), " already registered, skipping")); + if(!allowed) console.log(" warning: it is registered but withdrawn"); + return; + } + + PaymentAdapter(paymentAdapter).registerAsset(symbol, asset); + console.log(string.concat("PaymentAdapter: ", _symbolName(symbol), " registered"), asset.decimals); + } + + /// @notice Reads the currency table back out of the adapter + function _verifyCurrencies(address paymentAdapter, address settlementToken) private view { + (bool usdAllowed,,, address usdToken) = PaymentAdapter(paymentAdapter).assets(ASSET_USD); + if(!usdAllowed || usdToken != address(0)) { + revert NotWired("PaymentAdapter.assets(USD)", address(0), usdToken); + } + + (bool usdcAllowed,,, address usdcToken) = PaymentAdapter(paymentAdapter).assets(ASSET_USDC); + if(!usdcAllowed || usdcToken != settlementToken) { + revert NotWired("PaymentAdapter.assets(USDC)", settlementToken, usdcToken); + } + } + + /// @notice The trailing zero bytes trimmed off a symbol, so it prints readably + function _symbolName(bytes32 symbol) private pure returns (string memory) { + uint256 length; + while(length < 32 && symbol[length] != 0) ++length; + + bytes memory trimmed = new bytes(length); + for(uint256 i = 0; i < length; ++i) trimmed[i] = symbol[i]; + + return string(trimmed); + } + /// @notice Reads every wiring back out of the contracts that hold it function _verify( address registryAddress, From 5ffb73336fcf078ba6bb8d52379746a1be30dd8a Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 21:00:16 +0530 Subject: [PATCH 21/75] Drop declarations nothing reaches 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. --- contracts/Errors.sol | 3 --- contracts/Registry.sol | 1 - contracts/payments/PaymentAdapter.sol | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/contracts/Errors.sol b/contracts/Errors.sol index d2932936..663c2509 100644 --- a/contracts/Errors.sol +++ b/contracts/Errors.sol @@ -136,8 +136,5 @@ interface Errors { error AssetDecimalsTooHigh(bytes32 asset, uint8 decimals); // No price feeds, so only a currency already in dollars can be converted from cents error AssetNeedsSwap(bytes32 asset); - // Fiat and non-EVM currencies have no token address, so nothing can be transferred - error AssetNotSettleable(bytes32 asset); error PaymentReferenceAlreadyUsed(bytes32 paymentReference); - error SettlementNotAvailable(); } diff --git a/contracts/Registry.sol b/contracts/Registry.sol index 7aa7d66a..a7e5a339 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -13,7 +13,6 @@ import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/acces import {RegistryHelper} from "./RegistryHelper.sol"; import {LazyWalletRegistry} from "./LazyWalletRegistry.sol"; import {DeviceWalletFactory} from "./device-wallet/DeviceWalletFactory.sol"; -import {DeviceWallet} from "./device-wallet/DeviceWallet.sol"; import {ESIMWalletFactory} from "./esim-wallet/ESIMWalletFactory.sol"; import {ESIMWallet} from "./esim-wallet/ESIMWallet.sol"; import {PaymentAdapter, Asset} from "./payments/PaymentAdapter.sol"; diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index 2b41d4af..4a76e350 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -145,7 +145,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @param _symbol Currency the price is being expressed in /// @param _priceUSDCents Price in USD cents /// @return amountIn Amount in that currency's own smallest unit - function quote(bytes32 _symbol, uint64 _priceUSDCents) public view returns (uint256 amountIn) { + function quote(bytes32 _symbol, uint64 _priceUSDCents) external view returns (uint256 amountIn) { Asset memory asset = assets[_symbol]; if(!asset.allowed) revert Errors.AssetNotAllowed(_symbol); From cf690732635cb5a411530cdcbec6792e0f7c9ec1 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:14:19 +0530 Subject: [PATCH 22/75] Let an eSIM wallet pull tokens from its device wallet 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. --- certora/specs/DeviceWallet.spec | 28 +++--- contracts/Errors.sol | 12 ++- contracts/RegistryHelper.sol | 2 +- contracts/device-wallet/DeviceWallet.sol | 98 +++++++++++++------ .../device-wallet/DeviceWalletFactory.sol | 3 +- snapshots/DeviceWallet.Operations.json | 14 +-- snapshots/DeviceWalletFactory.Operations.json | 10 +- snapshots/ESIMWallet.Operations.json | 18 ++-- snapshots/LazyWalletRegistry.Operations.json | 20 ++-- snapshots/PaymentAdapter.Operations.json | 10 +- snapshots/Registry.Operations.json | 10 +- snapshots/Signature.Operations.json | 2 +- test/foundry/fuzz-testing/ETHAmounts.t.sol | 2 +- test/foundry/fuzz-testing/base/FuzzBase.sol | 2 +- .../gas/DeviceWallet.Operations.gas.t.sol | 14 +-- .../gas/ESIMWallet.Operations.gas.t.sol | 2 +- .../invariant-testing/ETHInvariants.t.sol | 2 +- .../HandlerDistribution.t.sol | 4 +- .../invariant-testing/WalletInvariants.t.sol | 2 +- .../handler/WalletHandler.sol | 16 +-- .../DeviceWalletESIMWallets.t.sol | 10 +- .../device-wallet/DeviceWalletETH.t.sol | 72 +++++++------- .../device-wallet/DeviceWalletGuards.t.sol | 8 +- .../base/DeviceWalletFixture.sol | 18 ++-- .../DeviceWalletFactoryBatchDeploy.t.sol | 2 +- .../factory/DeviceWalletFactoryConfig.t.sol | 4 +- .../unit-testing/esim-wallet/ESIMWallet.t.sol | 12 +-- .../esim-wallet/ESIMWalletGuards.t.sol | 2 +- .../registry/LazyWalletRegistry.t.sol | 4 +- .../registry/SettledPurchase.t.sol | 2 +- .../upgrade/PriceCapSlotMigration.t.sol | 2 +- .../unit-testing/upgrade/StorageLayout.t.sol | 4 +- test/utils/mocks/ReentrantESIMWallet.sol | 2 +- 33 files changed, 227 insertions(+), 186 deletions(-) diff --git a/certora/specs/DeviceWallet.spec b/certora/specs/DeviceWallet.spec index a81bff53..2f95bb95 100644 --- a/certora/specs/DeviceWallet.spec +++ b/certora/specs/DeviceWallet.spec @@ -1,8 +1,8 @@ /// DeviceWallet: the rights it hands to eSIM wallets, and the key that owns it. /// /// A device wallet holds user ETH and decides which eSIM wallets may reach it. Two mappings carry -/// that decision, `isValidESIMWallet` for membership and `canPullETH` for the spending right, and -/// the second is meaningless without the first: `pullETH` checks `canPullETH` on its own, so a +/// that decision, `isValidESIMWallet` for membership and `canPullFunds` for the spending right, and +/// the second is meaningless without the first: `pullETH` checks `canPullFunds` on its own, so a /// wallet that kept the right after being let go would still be able to spend. The rules below fix /// the relation between the two, fix the one ETH path against the balance, and fix the P256 key /// that authorises everything this wallet does. @@ -22,7 +22,7 @@ /// no rule here states who may call what. The one guard that survives untouched is `onlySelf`, which /// compares against `address(this)` and reads no storage. /// -/// `transferOwnership` and `toggleAccessToETH` are both `onlySelf`, so on chain they are reachable +/// `transferOwnership` and `toggleAccessToFunds` are both `onlySelf`, so on chain they are reachable /// only through `execute` with this wallet as the target, which needs a signature from the current /// owner key. The rules drive them directly instead. That is the stronger statement about which /// storage they write, and it says nothing about who could get there, which the paragraph above @@ -53,7 +53,7 @@ methods { function isValidESIMWallet(address) external returns (bool) envfree; - function canPullETH(address) external returns (bool) envfree; + function canPullFunds(address) external returns (bool) envfree; function registry() external returns (address) envfree; function eSIMWalletFactory() external returns (address) envfree; @@ -70,7 +70,7 @@ methods { /// R-13. A wallet that may pull ETH is a wallet this device wallet still recognises. /// -/// `canPullETH` is checked on its own in both spending paths, without a membership check beside it, +/// `canPullFunds` is checked on its own in both spending paths, without a membership check beside it, /// so this relation is the whole of what keeps a released wallet away from the balance. /// `removeESIMWallet` clears both, and this says nothing anywhere leaves them apart. /// @@ -80,13 +80,13 @@ methods { rule theRightToPullETHNeverOutlivesMembership(method f, address eSIMWallet) filtered { f -> f.selector != sig:init(address, bytes32[2], string, address).selector } { - require canPullETH(eSIMWallet) => isValidESIMWallet(eSIMWallet); + require canPullFunds(eSIMWallet) => isValidESIMWallet(eSIMWallet); env callEnv; calldataarg args; f(callEnv, args); - assert canPullETH(eSIMWallet) => isValidESIMWallet(eSIMWallet), + assert canPullFunds(eSIMWallet) => isValidESIMWallet(eSIMWallet), "an eSIM wallet kept the right to pull ETH without being recognised"; } @@ -133,7 +133,7 @@ rule removalWithdrawsMembershipAndTheRightToPullTogether(address eSIMWallet, boo removeESIMWallet(callEnv, eSIMWallet, callBackETH); assert !isValidESIMWallet(eSIMWallet), "a removed eSIM wallet was still recognised"; - assert !canPullETH(eSIMWallet), "a removed eSIM wallet kept the right to pull ETH"; + assert !canPullFunds(eSIMWallet), "a removed eSIM wallet kept the right to pull ETH"; } /// R-17. The owner key moves only through `transferOwnership`. @@ -174,7 +174,7 @@ rule theOwnerKeyMovesOnlyThroughItsOwnEntryPoint(method f) filtered { /// Membership is never granted twice over. /// /// The add path refuses a wallet it already holds. That refusal is what stops a second grant quietly -/// resetting `canPullETH` on a wallet whose access the owner had already revoked, which would be a +/// resetting `canPullFunds` on a wallet whose access the owner had already revoked, which would be a /// revocation undone by a call that looks like it is only adding. rule addingAWalletTwiceAlwaysReverts(address eSIMWallet, bool hasAccessToETH) { require isValidESIMWallet(eSIMWallet); @@ -194,26 +194,26 @@ rule togglingETHAccessOnAnUnknownWalletAlwaysReverts(address eSIMWallet, bool ha require !isValidESIMWallet(eSIMWallet); env callEnv; - toggleAccessToETH@withrevert(callEnv, eSIMWallet, hasAccessToETH); + toggleAccessToFunds@withrevert(callEnv, eSIMWallet, hasAccessToETH); assert lastReverted, "ETH access was toggled on a wallet this device wallet does not recognise"; } /// The right to pull ETH is only ever granted by the owner. /// -/// `toggleAccessToETH` is `onlySelf` and every bind path refuses the flag, so a revocation stands. +/// `toggleAccessToFunds` is `onlySelf` and every bind path refuses the flag, so a revocation stands. /// The admin used to undo one by deploying a second eSIM wallet with the flag set. /// /// Stated over the whole method set, so a later function that writes the flag has to answer it too. rule onlyToggleAccessToETHGrantsETHAccess(method f, address eSIMWallet) filtered { f -> f.selector != sig:init(address, bytes32[2], string, address).selector } { - require !canPullETH(eSIMWallet); + require !canPullFunds(eSIMWallet); env callEnv; calldataarg args; f(callEnv, args); - assert canPullETH(eSIMWallet) => f.selector == sig:toggleAccessToETH(address, bool).selector, - "the right to pull ETH was granted by something other than toggleAccessToETH"; + assert canPullFunds(eSIMWallet) => f.selector == sig:toggleAccessToFunds(address, bool).selector, + "the right to pull ETH was granted by something other than toggleAccessToFunds"; } diff --git a/contracts/Errors.sol b/contracts/Errors.sol index 663c2509..880a43d2 100644 --- a/contracts/Errors.sol +++ b/contracts/Errors.sol @@ -90,6 +90,9 @@ interface Errors { // ESIMWallet and DeviceWallet error FailedToTransfer(); error InsufficientBalance(uint256 balance, uint256 amount); + error ZeroAmount(); + // A currency with no token address, so nothing can be transferred in it + error AssetNotTransferable(bytes32 asset); // ESIMWallet error OnlyRegistry(); @@ -105,9 +108,8 @@ interface Errors { // DeviceWallet error UnknownESIMWallet(address eSIMWallet); - error ZeroAmount(); - error ETHAccessRevoked(address eSIMWallet); - error ETHAccessNotGrantableAtBind(address eSIMWallet); + error FundsAccessRevoked(address eSIMWallet); + error FundsAccessNotGrantableAtBind(address eSIMWallet); error ESIMWalletAlreadyAdded(address eSIMWallet); error ESIMWalletNotOwnedByThisDeviceWallet(address eSIMWallet, address eSIMWalletOwner); error OnlyRegistryOrDeviceWalletFactoryOrOwner(); @@ -137,4 +139,8 @@ interface Errors { // No price feeds, so only a currency already in dollars can be converted from cents error AssetNeedsSwap(bytes32 asset); error PaymentReferenceAlreadyUsed(bytes32 paymentReference); + // The price costs more of the currency than the buyer was willing to spend + error SettlementAboveMax(uint256 required, uint256 maxAmountIn); + // The caller has to send the tokens before calling settle, and sent less than it declared + error SettlementNotFunded(uint256 amountIn, uint256 balance); } diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index 3cd9be5a..affffdac 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -354,7 +354,7 @@ contract RegistryHelper { address eSIMWallet = eSIMWalletFactory.deployESIMWallet(_deviceWallet, salt); // Updates the Device wallet storage variables as well as for the registry. No ETH access: - // only the owner grants that, with a signed `toggleAccessToETH`. + // only the owner grants that, with a signed `toggleAccessToFunds`. DeviceWallet(payable(_deviceWallet)).addESIMWallet(eSIMWallet, false); _assignESIMIdentifier(eSIMWallet, _eSIMUniqueIdentifier); diff --git a/contracts/device-wallet/DeviceWallet.sol b/contracts/device-wallet/DeviceWallet.sol index 5b221021..a0c5226c 100644 --- a/contracts/device-wallet/DeviceWallet.sol +++ b/contracts/device-wallet/DeviceWallet.sol @@ -4,9 +4,11 @@ pragma solidity 0.8.36; // Libraries import {FCL_Elliptic_ZZ} from "FreshCryptoLib/FCL_elliptic.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Errors} from "../Errors.sol"; // Interfaces +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; // Contracts @@ -20,11 +22,12 @@ import {P256Verifier} from "../P256Verifier.sol"; /// @notice A user's device: an ERC-4337 account that owns the eSIM wallets bought for that device /// @dev A beacon proxy deployed by `DeviceWalletFactory`, owned by a P256 key the user holds. It -/// funds its eSIM wallets, decides which of them may pull ETH, and is the only party that can +/// funds its eSIM wallets, decides which of them may spend its money, and is the only party that can /// move one to another device. Its own owner key rotates through `transferOwnership`, which /// also tells the registry so the two records cannot drift apart. contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 { using Address for address; + using SafeERC20 for IERC20; /// @notice Registry contract instance Registry public registry; @@ -38,18 +41,24 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// @notice Set to true if the eSIM wallet belongs to this device wallet mapping(address eSIMWalletAddress => bool isValid) public isValidESIMWallet; - /// @notice Tracks if an associated eSIM wallet can pull ETH or not - mapping(address eSIMWalletAddress => bool isAllowedToPullETH) public canPullETH; + /// @notice Tracks if an associated eSIM wallet may pull this wallet's ETH and tokens + /// @dev One flag for both. A wallet trusted with the ETH can already drain the owner, so a + /// second flag per asset would cost another signature and limit nothing. + mapping(address eSIMWalletAddress => bool isAllowedToPullFunds) public canPullFunds; - /// @notice Emitted when owner updates ETH access to a particular eSIM wallet - event ETHAccessUpdated(address indexed _eSIMWalletAddress, bool _hasAccessToETH); + /// @notice Emitted when owner updates an eSIM wallet's access to this wallet's money + event FundsAccessUpdated(address indexed _eSIMWalletAddress, bool _hasAccessToFunds); /// @notice Emitted when ETH is sent out from the contract /// @dev mostly when an eSIM wallet pulls ETH from this contract event ETHSent(address indexed _eSIMWalletAddress, uint256 _amount); + /// @notice Emitted when an ERC-20 leaves this contract + /// @dev mostly when an eSIM wallet pulls tokens to pay for a data bundle + event TokenSent(address indexed _token, address indexed _eSIMWalletAddress, uint256 _amount); + /// @notice Emitted when eSIM wallet is added to this Device Wallet - event ESIMWalletAdded(address indexed _eSIMWalletAddress, bool _hasAccessToETH, address indexed _caller); + event ESIMWalletAdded(address indexed _eSIMWalletAddress, bool _hasAccessToFunds, address indexed _caller); /// @notice Emitted when the eSIM wallet is removed from this Device Wallet event ESIMWalletRemoved(address indexed _eSIMWalletAddress, address indexed _deviceWalletAddress, address indexed _caller); @@ -90,7 +99,7 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// @notice Reverts unless the caller is this wallet itself or the eSIM wallet being removed /// @dev An eSIM wallet may only name itself. Accepting any associated wallet let one of them - /// unbind a sibling, strip its ETH access, put it on standby and force its balance back to + /// unbind a sibling, strip its access, put it on standby and force its balance back to /// the device wallet. Association of the named wallet is still established by the caller, /// which requires isValidESIMWallet before doing anything. function _onlySelfOrESIMWalletBeingRemoved(address _eSIMWalletAddress) private view { @@ -174,40 +183,65 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// @dev The new wallet has no eSIM identifier yet. That arrives through /// `setESIMUniqueIdentifierForAnESIMWallet` once the eSIM itself has been created. /// - /// ETH access is granted only afterwards, by the owner, with `toggleAccessToETH`. - /// @param _hasAccessToETH Must be false + /// Access to this wallet's money is granted only afterwards, by the owner, with + /// `toggleAccessToFunds`. + /// @param _hasAccessToFunds Must be false /// @param _salt CREATE2 salt for the new eSIM wallet /// @return eSIM wallet address function deployESIMWallet( - bool _hasAccessToETH, + bool _hasAccessToFunds, uint256 _salt ) external onlyESIMWalletAdmin returns (address) { address eSIMWalletAddress = eSIMWalletFactory.deployESIMWallet(address(this), _salt); - _addESIMWallet(eSIMWalletAddress, _hasAccessToETH); + _addESIMWallet(eSIMWalletAddress, _hasAccessToFunds); return eSIMWalletAddress; } // --------------------------------------------------------------------------------------------- - // ETH movement + // Money movement // --------------------------------------------------------------------------------------------- /// @notice Allow the eSIM wallets associated with this device wallet to pull ETH (for data bundles) - /// @dev Refused while the protocol is paused, and refused for a wallet whose ETH access the - /// owner has revoked. + /// @dev Refused while the protocol is paused, and refused for a wallet whose access the owner + /// has revoked. /// @param _amount Amount of ETH to pull /// @return The amount pulled function pullETH(uint256 _amount) external onlyAssociatedESIMWallets nonReentrant returns (uint256) { registry.requireNotPaused(); if(_amount == 0) revert Errors.ZeroAmount(); - if(!canPullETH[msg.sender]) revert Errors.ETHAccessRevoked(msg.sender); + if(!canPullFunds[msg.sender]) revert Errors.FundsAccessRevoked(msg.sender); _transferETH(msg.sender, _amount); return _amount; } + /// @notice Allow an associated eSIM wallet to pull an ERC-20 (for data bundles) + /// @dev Same gate and pause check as `pullETH`. It exists so the admin can charge this wallet + /// without an owner signature in that transaction; an owner buying for themselves can + /// batch the transfer and the purchase through `executeBatch` instead. + /// @param _token ERC-20 being pulled + /// @param _amount Amount in that token's smallest unit + /// @return The amount pulled + function pullToken(address _token, uint256 _amount) + external + onlyAssociatedESIMWallets + nonReentrant + returns (uint256) + { + registry.requireNotPaused(); + if(_token == address(0)) revert Errors.ZeroAddress("_token"); + if(_amount == 0) revert Errors.ZeroAmount(); + if(!canPullFunds[msg.sender]) revert Errors.FundsAccessRevoked(msg.sender); + + IERC20(_token).safeTransfer(msg.sender, _amount); + emit TokenSent(_token, msg.sender, _amount); + + return _amount; + } + // --------------------------------------------------------------------------------------------- // Owner key rotation // --------------------------------------------------------------------------------------------- @@ -238,27 +272,27 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 // eSIM wallet management // --------------------------------------------------------------------------------------------- - /// @notice Allow owner to revoke or give access to any associated eSIM wallet for pulling ETH - /// @dev The only way ETH access is ever granted. Binding a wallet never carries it, so a + /// @notice Allow owner to revoke or give an associated eSIM wallet access to this wallet's money + /// @dev The only way that access is ever granted. Binding a wallet never carries it, so a /// revocation stands until the owner signs a grant. - /// @param _eSIMWalletAddress Address of the eSIM wallet to toggle ETH access for - /// @param _hasAccessToETH Set to true to give access, false to revoke access - function toggleAccessToETH(address _eSIMWalletAddress, bool _hasAccessToETH) public onlySelf { + /// @param _eSIMWalletAddress Address of the eSIM wallet to toggle access for + /// @param _hasAccessToFunds Set to true to give access, false to revoke access + function toggleAccessToFunds(address _eSIMWalletAddress, bool _hasAccessToFunds) public onlySelf { if(!isValidESIMWallet[_eSIMWalletAddress]) revert Errors.UnknownESIMWallet(_eSIMWalletAddress); - canPullETH[_eSIMWalletAddress] = _hasAccessToETH; + canPullFunds[_eSIMWalletAddress] = _hasAccessToFunds; - emit ETHAccessUpdated(_eSIMWalletAddress, _hasAccessToETH); + emit FundsAccessUpdated(_eSIMWalletAddress, _hasAccessToFunds); } /// @notice Allow the device wallet factory or the wallet owner to add new eSIM wallet to this device wallet /// @param _eSIMWalletAddress Address of the eSIM wallet to be added - /// @param _hasAccessToETH Must be false. ETH access is granted only through `toggleAccessToETH` + /// @param _hasAccessToFunds Must be false. Access is granted only through `toggleAccessToFunds` function addESIMWallet( address _eSIMWalletAddress, - bool _hasAccessToETH + bool _hasAccessToFunds ) public onlyRegistryOrDeviceWalletFactoryOrOwner(_eSIMWalletAddress) { - _addESIMWallet(_eSIMWalletAddress, _hasAccessToETH); + _addESIMWallet(_eSIMWalletAddress, _hasAccessToFunds); } /// @notice Allow the device wallet owner or the eSIM wallet to remove any eSIM wallet bound with this device wallet @@ -271,7 +305,7 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 if(!isValidESIMWallet[_eSIMWalletAddress]) revert Errors.UnknownESIMWallet(_eSIMWalletAddress); isValidESIMWallet[_eSIMWalletAddress] = false; - canPullETH[_eSIMWalletAddress] = false; + canPullFunds[_eSIMWalletAddress] = false; // Inform the registry that this eSIM wallet has been let go. Only the flag moves: the // registry keeps naming this device wallet as the last one to hold it, which is what tells @@ -284,7 +318,7 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 // The callback runs last. All eSIM wallets share one upgradeable beacon, so the logic // reached here is not fixed for the life of the protocol. By this point the wallet has - // already lost canPullETH and its registry association, so a handler that re-enters + // already lost canPullFunds and its registry association, so a handler that re-enters // cannot use the rights it is in the middle of losing. if(_callBackETH) { try ESIMWallet(payable(_eSIMWalletAddress)).sendETHToDeviceWallet(_eSIMWalletAddress.balance) returns (uint256 _amount) { @@ -300,16 +334,16 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// @dev Refuses a wallet this device wallet does not already own, so binding cannot run ahead /// of the ownership handover. /// - /// A bind never carries ETH access. `toggleAccessToETH` is `onlySelf` and the only writer + /// A bind never carries access to this wallet's money. `toggleAccessToFunds` is `onlySelf` and the only writer /// of a `true`, so no bind can undo the owner's revocation. Asking for access here reverts /// rather than being downgraded in silence. /// @param _eSIMWalletAddress Address of the eSIM wallet to bind - /// @param _hasAccessToETH Must be false + /// @param _hasAccessToFunds Must be false function _addESIMWallet( address _eSIMWalletAddress, - bool _hasAccessToETH + bool _hasAccessToFunds ) internal { - if(_hasAccessToETH) revert Errors.ETHAccessNotGrantableAtBind(_eSIMWalletAddress); + if(_hasAccessToFunds) revert Errors.FundsAccessNotGrantableAtBind(_eSIMWalletAddress); if(isValidESIMWallet[_eSIMWalletAddress]) revert Errors.ESIMWalletAlreadyAdded(_eSIMWalletAddress); // If the eSIM wallet is a newly deployed one, then the owner will definitely be set // during initialisation. This device wallet will be the owner. @@ -324,7 +358,7 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 isValidESIMWallet[_eSIMWalletAddress] = true; // Already false on arrival, since `removeESIMWallet` zeroes it. Written anyway so the // property is readable here. - canPullETH[_eSIMWalletAddress] = false; + canPullFunds[_eSIMWalletAddress] = false; // Inform the registry that this device wallet now holds the eSIM wallet. The call writes the // association and, if a release was outstanding, lowers the transit marker. The two records diff --git a/contracts/device-wallet/DeviceWalletFactory.sol b/contracts/device-wallet/DeviceWalletFactory.sol index b67dc00b..8ac5bd04 100644 --- a/contracts/device-wallet/DeviceWalletFactory.sol +++ b/contracts/device-wallet/DeviceWalletFactory.sol @@ -401,7 +401,8 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr address deviceWalletAddress = address(deviceWallet); address eSIMWalletAddress = eSIMWalletFactory.deployESIMWallet(deviceWalletAddress, _salt); - // No ETH access: only the owner grants that, with a signed `toggleAccessToETH`. + // No access to the device wallet's money: only the owner grants that, with a signed + // `toggleAccessToFunds`. DeviceWallet(payable(deviceWalletAddress)).addESIMWallet( eSIMWalletAddress, false diff --git a/snapshots/DeviceWallet.Operations.json b/snapshots/DeviceWallet.Operations.json index 57bce8fa..5fb2890a 100644 --- a/snapshots/DeviceWallet.Operations.json +++ b/snapshots/DeviceWallet.Operations.json @@ -1,9 +1,9 @@ { - "addDeposit: 1 ether": "35038", - "deployESIMWallet: second wallet on the device": "358239", - "execute: ETH transfer to an EOA": "36295", - "executeBatch: 3 ETH transfers": "79971", - "pullETH: 1 ether to the eSIM wallet": "13372", - "toggleAccessToETH: grant": "23441", - "toggleAccessToETH: revoke": "3541" + "addDeposit: 1 ether": "35060", + "deployESIMWallet: second wallet on the device": "358366", + "execute: ETH transfer to an EOA": "36317", + "executeBatch: 3 ETH transfers": "79993", + "pullETH: 1 ether to the eSIM wallet": "13418", + "toggleAccessToFunds: grant": "23507", + "toggleAccessToFunds: revoke": "3607" } \ No newline at end of file diff --git a/snapshots/DeviceWalletFactory.Operations.json b/snapshots/DeviceWalletFactory.Operations.json index 7b83cb83..d7ea6c3f 100644 --- a/snapshots/DeviceWalletFactory.Operations.json +++ b/snapshots/DeviceWalletFactory.Operations.json @@ -1,8 +1,8 @@ { - "createAccount: first wallet, cold factory storage": "326317", - "createAccount: second wallet, warm factory storage": "308817", - "deployDeviceWalletForUsers: batch of 1": "881806", - "deployDeviceWalletForUsers: batch of 1, funded": "889623", - "deployDeviceWalletForUsers: batch of 5": "4147402", + "createAccount: first wallet, cold factory storage": "326339", + "createAccount: second wallet, warm factory storage": "308839", + "deployDeviceWalletForUsers: batch of 1": "881946", + "deployDeviceWalletForUsers: batch of 1, funded": "889763", + "deployDeviceWalletForUsers: batch of 5": "4148102", "postCreateAccount: registering a wallet": "173975" } \ No newline at end of file diff --git a/snapshots/ESIMWallet.Operations.json b/snapshots/ESIMWallet.Operations.json index 350b63cc..efa14e9a 100644 --- a/snapshots/ESIMWallet.Operations.json +++ b/snapshots/ESIMWallet.Operations.json @@ -1,11 +1,11 @@ { - "acceptOwnershipTransfer": "3529", - "buyDataBundle: pulls from the device wallet": "107892", - "buyDataBundle: wallet already holds the price": "156419", - "deployESIMWallet: through the factory": "289517", - "populateHistory: 10 entries": "489289", - "requestTransferOwnership": "64251", - "setESIMUniqueIdentifier: first time": "27227", - "setPriceCapUSDCents: back to the registry ceiling": "2543", - "setPriceCapUSDCents: set": "22443" + "acceptOwnershipTransfer": "3551", + "buyDataBundle: pulls from the device wallet": "108255", + "buyDataBundle: wallet already holds the price": "156649", + "deployESIMWallet: through the factory": "289547", + "populateHistory: 10 entries": "489385", + "requestTransferOwnership": "64378", + "setESIMUniqueIdentifier: first time": "27249", + "setPriceCapUSDCents: back to the registry ceiling": "2565", + "setPriceCapUSDCents: set": "22465" } \ No newline at end of file diff --git a/snapshots/LazyWalletRegistry.Operations.json b/snapshots/LazyWalletRegistry.Operations.json index ad01c779..2063f540 100644 --- a/snapshots/LazyWalletRegistry.Operations.json +++ b/snapshots/LazyWalletRegistry.Operations.json @@ -1,14 +1,14 @@ { "batchPopulateHistory: 1 device, 5 eSIMs": "668758", "batchPopulateHistory: 5 devices, 22 eSIMs": "2596984", - "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1013162", - "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5002891", - "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9487261", - "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2762791", - "deployMoreESIMWalletsForLazyDevice: batch of 1": "462723", - "deployMoreESIMWalletsForLazyDevice: batch of 18": "8062999", - "deployMoreESIMWalletsForLazyDevice: batch of 5": "2249424", - "setHistoryForLazyWallet: 1 entry": "107184", - "setHistoryForLazyWallet: 10 entries": "493931", - "setHistoryForLazyWallet: 39 entries": "1881545" + "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1013412", + "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5005193", + "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9491843", + "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2763953", + "deployMoreESIMWalletsForLazyDevice: batch of 1": "462951", + "deployMoreESIMWalletsForLazyDevice: batch of 18": "8067103", + "deployMoreESIMWalletsForLazyDevice: batch of 5": "2250564", + "setHistoryForLazyWallet: 1 entry": "107253", + "setHistoryForLazyWallet: 10 entries": "494027", + "setHistoryForLazyWallet: 39 entries": "1881728" } \ No newline at end of file diff --git a/snapshots/PaymentAdapter.Operations.json b/snapshots/PaymentAdapter.Operations.json index cf6bdc40..f6ac12d8 100644 --- a/snapshots/PaymentAdapter.Operations.json +++ b/snapshots/PaymentAdapter.Operations.json @@ -1,8 +1,8 @@ { - "consumePaymentReference: a reference not yet spent": "30821", - "quote: 2 decimals": "3242", - "quote: 6 decimals": "7742", + "consumePaymentReference: a reference not yet spent": "30843", + "quote: 2 decimals": "3342", + "quote: 6 decimals": "7842", "registerAsset: a currency not in the table": "32911", - "resolveAsset": "7969", - "updateAsset: withdrawing a currency": "16122" + "resolveAsset": "7991", + "updateAsset: withdrawing a currency": "16144" } \ No newline at end of file diff --git a/snapshots/Registry.Operations.json b/snapshots/Registry.Operations.json index 08c85f77..4b682019 100644 --- a/snapshots/Registry.Operations.json +++ b/snapshots/Registry.Operations.json @@ -1,13 +1,13 @@ { "acceptAdminUpdate": "4964", - "assignESIMIdentifier: first time": "64764", + "assignESIMIdentifier: first time": "64852", "pause": "13963", - "recordSettledPurchase: first for the wallet": "124759", - "recordSettledPurchase: second for the same wallet": "87859", + "recordSettledPurchase: first for the wallet": "124847", + "recordSettledPurchase: second for the same wallet": "87947", "requestAdminUpdate": "10494", "setDefaultPriceCapUSDCents": "13346", - "toggleESIMWalletStandbyStatus: off standby": "5365", - "toggleESIMWalletStandbyStatus: onto standby": "25265", + "toggleESIMWalletStandbyStatus: off standby": "5387", + "toggleESIMWalletStandbyStatus: onto standby": "25287", "unpause": "4102", "updateDeviceWalletInfo: first device, cold storage": "122096", "updateDeviceWalletInfo: second device, warm storage": "115596", diff --git a/snapshots/Signature.Operations.json b/snapshots/Signature.Operations.json index e0bb31f5..46c60a24 100644 --- a/snapshots/Signature.Operations.json +++ b/snapshots/Signature.Operations.json @@ -1,7 +1,7 @@ { "p256 verify: FreshCryptoLib alone": "227320", "p256 verify: RIP-7212 precompile alone": "6900", - "validateUserOp: valid assertion": "30807", + "validateUserOp: valid assertion": "30829", "verifySignature: falls through to FreshCryptoLib": "250760", "verifySignature: real assertion, precompile answers": "22493" } \ No newline at end of file diff --git a/test/foundry/fuzz-testing/ETHAmounts.t.sol b/test/foundry/fuzz-testing/ETHAmounts.t.sol index 0f1a239d..bcb4da03 100644 --- a/test/foundry/fuzz-testing/ETHAmounts.t.sol +++ b/test/foundry/fuzz-testing/ETHAmounts.t.sol @@ -240,7 +240,7 @@ contract ETHAmountsTest is FuzzBase { } catch {} assertFalse( - fuzzDeviceWallet.canPullETH(wallet), + fuzzDeviceWallet.canPullFunds(wallet), "A refused bind must leave the wallet without the right to pull ETH" ); } diff --git a/test/foundry/fuzz-testing/base/FuzzBase.sol b/test/foundry/fuzz-testing/base/FuzzBase.sol index eaf1f140..e7e2d54b 100644 --- a/test/foundry/fuzz-testing/base/FuzzBase.sol +++ b/test/foundry/fuzz-testing/base/FuzzBase.sol @@ -59,7 +59,7 @@ abstract contract FuzzBase is DeployerBase { // A bind never carries ETH access, and the pull path these suites measure needs it vm.prank(address(fuzzDeviceWallet)); - fuzzDeviceWallet.toggleAccessToETH(address(fuzzESIMWallet), true); + fuzzDeviceWallet.toggleAccessToFunds(address(fuzzESIMWallet), true); } /// @notice Builds a string of the requested byte length out of a repeating filler diff --git a/test/foundry/gas/DeviceWallet.Operations.gas.t.sol b/test/foundry/gas/DeviceWallet.Operations.gas.t.sol index f892a680..0070237a 100644 --- a/test/foundry/gas/DeviceWallet.Operations.gas.t.sol +++ b/test/foundry/gas/DeviceWallet.Operations.gas.t.sol @@ -26,7 +26,7 @@ contract DeviceWalletOperationsGasTest is GasBase { /// @notice Adding another eSIM wallet to a device that already has one /// @dev The first eSIM wallet comes with the device deployment, so this is the marginal cost of - /// the second and every one after it. The grant is measured in `test_toggleAccessToETH`. + /// the second and every one after it. The grant is measured in `test_toggleAccessToFunds`. function test_deployESIMWallet() public { _deploy(); @@ -59,16 +59,16 @@ contract DeviceWalletOperationsGasTest is GasBase { /// @notice Granting and revoking an eSIM wallet's access to the device's ETH /// @dev Gated on the wallet calling itself, which in production means it arrives as the target /// of an `execute` rather than as a transaction of its own. - function test_toggleAccessToETH() public { + function test_toggleAccessToFunds() public { _deploy(); vm.prank(address(wallet)); - wallet.toggleAccessToETH(address(firstESIMWallet), true); - vm.snapshotGasLastCall(NAMESPACE, "toggleAccessToETH: grant"); + wallet.toggleAccessToFunds(address(firstESIMWallet), true); + vm.snapshotGasLastCall(NAMESPACE, "toggleAccessToFunds: grant"); vm.prank(address(wallet)); - wallet.toggleAccessToETH(address(firstESIMWallet), false); - vm.snapshotGasLastCall(NAMESPACE, "toggleAccessToETH: revoke"); + wallet.toggleAccessToFunds(address(firstESIMWallet), false); + vm.snapshotGasLastCall(NAMESPACE, "toggleAccessToFunds: revoke"); } /// @notice An eSIM wallet pulling ETH from the device wallet that owns it @@ -77,7 +77,7 @@ contract DeviceWalletOperationsGasTest is GasBase { vm.deal(address(wallet), 10 ether); vm.prank(address(wallet)); - wallet.toggleAccessToETH(address(firstESIMWallet), true); + wallet.toggleAccessToFunds(address(firstESIMWallet), true); vm.prank(address(firstESIMWallet)); wallet.pullETH(1 ether); diff --git a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol index 055fc503..5bdfbeaf 100644 --- a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol +++ b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol @@ -26,7 +26,7 @@ contract ESIMWalletOperationsGasTest is GasBase { vm.deal(address(wallet), 100 ether); vm.prank(address(wallet)); - wallet.toggleAccessToETH(address(eSIMWallet), true); + wallet.toggleAccessToFunds(address(eSIMWallet), true); } /// @notice Deploying an eSIM wallet through the factory diff --git a/test/foundry/invariant-testing/ETHInvariants.t.sol b/test/foundry/invariant-testing/ETHInvariants.t.sol index 961998b0..7517a10d 100644 --- a/test/foundry/invariant-testing/ETHInvariants.t.sol +++ b/test/foundry/invariant-testing/ETHInvariants.t.sol @@ -54,7 +54,7 @@ contract ETHInvariantsTest is CampaignBase { /// @notice Fails if a pair may pull ETH without the owner having granted it function _assertGranted(address device, address wallet) private view { if (device == address(0)) return; - if (!MockDeviceWallet(payable(device)).canPullETH(wallet)) return; + if (!MockDeviceWallet(payable(device)).canPullFunds(wallet)) return; assertTrue( state.ghost_ethAccessGranted(device, wallet), diff --git a/test/foundry/invariant-testing/HandlerDistribution.t.sol b/test/foundry/invariant-testing/HandlerDistribution.t.sol index 81dbb49a..a5d35fb8 100644 --- a/test/foundry/invariant-testing/HandlerDistribution.t.sol +++ b/test/foundry/invariant-testing/HandlerDistribution.t.sol @@ -74,7 +74,7 @@ contract HandlerDistributionTest is CampaignBase { adminHandler.deployESIMWalletForDevice(round, seed + 2000); // Follows the deploy so there is always a wallet still waiting for an identifier adminHandler.setESIMIdentifier(round, seed + 3000, false); - walletHandler.toggleAccessToETH(round, true); + walletHandler.toggleAccessToFunds(round, true); walletHandler.setESIMWalletPriceCap(round, round); adminHandler.buyDataBundle(round, 100, 1 gwei); // Each payment path is given its own block of reference seeds. Two calls presenting @@ -143,7 +143,7 @@ contract HandlerDistributionTest is CampaignBase { _assertExercised("donateToSingleton"); _assertExercised("deployESIMWalletForDevice"); _assertExercised("setESIMIdentifier"); - _assertExercised("toggleAccessToETH"); + _assertExercised("toggleAccessToFunds"); _assertExercised("buyDataBundle"); _assertExercised("recordSettledPurchase"); _assertExercised("quote"); diff --git a/test/foundry/invariant-testing/WalletInvariants.t.sol b/test/foundry/invariant-testing/WalletInvariants.t.sol index 90503098..3771d9c6 100644 --- a/test/foundry/invariant-testing/WalletInvariants.t.sol +++ b/test/foundry/invariant-testing/WalletInvariants.t.sol @@ -82,7 +82,7 @@ contract WalletInvariantsTest is CampaignBase { address device = state.ghost_lastDevice(wallet); if (device == address(0)) continue; - if (MockDeviceWallet(payable(device)).canPullETH(wallet)) { + if (MockDeviceWallet(payable(device)).canPullFunds(wallet)) { assertTrue( MockDeviceWallet(payable(device)).isValidESIMWallet(wallet), "An eSIM wallet may pull ETH from a device wallet that does not hold it" diff --git a/test/foundry/invariant-testing/handler/WalletHandler.sol b/test/foundry/invariant-testing/handler/WalletHandler.sol index 7f4f88a9..6a8df4e3 100644 --- a/test/foundry/invariant-testing/handler/WalletHandler.sol +++ b/test/foundry/invariant-testing/handler/WalletHandler.sol @@ -76,7 +76,7 @@ contract WalletHandler is HandlerBase { "A removed eSIM wallet is still claimed by its device wallet" ); assertFalse( - DeviceWallet(payable(device)).canPullETH(wallet), + DeviceWallet(payable(device)).canPullFunds(wallet), "A removed eSIM wallet kept its right to pull ETH" ); assertEq( @@ -179,7 +179,7 @@ contract WalletHandler is HandlerBase { "An added eSIM wallet is not claimed by the device wallet that added it" ); assertFalse( - DeviceWallet(payable(device)).canPullETH(wallet), + DeviceWallet(payable(device)).canPullFunds(wallet), "An added eSIM wallet arrived with the right to pull ETH" ); assertEq( @@ -199,20 +199,20 @@ contract WalletHandler is HandlerBase { /// @notice The wallet owner revokes or restores an eSIM wallet's right to pull ETH /// @param eSIMIndex Which eSIM wallet to toggle /// @param hasAccessToETH The access it should end up with - function toggleAccessToETH(uint256 eSIMIndex, bool hasAccessToETH) external counted { + function toggleAccessToFunds(uint256 eSIMIndex, bool hasAccessToETH) external counted { address wallet = _pickESIMWallet(eSIMIndex); if (wallet == address(0)) { - state.recordRevert("toggleAccessToETH"); + state.recordRevert("toggleAccessToFunds"); return; } address device = registry.isESIMWalletValid(wallet); if (device == address(0)) { - state.recordRevert("toggleAccessToETH"); + state.recordRevert("toggleAccessToFunds"); return; } vm.prank(device); - try DeviceWallet(payable(device)).toggleAccessToETH(wallet, hasAccessToETH) { + try DeviceWallet(payable(device)).toggleAccessToFunds(wallet, hasAccessToETH) { // Recorded in ghost state rather than asserted here: an assertion that trips inside a // handler reverts the call and the campaign reads it as a skipped action if (hasAccessToETH) { @@ -220,9 +220,9 @@ contract WalletHandler is HandlerBase { } else { state.clearETHAccessGrant(device, wallet); } - state.recordCall("toggleAccessToETH"); + state.recordCall("toggleAccessToFunds"); } catch { - state.recordRevert("toggleAccessToETH"); + state.recordRevert("toggleAccessToFunds"); } } diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol index 7e9b34e4..04ebecdd 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol @@ -226,10 +226,10 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { // 5. deviceWallet2 grants access to eSIMWallet1 to pull ETH (This could also be done in a single step during addESIMWallet function call) vm.startPrank(address(deviceWallet2)); - deviceWallet2.toggleAccessToETH(address(eSIMWallet1), true); + deviceWallet2.toggleAccessToFunds(address(eSIMWallet1), true); vm.stopPrank(); - assertEq(deviceWallet2.canPullETH(address(eSIMWallet1)), true, "ESIMWallet1 should have access to ETH for deviceWallet2"); + assertEq(deviceWallet2.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should have access to ETH for deviceWallet2"); assertEq(address(eSIMWallet1).balance, 0, "eSIMWallet1 balance should have been 0 ETH"); // 6. Add ETH to deviceWallet2, and buy data bundle for eSIMWallet1 @@ -355,7 +355,7 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { /// @dev Pinned rather than prevented. It stays the associated device wallet until the new one /// binds, and it is still the eSIM wallet's owner through this window, so this is the /// party reversing its own release rather than a third one interfering. It buys back no - /// authority: the device wallet cleared `isValidESIMWallet` and `canPullETH` on itself + /// authority: the device wallet cleared `isValidESIMWallet` and `canPullFunds` on itself /// when it released, and the pending owner still refuses a rebind. function test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() public { deployWallets(); @@ -449,10 +449,10 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { // 6. deviceWallet2 grants access to eSIMWallet1 to pull ETH (This could also be done in a single step during addESIMWallet function call) vm.startPrank(address(deviceWallet2)); - deviceWallet2.toggleAccessToETH(address(eSIMWallet1), true); + deviceWallet2.toggleAccessToFunds(address(eSIMWallet1), true); vm.stopPrank(); - assertEq(deviceWallet2.canPullETH(address(eSIMWallet1)), true, "ESIMWallet1 should have access to ETH for deviceWallet2"); + assertEq(deviceWallet2.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should have access to ETH for deviceWallet2"); assertEq(address(eSIMWallet1).balance, 0, "eSIMWallet1 balance should have been 0 ETH"); // 7. Add ETH to deviceWallet2, and buy data bundle for eSIMWallet1 diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol index 2f936a1f..9042b903 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol @@ -29,7 +29,7 @@ contract DeviceWalletETHTest is DeviceWalletFixture { vm.deal(address(deviceWallet), 2 ether); vm.startPrank(address(eSIMWallet2)); - vm.expectRevert(abi.encodeWithSelector(Errors.ETHAccessRevoked.selector, address(eSIMWallet2))); + vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, address(eSIMWallet2))); deviceWallet.pullETH(1000000000000000000); // 1 ETH vm.stopPrank(); } @@ -74,57 +74,57 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(deviceWallet2.getVaultAddress(), user5, "Every wallet must follow, not just one"); } - function test_toggleAccessToETH_unauthorised() public { + function test_toggleAccessToFunds_unauthorised() public { deployWallets(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); vm.startPrank(user1); vm.expectRevert(Errors.OnlySelf.selector); - deviceWallet.toggleAccessToETH( + deviceWallet.toggleAccessToFunds( address(eSIMWallet1), false ); vm.stopPrank(); } - function test_toggleAccessToETH_revoke_deviceWalletHasETH() public { + function test_toggleAccessToFunds_revoke_deviceWalletHasETH() public { deployWallets(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToETH( + deviceWallet.toggleAccessToFunds( address(eSIMWallet1), false ); vm.stopPrank(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); uint256 _priceWei = 0.1 ether; vm.deal(address(deviceWallet), 1 ether); vm.startPrank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.ETHAccessRevoked.selector, address(eSIMWallet1))); + vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, address(eSIMWallet1))); eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); vm.stopPrank(); } - function test_toggleAccessToETH_revoke_eSIMWalletHasETH() public { + function test_toggleAccessToFunds_revoke_eSIMWalletHasETH() public { deployWallets(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToETH( + deviceWallet.toggleAccessToFunds( address(eSIMWallet1), false ); vm.stopPrank(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); uint256 _priceWei = 0.1 ether; @@ -143,19 +143,19 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } - function test_toggleAccessToETH_revoke_userHasETH() public { + function test_toggleAccessToFunds_revoke_userHasETH() public { deployWallets(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToETH( + deviceWallet.toggleAccessToFunds( address(eSIMWallet1), false ); vm.stopPrank(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); uint256 _priceWei = 0.1 ether; @@ -175,19 +175,19 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } - function test_toggleAccessToETH_grant_deviceWalletHasETH() public { + function test_toggleAccessToFunds_grant_deviceWalletHasETH() public { deployWallets(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), false, "eSIMWallet2 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "eSIMWallet2 should not be able to pull ETH"); vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToETH( + deviceWallet.toggleAccessToFunds( address(eSIMWallet2), true ); vm.stopPrank(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); uint256 _priceWei = 0.1 ether; @@ -206,19 +206,19 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } - function test_toggleAccessToETH_grant_userHasETH() public { + function test_toggleAccessToFunds_grant_userHasETH() public { deployWallets(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), false, "eSIMWallet2 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "eSIMWallet2 should not be able to pull ETH"); vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToETH( + deviceWallet.toggleAccessToFunds( address(eSIMWallet2), true ); vm.stopPrank(); - assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); uint256 _priceWei = 0.1 ether; @@ -249,18 +249,18 @@ contract DeviceWalletETHTest is DeviceWalletFixture { deployWallets(); vm.prank(eSIMWalletAdmin); - vm.expectPartialRevert(Errors.ETHAccessNotGrantableAtBind.selector); + vm.expectPartialRevert(Errors.FundsAccessNotGrantableAtBind.selector); deviceWallet.deployESIMWallet(true, 4242); } /// @notice Not even the device wallet itself may grant access at bind time - /// @dev Pins `toggleAccessToETH` as the single grant path, not merely the non-admin one. + /// @dev Pins `toggleAccessToFunds` as the single grant path, not merely the non-admin one. function test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() public { deployWallets(); vm.prank(address(deviceWallet)); vm.expectRevert(abi.encodeWithSelector( - Errors.ETHAccessNotGrantableAtBind.selector, address(eSIMWallet3) + Errors.FundsAccessNotGrantableAtBind.selector, address(eSIMWallet3) )); deviceWallet.addESIMWallet(address(eSIMWallet3), true); } @@ -273,19 +273,19 @@ contract DeviceWalletETHTest is DeviceWalletFixture { vm.deal(address(deviceWallet), 10 ether); vm.prank(address(deviceWallet)); - deviceWallet.toggleAccessToETH(address(eSIMWallet1), false); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), false); vm.prank(eSIMWalletAdmin); - vm.expectPartialRevert(Errors.ETHAccessNotGrantableAtBind.selector); + vm.expectPartialRevert(Errors.FundsAccessNotGrantableAtBind.selector); deviceWallet.deployESIMWallet(true, 4243); vm.prank(eSIMWalletAdmin); address fresh = deviceWallet.deployESIMWallet(false, 4243); - assertFalse(deviceWallet.canPullETH(fresh), "The fresh wallet must arrive with no ETH access"); + assertFalse(deviceWallet.canPullFunds(fresh), "The fresh wallet must arrive with no ETH access"); vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.ETHAccessRevoked.selector, fresh)); + vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, fresh)); MockESIMWallet(payable(fresh)).buyDataBundle(bundle("DB_ID_0", TEST_PRICE_CENTS), 1 ether, nextRef()); assertEq(address(deviceWallet).balance, 10 ether, "No ETH may leave through a wallet the user never granted"); @@ -316,13 +316,13 @@ contract DeviceWalletETHTest is DeviceWalletFixture { MockDeviceWallet fresh = MockDeviceWallet(payable(batch.deviceWallet)); assertFalse( - fresh.canPullETH(batch.eSIMWallet), + fresh.canPullFunds(batch.eSIMWallet), "The batch deployed wallet must arrive with no ETH access" ); vm.prank(eSIMWalletAdmin); address second = fresh.deployESIMWallet(false, 4245); - assertFalse(fresh.canPullETH(second), "The admin deployed wallet must arrive with no ETH access"); + assertFalse(fresh.canPullFunds(second), "The admin deployed wallet must arrive with no ETH access"); } /// @notice The owner grants after the bind, and the purchase then goes through @@ -336,8 +336,8 @@ contract DeviceWalletETHTest is DeviceWalletFixture { address fresh = deviceWallet.deployESIMWallet(false, 4245); vm.prank(address(deviceWallet)); - deviceWallet.toggleAccessToETH(fresh, true); - assertTrue(deviceWallet.canPullETH(fresh), "The grant must land"); + deviceWallet.toggleAccessToFunds(fresh, true); + assertTrue(deviceWallet.canPullFunds(fresh), "The grant must land"); vm.prank(eSIMWalletAdmin); MockESIMWallet(payable(fresh)).buyDataBundle(bundle("DB_ID_0", TEST_PRICE_CENTS), 1 ether, nextRef()); diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol index b653b867..3bc79778 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol @@ -136,16 +136,16 @@ contract DeviceWalletGuardsTest is DeployerBase { // --------------------------------------------------------------------------------------------- /// @notice ETH access cannot be granted to an address the wallet has never bound - /// @dev Without this an arbitrary address could be given canPullETH, and the pull path checks + /// @dev Without this an arbitrary address could be given canPullFunds, and the pull path checks /// only that flag and the binding it is set alongside. - function test_toggleAccessToETH_rejectsAnUnknownESIMWallet() public { + function test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() public { _deployWallet(customDeviceUniqueIdentifiers[0], pubKey1, 8006); vm.prank(address(wallet)); vm.expectRevert(abi.encodeWithSelector(Errors.UnknownESIMWallet.selector, user1)); - wallet.toggleAccessToETH(user1, true); + wallet.toggleAccessToFunds(user1, true); - assertEq(wallet.canPullETH(user1), false, "A refused grant must leave the address without access"); + assertEq(wallet.canPullFunds(user1), false, "A refused grant must leave the address without access"); } /// @notice An eSIM wallet this device does not hold cannot be removed from it diff --git a/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol b/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol index 58021245..2605ff20 100644 --- a/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol +++ b/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol @@ -73,7 +73,7 @@ abstract contract DeviceWalletFixture is DeployerBase { // A bind never carries ETH access, so the owner grants it here vm.prank(address(userDeviceWallet)); - userDeviceWallet.toggleAccessToETH(address(userESIMWallet), true); + userDeviceWallet.toggleAccessToFunds(address(userESIMWallet), true); assertNotEq(address(userDeviceWallet), address(0), "deviceWallet address cannot be address(0)"); @@ -92,7 +92,7 @@ abstract contract DeviceWalletFixture is DeployerBase { assertEq(address(userDeviceWallet.registry()), address(registry), "Registry should have been correct for userDeviceWallet"); assertEq(address(userDeviceWallet.eSIMWalletFactory()), address(eSIMWalletFactory), "eSIMWalletFactory address in userDeviceWallet should have matched"); assertEq(userDeviceWallet.isValidESIMWallet(address(userESIMWallet)), true, "userESIMWallet should have been set to valid"); - assertEq(userDeviceWallet.canPullETH(address(userESIMWallet)), true, "userESIMWallet should be able to pull ETH"); + assertEq(userDeviceWallet.canPullFunds(address(userESIMWallet)), true, "userESIMWallet should be able to pull ETH"); assertEq(address(userDeviceWallet.entryPoint()), address(entryPoint), "Entry point address should have been initialised in userDeviceWallet"); assertEq(address(userDeviceWallet.verifier()), address(p256Verifier), "P256Verifier address should have been initialised in userDeviceWallet"); @@ -156,9 +156,9 @@ abstract contract DeviceWalletFixture is DeployerBase { // A bind never carries ETH access, so the two wallets that need it are granted it here vm.prank(address(deviceWallet)); - deviceWallet.toggleAccessToETH(address(eSIMWallet1), true); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), true); vm.prank(address(deviceWallet2)); - deviceWallet2.toggleAccessToETH(address(eSIMWallet3), true); + deviceWallet2.toggleAccessToFunds(address(eSIMWallet3), true); vm.startPrank(admin); // eSIMWallet2 -> no access to ETH, no eSIM identifier set @@ -204,9 +204,9 @@ abstract contract DeviceWalletFixture is DeployerBase { assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet1)), true, "ESIMWallet1 should have been set to valid"); assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet2)), true, "ESIMWallet2 should have been set to valid"); assertEq(deviceWallet2.isValidESIMWallet(address(eSIMWallet3)), true, "ESIMWallet3 should have been set to valid"); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), true, "ESIMWallet1 should be able to pull ETH"); - assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), false, "ESIMWallet2 should not be able to pull ETH"); - assertEq(deviceWallet2.canPullETH(address(eSIMWallet3)), true, "ESIMWallet3 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "ESIMWallet2 should not be able to pull ETH"); + assertEq(deviceWallet2.canPullFunds(address(eSIMWallet3)), true, "ESIMWallet3 should be able to pull ETH"); assertEq(address(deviceWallet.entryPoint()), address(entryPoint), "Entry point address should have been initialised in deviceWallet"); assertEq(address(deviceWallet2.entryPoint()), address(entryPoint), "Entry point address should have been initialised in deviceWallet2"); assertEq(address(deviceWallet.verifier()), address(p256Verifier), "P256Verifier address should have been initialised in deviceWallet"); @@ -251,12 +251,12 @@ abstract contract DeviceWalletFixture is DeployerBase { MockESIMWallet _eSIMWallet, bool _onStandby, address _associatedDeviceWallet, - bool _canPullETH, + bool _canPullFunds, bool _isValidForDeviceWallet ) internal view { assertEq(registry.isESIMWalletOnStandby(address(_eSIMWallet)), _onStandby, "Unexpected standby status for the eSIM wallet"); assertEq(registry.isESIMWalletValid(address(_eSIMWallet)), _associatedDeviceWallet, "Unexpected device wallet associated with the eSIM wallet"); - assertEq(_deviceWallet.canPullETH(address(_eSIMWallet)), _canPullETH, "Unexpected ETH pull access for the eSIM wallet"); + assertEq(_deviceWallet.canPullFunds(address(_eSIMWallet)), _canPullFunds, "Unexpected ETH pull access for the eSIM wallet"); assertEq(_deviceWallet.isValidESIMWallet(address(_eSIMWallet)), _isValidForDeviceWallet, "Unexpected eSIM wallet validity for the device wallet"); } } diff --git a/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryBatchDeploy.t.sol b/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryBatchDeploy.t.sol index 23406822..78f3bf88 100644 --- a/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryBatchDeploy.t.sol +++ b/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryBatchDeploy.t.sol @@ -88,7 +88,7 @@ contract DeviceWalletFactoryBatchDeployTest is DeviceWalletFactoryFixture { assertEq(address(deviceWallet.registry()), address(registry), "Registry should have been correct"); assertEq(address(deviceWallet.eSIMWalletFactory()), address(eSIMWalletFactory), "eSIMWalletFactory address in device wallet should have matched"); assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet)), true, "ESIMWallet should have been set to valid"); - assertEq(deviceWallet.canPullETH(address(eSIMWallet)), false, "A batch deploy must not hand out ETH access"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet)), false, "A batch deploy must not hand out ETH access"); // Check storage variables in eSIM wallet assertEq(address(eSIMWallet.eSIMWalletFactory()), address(eSIMWalletFactory), "eSIMWalletFactory address in eSIM wallet should have matched"); diff --git a/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryConfig.t.sol b/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryConfig.t.sol index 168c9008..0854ff7c 100644 --- a/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryConfig.t.sol +++ b/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryConfig.t.sol @@ -74,7 +74,7 @@ contract DeviceWalletFactoryConfigTest is DeviceWalletFactoryFixture { // Granted before the upgrade, so the assertion below checks a true survives the move vm.prank(wallet.deviceWallet); - MockDeviceWallet(payable(wallet.deviceWallet)).toggleAccessToETH(wallet.eSIMWallet, true); + MockDeviceWallet(payable(wallet.deviceWallet)).toggleAccessToFunds(wallet.eSIMWallet, true); // Now upgrade the Device Wallet implementation contract address owner = deviceWalletFactory.owner(); @@ -103,6 +103,6 @@ contract DeviceWalletFactoryConfigTest is DeviceWalletFactoryFixture { assertEq(address(upgradedDeviceWallet.registry()), address(registry), "Registry should have been correct"); assertEq(address(upgradedDeviceWallet.eSIMWalletFactory()), address(eSIMWalletFactory), "eSIMWalletFactory address in device wallet should have matched"); assertEq(upgradedDeviceWallet.isValidESIMWallet(wallet.eSIMWallet), true, "ESIMWallet should have been set to valid"); - assertEq(upgradedDeviceWallet.canPullETH(wallet.eSIMWallet), true, "ESIMWallet should be able to pull ETH"); + assertEq(upgradedDeviceWallet.canPullFunds(wallet.eSIMWallet), true, "ESIMWallet should be able to pull ETH"); } } diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol index 09589685..b3ae42a9 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol @@ -81,7 +81,7 @@ contract ESIMWalletTest is DeployerBase { // A bind never carries ETH access, so the owner grants it here vm.prank(address(deviceWallet)); - deviceWallet.toggleAccessToETH(address(eSIMWallet1), true); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), true); vm.startPrank(admin); // eSIMWallet2 -> no access to ETH, no eSIM identifier set @@ -113,8 +113,8 @@ contract ESIMWalletTest is DeployerBase { assertEq(address(deviceWallet.eSIMWalletFactory()), address(eSIMWalletFactory), "eSIMWalletFactory address in device wallet should have matched"); assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet1)), true, "ESIMWallet1 should have been set to valid"); assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet2)), true, "ESIMWallet2 should have been set to valid"); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), true, "ESIMWallet1 should be able to pull ETH"); - assertEq(deviceWallet.canPullETH(address(eSIMWallet2)), false, "ESIMWallet2 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "ESIMWallet2 should not be able to pull ETH"); // Check storage variables in eSIM wallet assertEq(address(eSIMWallet1.eSIMWalletFactory()), address(eSIMWalletFactory), "eSIMWalletFactory address in eSIM wallet1 should have matched"); @@ -377,7 +377,7 @@ contract ESIMWalletTest is DeployerBase { assertEq(eSIMWallet1.newRequestedOwner(), address(0), "the pending request must be cleared"); assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet1)), true, "the binding must be restored"); - assertEq(deviceWallet.canPullETH(address(eSIMWallet1)), false, "ETH access is not restored"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "ETH access is not restored"); assertEq(registry.isESIMWalletOnStandby(address(eSIMWallet1)), false, "standby must be cleared"); assertEq(registry.isESIMWalletValid(address(eSIMWallet1)), currentOwner, "the association is unchanged"); } @@ -508,13 +508,13 @@ contract ESIMWalletTest is DeployerBase { // The bind carries no ETH access, so the new owner grants it separately. vm.startPrank(address(deviceWallet2)); deviceWallet2.addESIMWallet(address(eSIMWallet1), false); - deviceWallet2.toggleAccessToETH(address(eSIMWallet1), true); + deviceWallet2.toggleAccessToFunds(address(eSIMWallet1), true); vm.stopPrank(); assertEq(address(deviceWallet).balance, 11 ether, "Device wallet balance should have increased to 11 ETH"); assertEq(address(eSIMWallet1).balance, 0 ether, "eSIM wallet balance should have decreased to 0 ETH"); assertEq(deviceWallet2.isValidESIMWallet(address(eSIMWallet1)), true, "eSIM wallet added should have been set to valid"); - assertEq(deviceWallet2.canPullETH(address(eSIMWallet1)), true, "eSIM wallet should have ability to pull ETH"); + assertEq(deviceWallet2.canPullFunds(address(eSIMWallet1)), true, "eSIM wallet should have ability to pull ETH"); assertEq(registry.isESIMWalletOnStandby(address(eSIMWallet1)), false, "ESIMWallet1 should not have been on standBy"); assertEq(registry.isESIMWalletValid(address(eSIMWallet1)), address(deviceWallet2), "Registry should have updated the eSIM wallet to the new device wallet"); } diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol index 63268fa6..17be8646 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol @@ -51,7 +51,7 @@ contract ESIMWalletGuardsTest is DeployerBase { // A bind never carries ETH access, and the purchases below are funded from the device wallet vm.prank(address(deviceWallet)); - deviceWallet.toggleAccessToETH(address(eSIMWallet), true); + deviceWallet.toggleAccessToFunds(address(eSIMWallet), true); } /// @notice Deploys an eSIM wallet proxy straight against the beacon, so the initialiser diff --git a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol index bc1dac4a..62a05e7a 100644 --- a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol +++ b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol @@ -299,7 +299,7 @@ contract LazyWalletRegistryTest is DeployerBase { // Check storage variables in device wallet assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet)), true, "ESIMWallet should have been set to valid"); - assertEq(deviceWallet.canPullETH(address(eSIMWallet)), false, "A lazy deploy must not hand out ETH access"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet)), false, "A lazy deploy must not hand out ETH access"); // Check storage variables in eSIM wallet assertEq(eSIMWallet.owner(), address(deviceWallet), "ESIMWallet owner should have been device wallet"); @@ -690,7 +690,7 @@ contract LazyWalletRegistryTest is DeployerBase { assertEq(registry.isESIMWalletValid(eSIMWallets[i]), deviceWalletAddress, "Each wallet must be bound"); assertEq(deviceWallet.isValidESIMWallet(eSIMWallets[i]), true, "Each wallet must be valid on the device"); - assertEq(deviceWallet.canPullETH(eSIMWallets[i]), false, "No wallet may arrive with ETH access"); + assertEq(deviceWallet.canPullFunds(eSIMWallets[i]), false, "No wallet may arrive with ETH access"); assertEq( eSIMWallet.eSIMUniqueIdentifier(), string.concat("partial_", vm.toString(i)), diff --git a/test/foundry/unit-testing/registry/SettledPurchase.t.sol b/test/foundry/unit-testing/registry/SettledPurchase.t.sol index 0ab7db6d..9ff148a6 100644 --- a/test/foundry/unit-testing/registry/SettledPurchase.t.sol +++ b/test/foundry/unit-testing/registry/SettledPurchase.t.sol @@ -42,7 +42,7 @@ contract SettledPurchaseTest is DeployerBase { vm.deal(address(deviceWallet), 10 ether); vm.prank(address(deviceWallet)); - deviceWallet.toggleAccessToETH(address(eSIMWallet), true); + deviceWallet.toggleAccessToFunds(address(eSIMWallet), true); } /// @notice Records a purchase the admin says was paid for offchain diff --git a/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol b/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol index 8a4956ea..a0dd799a 100644 --- a/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol +++ b/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol @@ -53,7 +53,7 @@ contract PriceCapSlotMigrationTest is DeployerBase { vm.deal(address(deviceWallet), 100 ether); vm.prank(address(deviceWallet)); - deviceWallet.toggleAccessToETH(address(eSIMWallet), true); + deviceWallet.toggleAccessToFunds(address(eSIMWallet), true); } /// @notice Clears the eight ceiling bytes and leaves the rest of the slot alone diff --git a/test/foundry/unit-testing/upgrade/StorageLayout.t.sol b/test/foundry/unit-testing/upgrade/StorageLayout.t.sol index 89a4e09e..1fb57222 100644 --- a/test/foundry/unit-testing/upgrade/StorageLayout.t.sol +++ b/test/foundry/unit-testing/upgrade/StorageLayout.t.sol @@ -13,7 +13,7 @@ import "test/utils/DeployerBase.sol"; /// The way that happens by accident is a variable added to a base contract, because base /// storage comes first. `Account4337` declares `owner` and `DeviceWallet` picks up at slot /// 2, so a single new variable in `Account4337` pushes `registry`, `eSIMWalletFactory`, -/// `deviceUniqueIdentifier`, `isValidESIMWallet` and `canPullETH` down on every device +/// `deviceUniqueIdentifier`, `isValidESIMWallet` and `canPullFunds` down on every device /// wallet that exists. `RegistryHelper` sits under `Registry` the same way, which is what /// the 50 slot gap at `RegistryHelper.sol:81` is holding open and why `Registry`'s own /// state starts at slot 60 rather than slot 10. @@ -81,7 +81,7 @@ contract StorageLayoutTest is DeployerBase { assertTrue(_wallet.isValidESIMWallet(SENTINEL), "DeviceWallet.isValidESIMWallet must read slot 5"); vm.store(_target, _entry(SENTINEL, 6), bytes32(uint256(1))); - assertTrue(_wallet.canPullETH(SENTINEL), "DeviceWallet.canPullETH must read slot 6"); + assertTrue(_wallet.canPullFunds(SENTINEL), "DeviceWallet.canPullFunds must read slot 6"); } function test_layout_eSIMWalletSlotsAreUnchanged() public { diff --git a/test/utils/mocks/ReentrantESIMWallet.sol b/test/utils/mocks/ReentrantESIMWallet.sol index 2905e87e..c9f57b18 100644 --- a/test/utils/mocks/ReentrantESIMWallet.sol +++ b/test/utils/mocks/ReentrantESIMWallet.sol @@ -34,7 +34,7 @@ contract ReentrantESIMWallet { // These are etched over an existing eSIM wallet, so the slots hold that wallet's leftover // storage until each one is written. All three have to be set, not just the observed ones. wasStillValidDuringRemoval = deviceWallet.isValidESIMWallet(address(this)); - couldStillPullETHDuringRemoval = deviceWallet.canPullETH(address(this)); + couldStillPullETHDuringRemoval = deviceWallet.canPullFunds(address(this)); pullETHSucceededDuringRemoval = false; try deviceWallet.pullETH(1 ether) returns (uint256) { From 50e852642bd2f353fe71ed22c13623957c833325 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:14:25 +0530 Subject: [PATCH 23/75] Move tokens to the vault from the payment adapter The caller funds the adapter then calls settle, so a swap can be added later without changing the signature or the wallets. --- contracts/interfaces/IPaymentRegistry.sol | 14 +++ contracts/payments/PaymentAdapter.sol | 105 ++++++++++++++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 contracts/interfaces/IPaymentRegistry.sol diff --git a/contracts/interfaces/IPaymentRegistry.sol b/contracts/interfaces/IPaymentRegistry.sol new file mode 100644 index 00000000..8584cf4a --- /dev/null +++ b/contracts/interfaces/IPaymentRegistry.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +/// @notice The two things the payment adapter asks the registry on a settlement +/// @dev An interface rather than an import of `Registry`, which already imports the adapter. It +/// also keeps the adapter's view of the registry down to what it reads. +interface IPaymentRegistry { + /// @notice Address that receives payments for data bundles + function vault() external view returns (address); + + /// @notice The device wallet an eSIM wallet belongs to, or zero if the registry has no record + function isESIMWalletValid(address eSIMWallet) external view returns (address); +} diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index 4a76e350..673f7029 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -2,12 +2,18 @@ pragma solidity 0.8.36; // Libraries +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Errors} from "../Errors.sol"; +// Interfaces +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IPaymentRegistry} from "../interfaces/IPaymentRegistry.sol"; + // Contracts import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; /// @notice One currency the protocol will price a data bundle in /// @dev 23 bytes, so it fits one slot with nine to spare. New fields have to stay inside those @@ -20,13 +26,17 @@ struct Asset { address token; // ERC-20 address, or zero for fiat and non-EVM assets } -/// @notice Holds the accepted currencies, converts prices, and spends payment references +/// @notice Holds the accepted currencies, converts prices, moves tokens to the vault, and spends +/// payment references /// @dev Prices cross contract boundaries in USD cents only, and this is the one place a cent /// figure becomes a token amount, so a decimals mismatch cannot happen rather than having to /// be checked for. There are no price feeds, so a currency not already in dollars has no /// conversion here. UUPS and not swappable: `usedReferences` is replay protection, and a /// fresh copy would re-open every reference already spent. -contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { +/// +/// Tokens pass through in one call and never rest here. +contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable, ReentrancyGuardUpgradeable { + using SafeERC20 for IERC20; /// @notice Cents per dollar, the divisor that takes a cent price down to whole units uint256 private constant CENTS_PER_DOLLAR = 100; @@ -70,12 +80,34 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @notice Emitted when a payment reference is spent event PaymentReferenceConsumed(bytes32 indexed _paymentReference); + /// @notice Emitted when a data bundle is paid for in tokens through this contract + /// @dev One address for an indexer to watch instead of every eSIM wallet. The vault is + /// recorded because it can be rotated, and reconciliation needs the one that was paid. + event PaymentSettled( + bytes32 indexed _symbol, + address indexed _eSIMWallet, + address indexed _vault, + uint64 _priceUSDCents, + uint256 _spent, + uint256 _refunded + ); + /// @notice Restricts a call to the registry modifier onlyRegistry() { if(msg.sender != registry) revert Errors.OnlyRegistry(); _; } + /// @notice Restricts a call to an eSIM wallet the registry has a record of + /// @dev Read from the registry on every call rather than held here, so a wallet the registry + /// has let go cannot keep paying through this contract. + modifier onlyProtocolESIMWallet() { + if(IPaymentRegistry(registry).isESIMWalletValid(msg.sender) == address(0)) { + revert Errors.NotAProtocolESIMWallet(msg.sender); + } + _; + } + // --------------------------------------------------------------------------------------------- // Initialisation // --------------------------------------------------------------------------------------------- @@ -104,6 +136,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab __Ownable2Step_init(); __Ownable_init(_upgradeManager); + __ReentrancyGuard_init(); emit PaymentAdapterInitialized(_registry, _settlementToken); } @@ -146,12 +179,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @param _priceUSDCents Price in USD cents /// @return amountIn Amount in that currency's own smallest unit function quote(bytes32 _symbol, uint64 _priceUSDCents) external view returns (uint256 amountIn) { - Asset memory asset = assets[_symbol]; - - if(!asset.allowed) revert Errors.AssetNotAllowed(_symbol); - if(!asset.isDollarUnit) revert Errors.AssetNeedsSwap(_symbol); - - return (uint256(_priceUSDCents) * 10 ** asset.decimals) / CENTS_PER_DOLLAR; + return _quote(_symbol, assets[_symbol], _priceUSDCents); } /// @notice Reads back a currency entry, reverting if it is not allowed @@ -166,6 +194,51 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab return asset; } + // --------------------------------------------------------------------------------------------- + // Settlement + // --------------------------------------------------------------------------------------------- + + /// @notice Pays the vault for one data bundle out of tokens the caller has already sent here + /// @dev The caller funds this contract and calls settle in the same transaction, which leaves + /// the tokens here for a swap to be added later without changing this signature. + /// + /// The refund is bounded by what the caller funded rather than by the balance, so a token + /// sent here by mistake is not swept out by the next purchase. A fee-on-transfer token + /// delivers less than declared and fails the funding check. + /// @param _symbol Currency being paid in + /// @param _priceUSDCents Price of the data bundle, in USD cents + /// @param _amountIn Amount the caller has funded, and the most it is willing to spend + /// @param _refundTo Address anything unspent goes back to + /// @return spent Amount that reached the vault + /// @return refunded Amount returned to `_refundTo`, always zero until a swap can overshoot + function settle( + bytes32 _symbol, + uint64 _priceUSDCents, + uint256 _amountIn, + address _refundTo + ) external onlyProtocolESIMWallet nonReentrant returns (uint256 spent, uint256 refunded) { + if(_priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); + if(_refundTo == address(0)) revert Errors.ZeroAddress("_refundTo"); + + Asset memory asset = assets[_symbol]; + if(asset.token == address(0)) revert Errors.AssetNotTransferable(_symbol); + + spent = _quote(_symbol, asset, _priceUSDCents); + if(spent > _amountIn) revert Errors.SettlementAboveMax(spent, _amountIn); + + IERC20 token = IERC20(asset.token); + uint256 held = token.balanceOf(address(this)); + if(held < _amountIn) revert Errors.SettlementNotFunded(_amountIn, held); + + refunded = _amountIn - spent; + address vault = IPaymentRegistry(registry).vault(); + + emit PaymentSettled(_symbol, msg.sender, vault, _priceUSDCents, spent, refunded); + + token.safeTransfer(vault, spent); + if(refunded != 0) token.safeTransfer(_refundTo, refunded); + } + // --------------------------------------------------------------------------------------------- // Payment references // --------------------------------------------------------------------------------------------- @@ -211,6 +284,22 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab // Internals // --------------------------------------------------------------------------------------------- + /// @notice The cents to smallest-unit conversion `quote` and `settle` both go through + /// @dev One copy, so a settled amount can never disagree with the quoted one. + /// @param _symbol Currency the price is being expressed in, carried only for the revert + /// @param _asset The entry already read from storage + /// @param _priceUSDCents Price in USD cents + function _quote(bytes32 _symbol, Asset memory _asset, uint64 _priceUSDCents) + private + pure + returns (uint256) + { + if(!_asset.allowed) revert Errors.AssetNotAllowed(_symbol); + if(!_asset.isDollarUnit) revert Errors.AssetNeedsSwap(_symbol); + + return (uint256(_priceUSDCents) * 10 ** _asset.decimals) / CENTS_PER_DOLLAR; + } + /// @notice Validates and stores one currency entry /// @dev A non-zero `decimals` is what marks a symbol as registered, so withdrawing a currency /// sets `allowed` to false instead of deleting the entry. From 2fcb98ce8119529303361f24f765d8463b46746e Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:14:25 +0530 Subject: [PATCH 24/75] Buy a data bundle with an ERC-20 The adapter works the amount out from the price in cents, so unlike the ETH path there is no second figure taken on trust. --- contracts/esim-wallet/ESIMWallet.sol | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index e2f50ab0..1d162bad 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -3,8 +3,12 @@ pragma solidity 0.8.36; // Libraries import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Errors} from "../Errors.sol"; +// Interfaces +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + // Types import {DataBundleDetails, Settlement} from "../CustomStructs.sol"; @@ -13,6 +17,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {DeviceWallet} from "../device-wallet/DeviceWallet.sol"; +import {PaymentAdapter, Asset} from "../payments/PaymentAdapter.sol"; import {Registry} from "../Registry.sol"; /// @notice One eSIM, its purchase history and the ETH that pays for its data bundles @@ -22,6 +27,7 @@ import {Registry} from "../Registry.sol"; /// for a data bundle but cannot raise the ceiling that limits what it may charge. contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgradeable { using Address for address; + using SafeERC20 for IERC20; /// @notice Address of the eSIM wallet factory contract address public eSIMWalletFactory; @@ -61,6 +67,20 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade bytes32 indexed _paymentReference ); + /// @notice Emitted when a data bundle is paid for in an ERC-20 + /// @dev The adapter emits the settlement. This one is for an indexer watching one wallet. + event DataBundleBoughtWithToken( + bytes32 _dataBundleID, + uint64 _priceUSDCents, + bytes32 indexed _asset, + address indexed _token, + uint256 _amountSpent, + bytes32 indexed _paymentReference + ); + + /// @notice Emitted when an ERC-20 is returned to the owning device wallet + event TokenSentToDeviceWallet(address indexed _token, address indexed _deviceWallet, uint256 _amount); + /// @notice Emitted when a purchase paid for outside the protocol is recorded here /// @dev The registry emits the full record. This one is for an indexer watching one wallet. event DataBundleSettlementRecorded( @@ -340,6 +360,88 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade return true; } + /// @notice Pays the vault for one data bundle in an ERC-20 and records the purchase + /// @dev The adapter works the amount out from the price, so unlike the ETH path there is no + /// second figure to take on trust. Any shortfall is pulled from the device wallet. + /// @param _dataBundleDetail Data bundle being bought. Its settlement field is overwritten here. + /// @param _asset Symbol of the currency to pay in + /// @param _maxAmountIn Most of that currency the buyer will spend, in its smallest unit + /// @param _paymentReference The offchain order id. Spent once, so a retry of a call that + /// already landed cannot charge the user twice. + /// @return True if the transaction is successful + function buyDataBundleWithToken( + DataBundleDetails memory _dataBundleDetail, + bytes32 _asset, + uint256 _maxAmountIn, + bytes32 _paymentReference + ) public onlyDeviceWalletOrESIMWalletAdmin nonReentrant returns (bool) { + Registry registry = deviceWallet.registry(); + registry.requireNotPaused(); + if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); + if(_dataBundleDetail.priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); + _requirePriceWithinCap(_dataBundleDetail.priceUSDCents, registry); + + // The money moves through this contract, so this path can say the protocol saw it. + _dataBundleDetail.settlement = Settlement.DeviceWallet; + + registry.consumePaymentReference(_paymentReference); + + address adapterAddress = registry.paymentAdapter(); + if(adapterAddress == address(0)) revert Errors.PaymentAdapterNotSet(); + + PaymentAdapter adapter = PaymentAdapter(adapterAddress); + Asset memory asset = adapter.resolveAsset(_asset); + if(asset.token == address(0)) revert Errors.AssetNotTransferable(_asset); + + uint256 amountIn = adapter.quote(_asset, _dataBundleDetail.priceUSDCents); + if(amountIn > _maxAmountIn) revert Errors.SettlementAboveMax(amountIn, _maxAmountIn); + + IERC20 token = IERC20(asset.token); + uint256 held = token.balanceOf(address(this)); + if(held < amountIn) deviceWallet.pullToken(asset.token, amountIn - held); + + // Recorded before the money leaves, so nothing downstream reads a history missing this + transactionHistory.push(_dataBundleDetail); + + // Funded first, then told to settle. That is what lets a swap be added there later + // without changing anything here. + token.safeTransfer(adapterAddress, amountIn); + adapter.settle(_asset, _dataBundleDetail.priceUSDCents, amountIn, address(this)); + + emit DataBundleBoughtWithToken( + _dataBundleDetail.id, + _dataBundleDetail.priceUSDCents, + _asset, + asset.token, + amountIn, + _paymentReference + ); + + return true; + } + + /// @notice Sends an ERC-20 held here back to the owning device wallet + /// @dev The callback on `removeESIMWallet` moves ETH only, so without this a token balance + /// would be stranded when the wallet changes hands. + /// @param _token ERC-20 to send back + /// @param _amount Amount in that token's smallest unit + /// @return The amount sent + function sendTokenToDeviceWallet( + address _token, + uint256 _amount + ) external onlyDeviceWallet returns (uint256) { + if(_token == address(0)) revert Errors.ZeroAddress("_token"); + if(_amount == 0) revert Errors.ZeroAmount(); + + address currentOwner = owner(); + if(currentOwner == address(0)) revert Errors.ZeroAddress("owner"); + + IERC20(_token).safeTransfer(currentOwner, _amount); + emit TokenSentToDeviceWallet(_token, currentOwner, _amount); + + return _amount; + } + /// @notice Appends a purchase paid for outside the protocol /// @dev No money moves here. Nothing onchain saw this payment, so the ceiling is the only /// limit on what the admin can write into a user's history. Checked here and not on the From efe580fd338b18f82e30750891e2f7d9fa791653 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:23:50 +0530 Subject: [PATCH 25/75] Give the suite real tokens to move The settlement token was an address with no code, which the settlement path cannot use. Adds fee-on-transfer, no-return and callback variants. --- test/utils/DeployerBase.sol | 25 +++++++++++-- test/utils/mocks/tokens/MockERC20.sol | 25 +++++++++++++ .../mocks/tokens/MockFeeOnTransferERC20.sol | 33 +++++++++++++++++ test/utils/mocks/tokens/MockNoReturnERC20.sol | 29 +++++++++++++++ .../mocks/tokens/MockReenteringERC20.sol | 37 +++++++++++++++++++ 5 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 test/utils/mocks/tokens/MockERC20.sol create mode 100644 test/utils/mocks/tokens/MockFeeOnTransferERC20.sol create mode 100644 test/utils/mocks/tokens/MockNoReturnERC20.sol create mode 100644 test/utils/mocks/tokens/MockReenteringERC20.sol diff --git a/test/utils/DeployerBase.sol b/test/utils/DeployerBase.sol index 137a71aa..f3fb8839 100644 --- a/test/utils/DeployerBase.sol +++ b/test/utils/DeployerBase.sol @@ -19,6 +19,7 @@ import "test/utils/mocks/MockLazyWalletRegistry.sol"; import "test/utils/mocks/MockRegistry.sol"; import "test/utils/mocks/MockDeviceWallet.sol"; import "test/utils/mocks/MockESIMWallet.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; contract DeployerBase is Test { @@ -65,8 +66,10 @@ contract DeployerBase is Test { address vault = address(0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB); uint64 defaultPriceCapUSDCents = 100_000; // $1000 - // A stand-in for USDC. Nothing settles onchain yet, so the entry only has to be resolvable. - address settlementToken = address(0x036CbD53842c5426634e7929541eC2318f3dCF7e); + // A stand-in for USDC, deployed in setUp so the settlement path has real balances to move. + MockERC20 settlementERC20; + address settlementToken; + uint8 constant SETTLEMENT_DECIMALS = 6; bytes32 constant ASSET_USDC = bytes32("USDC"); bytes32 constant ASSET_USD = bytes32("USD"); bytes32 constant ASSET_ETH = bytes32("ETH"); @@ -169,6 +172,10 @@ contract DeployerBase is Test { // 9. Populate addresses deployed during the process // 8.c. Deploy the payment adapter and give it the currencies the tests price in + settlementERC20 = new MockERC20("USD Coin", "USDC", SETTLEMENT_DECIMALS); + settlementToken = address(settlementERC20); + console.log("settlementToken: ", settlementToken); + PaymentAdapter paymentAdapterImpl = new PaymentAdapter(); ERC1967Proxy paymentAdapterProxy = new ERC1967Proxy( address(paymentAdapterImpl), @@ -221,7 +228,7 @@ contract DeployerBase is Test { paymentAdapter.registerAsset(ASSET_USDC, Asset({ allowed: true, isDollarUnit: true, - decimals: 6, + decimals: SETTLEMENT_DECIMALS, token: settlementToken })); paymentAdapter.registerAsset(ASSET_USD, Asset({ @@ -248,6 +255,18 @@ contract DeployerBase is Test { }); } + /// @notice What a price in cents costs in the settlement token's own units + /// @dev Worked out here rather than read from `quote`, so a test comparing the two is comparing + /// the adapter against a second calculation instead of against itself. + function settlementAmount(uint64 _priceUSDCents) internal pure returns (uint256) { + return (uint256(_priceUSDCents) * 10 ** SETTLEMENT_DECIMALS) / 100; + } + + /// @notice Gives an address a settlement token balance + function fundSettlementToken(address _account, uint256 _amount) internal { + settlementERC20.mint(_account, _amount); + } + /// @notice A payment reference derived from a label, so two purchases in one test never collide function paymentRef(string memory _label) internal pure returns (bytes32) { return keccak256(bytes(_label)); diff --git a/test/utils/mocks/tokens/MockERC20.sol b/test/utils/mocks/tokens/MockERC20.sol new file mode 100644 index 00000000..ff465530 --- /dev/null +++ b/test/utils/mocks/tokens/MockERC20.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice A plain ERC-20 with the decimals set per instance +/// @dev Decimals are a constructor argument because the payment path converts cents into a token's +/// own smallest unit, so a suite that only ever saw 18 would not exercise the conversion. +contract MockERC20 is ERC20 { + + uint8 private immutable _tokenDecimals; + + constructor(string memory _name, string memory _symbol, uint8 _decimals) ERC20(_name, _symbol) { + _tokenDecimals = _decimals; + } + + function decimals() public view override returns (uint8) { + return _tokenDecimals; + } + + function mint(address _to, uint256 _amount) external { + _mint(_to, _amount); + } +} diff --git a/test/utils/mocks/tokens/MockFeeOnTransferERC20.sol b/test/utils/mocks/tokens/MockFeeOnTransferERC20.sol new file mode 100644 index 00000000..98a5e2cb --- /dev/null +++ b/test/utils/mocks/tokens/MockFeeOnTransferERC20.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import {MockERC20} from "./MockERC20.sol"; + +/// @notice A token that burns a cut of every transfer, so less arrives than was sent +/// @dev The protocol does not support these. This exists to pin what happens when one is added to +/// the currency table anyway. +contract MockFeeOnTransferERC20 is MockERC20 { + + uint256 private constant BPS_DENOMINATOR = 10_000; + + uint256 public immutable feeBps; + + constructor(string memory _name, string memory _symbol, uint8 _decimals, uint256 _feeBps) + MockERC20(_name, _symbol, _decimals) + { + feeBps = _feeBps; + } + + /// @dev Minting and burning go through untouched, so a balance can be set up exactly. + function _update(address _from, address _to, uint256 _value) internal override { + if(_from == address(0) || _to == address(0)) { + super._update(_from, _to, _value); + return; + } + + uint256 fee = (_value * feeBps) / BPS_DENOMINATOR; + super._update(_from, _to, _value - fee); + if(fee != 0) super._update(_from, address(0), fee); + } +} diff --git a/test/utils/mocks/tokens/MockNoReturnERC20.sol b/test/utils/mocks/tokens/MockNoReturnERC20.sol new file mode 100644 index 00000000..5dedd13f --- /dev/null +++ b/test/utils/mocks/tokens/MockNoReturnERC20.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +/// @notice A token whose `transfer` returns nothing, the way USDT does +/// @dev Written out rather than inherited, because the missing return value is the whole point and +/// no ERC-20 base can express it. Every path that moves one of these has to use SafeERC20. +contract MockNoReturnERC20 { + + error NotEnoughBalance(uint256 balance, uint256 amount); + + string public constant name = "No Return Token"; + string public constant symbol = "NRT"; + uint8 public constant decimals = 6; + + mapping(address account => uint256 amount) public balanceOf; + + function mint(address _to, uint256 _amount) external { + balanceOf[_to] += _amount; + } + + function transfer(address _to, uint256 _amount) external { + uint256 balance = balanceOf[msg.sender]; + if(balance < _amount) revert NotEnoughBalance(balance, _amount); + + balanceOf[msg.sender] = balance - _amount; + balanceOf[_to] += _amount; + } +} diff --git a/test/utils/mocks/tokens/MockReenteringERC20.sol b/test/utils/mocks/tokens/MockReenteringERC20.sol new file mode 100644 index 00000000..a9bb23bb --- /dev/null +++ b/test/utils/mocks/tokens/MockReenteringERC20.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import {MockERC20} from "./MockERC20.sol"; + +/// @notice A token that calls back into a target while a transfer is in flight +/// @dev Stands in for the ERC-777 style callback. The re-entrant call is made once and its failure +/// is swallowed, so the test asserts on the outcome rather than on this token's own revert. +contract MockReenteringERC20 is MockERC20 { + + address public target; + bytes public payload; + bool public reentered; + bool public reentryReverted; + + constructor(string memory _name, string memory _symbol, uint8 _decimals) + MockERC20(_name, _symbol, _decimals) + {} + + function setReentry(address _target, bytes calldata _payload) external { + target = _target; + payload = _payload; + reentered = false; + reentryReverted = false; + } + + function _update(address _from, address _to, uint256 _value) internal override { + super._update(_from, _to, _value); + + if(target == address(0) || reentered) return; + + reentered = true; + (bool success,) = target.call(payload); + reentryReverted = !success; + } +} From ecc382ca23cfcd02d5eb5e0b4f3120fd4e9e2ecc Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:23:50 +0530 Subject: [PATCH 26/75] Cover pulling, settling and buying with a token --- .../device-wallet/DeviceWalletTokens.t.sol | 123 ++++++ .../esim-wallet/ESIMWalletTokenPurchase.t.sol | 375 ++++++++++++++++++ .../payments/PaymentAdapterSettle.t.sol | 286 +++++++++++++ 3 files changed, 784 insertions(+) create mode 100644 test/foundry/unit-testing/device-wallet/DeviceWalletTokens.t.sol create mode 100644 test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol create mode 100644 test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletTokens.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletTokens.t.sol new file mode 100644 index 00000000..21f3f59c --- /dev/null +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletTokens.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "forge-std/Test.sol"; + +import {Errors} from "contracts/Errors.sol"; + +import {DeviceWalletFixture} from "test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; +import {MockNoReturnERC20} from "test/utils/mocks/tokens/MockNoReturnERC20.sol"; + +/// @notice Every path an ERC-20 takes out of a device wallet, and who may open it. +contract DeviceWalletTokensTest is DeviceWalletFixture { + + uint256 constant BALANCE = 1_000e6; + uint256 constant PULL = 250e6; + + event TokenSent(address indexed _token, address indexed _eSIMWalletAddress, uint256 _amount); + + function setUp() public override { + super.setUp(); + deployWallets(); + fundSettlementToken(address(deviceWallet), BALANCE); + } + + function test_pullToken_movesTheAmountToTheCallingWallet() public { + vm.prank(address(eSIMWallet1)); + uint256 pulled = deviceWallet.pullToken(settlementToken, PULL); + + assertEq(pulled, PULL, "The call should report what it moved"); + assertEq(settlementERC20.balanceOf(address(deviceWallet)), BALANCE - PULL, "Device wallet must be down by the amount"); + assertEq(settlementERC20.balanceOf(address(eSIMWallet1)), PULL, "eSIM wallet must be up by the amount"); + } + + function test_pullToken_emitsTokenSent() public { + vm.expectEmit(true, true, false, true, address(deviceWallet)); + emit TokenSent(settlementToken, address(eSIMWallet1), PULL); + + vm.prank(address(eSIMWallet1)); + deviceWallet.pullToken(settlementToken, PULL); + } + + function test_pullToken_revertsForAWalletWithoutAccess() public { + vm.prank(address(eSIMWallet2)); + vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, address(eSIMWallet2))); + deviceWallet.pullToken(settlementToken, PULL); + } + + /// @notice Access is one flag, so revoking it closes the token path as well as the ETH one + function test_pullToken_stopsOnceAccessIsRevoked() public { + vm.prank(address(eSIMWallet1)); + deviceWallet.pullToken(settlementToken, PULL); + + vm.prank(address(deviceWallet)); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), false); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, address(eSIMWallet1))); + deviceWallet.pullToken(settlementToken, PULL); + } + + function test_pullToken_revertsForACallerThatIsNotAnAssociatedWallet() public { + vm.prank(user1); + vm.expectRevert(Errors.OnlyAssociatedESIMWallets.selector); + deviceWallet.pullToken(settlementToken, PULL); + } + + /// @notice An eSIM wallet of another device cannot reach this one's balance + function test_pullToken_revertsForAnotherDevicesWallet() public { + vm.prank(address(eSIMWallet3)); + vm.expectRevert(Errors.OnlyAssociatedESIMWallets.selector); + deviceWallet.pullToken(settlementToken, PULL); + } + + function test_pullToken_revertsOnZeroAmount() public { + vm.prank(address(eSIMWallet1)); + vm.expectRevert(Errors.ZeroAmount.selector); + deviceWallet.pullToken(settlementToken, 0); + } + + function test_pullToken_revertsOnTheZeroTokenAddress() public { + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, "_token")); + deviceWallet.pullToken(address(0), PULL); + } + + function test_pullToken_revertsWhileTheProtocolIsPaused() public { + vm.prank(eSIMWalletAdmin); + registry.pause(); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(Errors.ProtocolPaused.selector); + deviceWallet.pullToken(settlementToken, PULL); + } + + /// @notice The token rejects a balance it cannot cover, so there is no second check here + function test_pullToken_revertsWhenTheBalanceIsShort() public { + vm.prank(address(eSIMWallet1)); + vm.expectRevert(); + deviceWallet.pullToken(settlementToken, BALANCE + 1); + } + + /// @notice USDT returns nothing from `transfer`, which only SafeERC20 accepts + function test_pullToken_movesATokenThatReturnsNothing() public { + MockNoReturnERC20 token = new MockNoReturnERC20(); + token.mint(address(deviceWallet), BALANCE); + + vm.prank(address(eSIMWallet1)); + deviceWallet.pullToken(address(token), PULL); + + assertEq(token.balanceOf(address(eSIMWallet1)), PULL, "The eSIM wallet must hold the pulled amount"); + } + + /// @notice A token this wallet has never held is not a special case, it just has no balance + function test_pullToken_revertsForATokenTheWalletDoesNotHold() public { + MockERC20 other = new MockERC20("Other", "OTH", 18); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(); + deviceWallet.pullToken(address(other), 1); + } +} diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol new file mode 100644 index 00000000..382fe4ce --- /dev/null +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "forge-std/Test.sol"; + +import "contracts/CustomStructs.sol"; +import {Errors} from "contracts/Errors.sol"; +import {Asset} from "contracts/payments/PaymentAdapter.sol"; + +import {DeviceWalletFixture} from "test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; +import {MockReenteringERC20} from "test/utils/mocks/tokens/MockReenteringERC20.sol"; + +/// @notice Buying a data bundle with an ERC-20, and getting a token balance back out again. +contract ESIMWalletTokenPurchaseTest is DeviceWalletFixture { + + uint64 constant PRICE_CENTS = 1_250; // $12.50 + uint256 constant DEVICE_BALANCE = 1_000e6; + + event DataBundleBoughtWithToken( + bytes32 _dataBundleID, + uint64 _priceUSDCents, + bytes32 indexed _asset, + address indexed _token, + uint256 _amountSpent, + bytes32 indexed _paymentReference + ); + + event TokenSentToDeviceWallet(address indexed _token, address indexed _deviceWallet, uint256 _amount); + + function setUp() public override { + super.setUp(); + deployWallets(); + fundSettlementToken(address(deviceWallet), DEVICE_BALANCE); + } + + // --------------------------------------------------------------------------------------------- + // The bought path + // --------------------------------------------------------------------------------------------- + + function test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() public { + uint256 needed = settlementAmount(PRICE_CENTS); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, nextRef()); + + assertEq(settlementERC20.balanceOf(vault), needed, "The vault should hold the price"); + assertEq(settlementERC20.balanceOf(address(deviceWallet)), DEVICE_BALANCE - needed, "The device wallet pays for it"); + assertEq(settlementERC20.balanceOf(address(eSIMWallet1)), 0, "Nothing should be left in the eSIM wallet"); + } + + /// @notice The eSIM wallet is the funding pot, so a balance it already holds is spent first + function test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() public { + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(eSIMWallet1), needed); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, nextRef()); + + assertEq(settlementERC20.balanceOf(vault), needed, "The vault should hold the price"); + assertEq(settlementERC20.balanceOf(address(deviceWallet)), DEVICE_BALANCE, "The device wallet should be untouched"); + } + + function test_buyDataBundleWithToken_pullsOnlyTheShortfall() public { + uint256 needed = settlementAmount(PRICE_CENTS); + uint256 held = needed / 4; + fundSettlementToken(address(eSIMWallet1), held); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, nextRef()); + + assertEq(settlementERC20.balanceOf(address(deviceWallet)), DEVICE_BALANCE - (needed - held), "Only the shortfall should have been pulled"); + assertEq(settlementERC20.balanceOf(vault), needed, "The vault still gets the whole price"); + } + + function test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() public { + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + + (bytes32 id, uint64 price, Settlement settlement) = eSIMWallet1.transactionHistory(0); + assertEq(id, bytes32("DB_TOKEN"), "The bundle id should have been recorded"); + assertEq(price, PRICE_CENTS, "The price should have been recorded in cents"); + assertEq(uint8(settlement), uint8(Settlement.DeviceWallet), "The protocol saw this money move"); + } + + /// @notice The settlement is set here, not taken from the caller + function test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() public { + DataBundleDetails memory detail = bundle("DB_TOKEN", PRICE_CENTS); + detail.settlement = Settlement.Fiat; + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(detail, ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + + (,, Settlement settlement) = eSIMWallet1.transactionHistory(0); + assertEq(uint8(settlement), uint8(Settlement.DeviceWallet), "A caller cannot name the settlement here"); + } + + function test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() public { + uint256 needed = settlementAmount(PRICE_CENTS); + bytes32 spentRef = nextRef(); + + vm.expectEmit(true, true, true, true, address(eSIMWallet1)); + emit DataBundleBoughtWithToken(bytes32("DB_TOKEN"), PRICE_CENTS, ASSET_USDC, settlementToken, needed, spentRef); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, spentRef); + } + + /// @notice The owner can buy without the admin, through the device wallet + function test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() public { + vm.prank(address(deviceWallet)); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + + assertEq(eSIMWallet1.getTransactionHistory().length, 1, "The purchase should have been recorded"); + } + + /// @notice A ceiling above the price does not get in the way + function test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() public { + uint256 needed = settlementAmount(PRICE_CENTS); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed * 2, nextRef()); + + assertEq(settlementERC20.balanceOf(vault), needed, "Only the quoted amount should be spent"); + assertEq(settlementERC20.balanceOf(address(deviceWallet)), DEVICE_BALANCE - needed, "The ceiling is not what gets pulled"); + } + + // --------------------------------------------------------------------------------------------- + // What the purchase refuses + // --------------------------------------------------------------------------------------------- + + function test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() public { + vm.prank(user1); + vm.expectRevert(Errors.OnlyDeviceWalletOrESIMWalletAdmin.selector); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + } + + function test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.EmptyDataBundleID.selector); + eSIMWallet1.buyDataBundleWithToken(bundle(bytes32(0), PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + } + + function test_buyDataBundleWithToken_revertsOnAZeroPrice() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.ZeroDataBundlePrice.selector); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", 0), ASSET_USDC, 1, nextRef()); + } + + function test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() public { + uint64 tooMuch = defaultPriceCapUSDCents + 1; + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, tooMuch, defaultPriceCapUSDCents)); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", tooMuch), ASSET_USDC, settlementAmount(tooMuch), nextRef()); + } + + /// @notice The admin names the price, so it must not be able to charge past the owner's own limit + function test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() public { + vm.prank(address(deviceWallet)); + eSIMWallet1.setPriceCapUSDCents(500); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, PRICE_CENTS, uint64(500))); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + } + + function test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() public { + vm.prank(eSIMWalletAdmin); + registry.pause(); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.ProtocolPaused.selector); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + } + + function test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() public { + uint256 needed = settlementAmount(PRICE_CENTS); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.SettlementAboveMax.selector, needed, needed - 1)); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed - 1, nextRef()); + } + + /// @notice Fiat has no token address, so there is nothing to transfer + function test_buyDataBundleWithToken_revertsForFiat() public { + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotTransferable.selector, ASSET_USD)); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USD, 1_000e6, nextRef()); + } + + function test_buyDataBundleWithToken_revertsForAWithdrawnAsset() public { + vm.prank(upgradeManager); + paymentAdapter.updateAsset(ASSET_USDC, Asset({ + allowed: false, + isDollarUnit: true, + decimals: SETTLEMENT_DECIMALS, + token: settlementToken + })); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, ASSET_USDC)); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + } + + /// @notice A wallet whose access the owner revoked cannot reach the device wallet's tokens + function test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() public { + vm.prank(address(deviceWallet)); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), false); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, address(eSIMWallet1))); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + } + + /// @notice A revoked wallet can still spend what it already holds, since it pulls nothing + function test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() public { + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(eSIMWallet1), needed); + + vm.prank(address(deviceWallet)); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), false); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, nextRef()); + + assertEq(settlementERC20.balanceOf(vault), needed, "The wallet's own balance should have paid for it"); + } + + /// @notice The wallet reads the adapter through the registry, so it checks what it got back + function test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() public { + vm.mockCall( + address(registry), + abi.encodeWithSignature("paymentAdapter()"), + abi.encode(address(0)) + ); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.PaymentAdapterNotSet.selector); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), nextRef()); + } + + // --------------------------------------------------------------------------------------------- + // Payment references + // --------------------------------------------------------------------------------------------- + + function test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() public { + bytes32 spentRef = nextRef(); + uint256 needed = settlementAmount(PRICE_CENTS); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, spentRef); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, spentRef)); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, spentRef); + } + + /// @notice References are spent protocol-wide, so the ETH path cannot re-use one from here + function test_buyDataBundleWithToken_referenceCannotBeReusedOnTheETHPath() public { + bytes32 spentRef = nextRef(); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), spentRef); + + vm.deal(address(eSIMWallet1), 1 ether); + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, spentRef)); + eSIMWallet1.buyDataBundle(bundle("DB_ETH", PRICE_CENTS), 0.1 ether, spentRef); + } + + /// @notice A second wallet cannot spend a reference the first one used + function test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() public { + bytes32 spentRef = nextRef(); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), spentRef); + + fundSettlementToken(address(deviceWallet2), DEVICE_BALANCE); + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, spentRef)); + eSIMWallet3.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), spentRef); + } + + // --------------------------------------------------------------------------------------------- + // Reentrancy + // --------------------------------------------------------------------------------------------- + + /// @notice A token calling back mid-purchase arrives as itself, which no gate on this path + /// accepts, so the purchase still lands exactly once + function test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() public { + MockReenteringERC20 token = new MockReenteringERC20("Callback Token", "CB", 6); + vm.prank(upgradeManager); + paymentAdapter.registerAsset(bytes32("CB"), Asset({ + allowed: true, + isDollarUnit: true, + decimals: 6, + token: address(token) + })); + + uint256 needed = settlementAmount(PRICE_CENTS); + token.mint(address(eSIMWallet1), needed); + token.setReentry( + address(eSIMWallet1), + abi.encodeCall( + eSIMWallet1.buyDataBundleWithToken, + (bundle("DB_REENTER", PRICE_CENTS), bytes32("CB"), needed, paymentRef("reentrant")) + ) + ); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), bytes32("CB"), needed, nextRef()); + + assertTrue(token.reentered(), "The token should have tried to call back"); + assertTrue(token.reentryReverted(), "The call back should have been refused"); + assertEq(eSIMWallet1.getTransactionHistory().length, 1, "Exactly one purchase should have been recorded"); + assertEq(token.balanceOf(vault), needed, "The vault should have been paid once"); + } + + // --------------------------------------------------------------------------------------------- + // Getting a token balance back out + // --------------------------------------------------------------------------------------------- + + function test_sendTokenToDeviceWallet_returnsTheBalance() public { + fundSettlementToken(address(eSIMWallet1), 100e6); + + vm.prank(address(deviceWallet)); + eSIMWallet1.sendTokenToDeviceWallet(settlementToken, 100e6); + + assertEq(settlementERC20.balanceOf(address(eSIMWallet1)), 0, "The eSIM wallet should be empty"); + assertEq(settlementERC20.balanceOf(address(deviceWallet)), DEVICE_BALANCE + 100e6, "The device wallet should have it back"); + } + + function test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() public { + fundSettlementToken(address(eSIMWallet1), 100e6); + + vm.expectEmit(true, true, false, true, address(eSIMWallet1)); + emit TokenSentToDeviceWallet(settlementToken, address(deviceWallet), 100e6); + + vm.prank(address(deviceWallet)); + eSIMWallet1.sendTokenToDeviceWallet(settlementToken, 100e6); + } + + function test_sendTokenToDeviceWallet_revertsForAnyoneElse() public { + fundSettlementToken(address(eSIMWallet1), 100e6); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.OnlyDeviceWallet.selector); + eSIMWallet1.sendTokenToDeviceWallet(settlementToken, 100e6); + } + + function test_sendTokenToDeviceWallet_revertsOnZeroAmount() public { + vm.prank(address(deviceWallet)); + vm.expectRevert(Errors.ZeroAmount.selector); + eSIMWallet1.sendTokenToDeviceWallet(settlementToken, 0); + } + + function test_sendTokenToDeviceWallet_revertsOnTheZeroTokenAddress() public { + vm.prank(address(deviceWallet)); + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, "_token")); + eSIMWallet1.sendTokenToDeviceWallet(address(0), 1); + } + + /// @notice Any token can come back out, not only the ones the adapter knows about + function test_sendTokenToDeviceWallet_returnsATokenTheProtocolNeverPricedIn() public { + MockERC20 other = new MockERC20("Other", "OTH", 18); + other.mint(address(eSIMWallet1), 5 ether); + + vm.prank(address(deviceWallet)); + eSIMWallet1.sendTokenToDeviceWallet(address(other), 5 ether); + + assertEq(other.balanceOf(address(deviceWallet)), 5 ether, "The device wallet should have it"); + } +} diff --git a/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol b/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol new file mode 100644 index 00000000..a7e5761d --- /dev/null +++ b/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "forge-std/Test.sol"; + +import {Errors} from "contracts/Errors.sol"; +import {Asset} from "contracts/payments/PaymentAdapter.sol"; + +import {DeviceWalletFixture} from "test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; +import {MockFeeOnTransferERC20} from "test/utils/mocks/tokens/MockFeeOnTransferERC20.sol"; +import {MockNoReturnERC20} from "test/utils/mocks/tokens/MockNoReturnERC20.sol"; +import {MockReenteringERC20} from "test/utils/mocks/tokens/MockReenteringERC20.sol"; + +/// @notice `settle`, the only place in the protocol that moves a token to the vault. +/// @dev The caller has to be an eSIM wallet the registry knows, so these prank as one from the +/// shared fixture rather than deploying a stand-in. +contract PaymentAdapterSettleTest is DeviceWalletFixture { + + bytes32 constant ASSET_WETH = bytes32("WETH"); + bytes32 constant ASSET_NRT = bytes32("NRT"); + uint64 constant PRICE_CENTS = 1_250; // $12.50 + + event PaymentSettled( + bytes32 indexed _symbol, + address indexed _eSIMWallet, + address indexed _vault, + uint64 _priceUSDCents, + uint256 _spent, + uint256 _refunded + ); + + function setUp() public override { + super.setUp(); + deployWallets(); + } + + // --------------------------------------------------------------------------------------------- + // The settled path + // --------------------------------------------------------------------------------------------- + + function test_settle_paysTheVaultTheQuotedAmount() public { + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(paymentAdapter), needed); + + vm.prank(address(eSIMWallet1)); + (uint256 spent, uint256 refunded) = + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, needed, address(eSIMWallet1)); + + assertEq(spent, needed, "The whole amount should have been spent"); + assertEq(refunded, 0, "An exactly funded settlement refunds nothing"); + assertEq(settlementERC20.balanceOf(vault), needed, "The vault should hold the price"); + assertEq(settlementERC20.balanceOf(address(paymentAdapter)), 0, "Nothing should rest in the adapter"); + } + + function test_settle_refundsWhatThePriceDidNotNeed() public { + uint256 needed = settlementAmount(PRICE_CENTS); + uint256 funded = needed + 40e6; + fundSettlementToken(address(paymentAdapter), funded); + + vm.prank(address(eSIMWallet1)); + (uint256 spent, uint256 refunded) = + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, funded, address(eSIMWallet1)); + + assertEq(spent, needed, "Only the price should reach the vault"); + assertEq(refunded, funded - needed, "The rest should come back"); + assertEq(settlementERC20.balanceOf(vault), needed, "The vault gets the price and no more"); + assertEq(settlementERC20.balanceOf(address(eSIMWallet1)), funded - needed, "The refund goes where it was asked to"); + } + + function test_settle_emitsPaymentSettled() public { + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(paymentAdapter), needed); + + vm.expectEmit(true, true, true, true, address(paymentAdapter)); + emit PaymentSettled(ASSET_USDC, address(eSIMWallet1), vault, PRICE_CENTS, needed, 0); + + vm.prank(address(eSIMWallet1)); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, needed, address(eSIMWallet1)); + } + + /// @notice The vault is read on every call, so a rotation reaches the next payment + function test_settle_paysTheVaultCurrentlySet() public { + address newVault = makeAddr("newVault"); + vm.prank(registry.owner()); + registry.updateVaultAddress(newVault); + + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(paymentAdapter), needed); + + vm.prank(address(eSIMWallet1)); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, needed, address(eSIMWallet1)); + + assertEq(settlementERC20.balanceOf(newVault), needed, "The new vault should have been paid"); + assertEq(settlementERC20.balanceOf(vault), 0, "The old vault should get nothing"); + } + + /// @notice A cent price on a two decimal currency is one unit, the smallest a settlement can be + function test_settle_handlesTheSmallestPrice() public { + MockERC20 cents = new MockERC20("Cent Token", "CENT", 2); + _register(bytes32("CENT"), true, 2, address(cents)); + cents.mint(address(paymentAdapter), 1); + + vm.prank(address(eSIMWallet1)); + (uint256 spent,) = paymentAdapter.settle(bytes32("CENT"), 1, 1, address(eSIMWallet1)); + + assertEq(spent, 1, "One cent must settle as one unit"); + } + + /// @notice USDT returns nothing from `transfer`, which only SafeERC20 accepts + function test_settle_movesATokenThatReturnsNothing() public { + MockNoReturnERC20 token = new MockNoReturnERC20(); + _register(ASSET_NRT, true, 6, address(token)); + + uint256 needed = settlementAmount(PRICE_CENTS); + token.mint(address(paymentAdapter), needed); + + vm.prank(address(eSIMWallet1)); + paymentAdapter.settle(ASSET_NRT, PRICE_CENTS, needed, address(eSIMWallet1)); + + assertEq(token.balanceOf(vault), needed, "The vault should have been paid"); + } + + // --------------------------------------------------------------------------------------------- + // What settle refuses + // --------------------------------------------------------------------------------------------- + + function test_settle_revertsForACallerTheRegistryDoesNotKnow() public { + fundSettlementToken(address(paymentAdapter), settlementAmount(PRICE_CENTS)); + + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(Errors.NotAProtocolESIMWallet.selector, user1)); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, settlementAmount(PRICE_CENTS), user1); + } + + /// @notice A device wallet is not an eSIM wallet, so it cannot settle on one's behalf + function test_settle_revertsForADeviceWallet() public { + fundSettlementToken(address(paymentAdapter), settlementAmount(PRICE_CENTS)); + + vm.prank(address(deviceWallet)); + vm.expectRevert(abi.encodeWithSelector(Errors.NotAProtocolESIMWallet.selector, address(deviceWallet))); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, settlementAmount(PRICE_CENTS), address(deviceWallet)); + } + + function test_settle_revertsForAnUnregisteredSymbol() public { + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotTransferable.selector, bytes32("NOPE"))); + paymentAdapter.settle(bytes32("NOPE"), PRICE_CENTS, 1, address(eSIMWallet1)); + } + + function test_settle_revertsForAWithdrawnAsset() public { + vm.prank(upgradeManager); + paymentAdapter.updateAsset(ASSET_USDC, Asset({ + allowed: false, + isDollarUnit: true, + decimals: SETTLEMENT_DECIMALS, + token: settlementToken + })); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotAllowed.selector, ASSET_USDC)); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, 1, address(eSIMWallet1)); + } + + /// @notice A currency that is not already in dollars needs a rate, and there are no feeds here + function test_settle_revertsForAnAssetThatNeedsASwap() public { + MockERC20 weth = new MockERC20("Wrapped Ether", "WETH", 18); + _register(ASSET_WETH, false, 18, address(weth)); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNeedsSwap.selector, ASSET_WETH)); + paymentAdapter.settle(ASSET_WETH, PRICE_CENTS, 1 ether, address(eSIMWallet1)); + } + + /// @notice Fiat is recorded, never transferred, so it has no token address to move + function test_settle_revertsForFiat() public { + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.AssetNotTransferable.selector, ASSET_USD)); + paymentAdapter.settle(ASSET_USD, PRICE_CENTS, 1, address(eSIMWallet1)); + } + + function test_settle_revertsOnAZeroPrice() public { + vm.prank(address(eSIMWallet1)); + vm.expectRevert(Errors.ZeroDataBundlePrice.selector); + paymentAdapter.settle(ASSET_USDC, 0, 1, address(eSIMWallet1)); + } + + function test_settle_revertsOnAZeroRefundAddress() public { + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.ZeroAddress.selector, "_refundTo")); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, 1, address(0)); + } + + function test_settle_revertsWhenThePriceIsAboveWhatTheCallerWillSpend() public { + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(paymentAdapter), needed); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.SettlementAboveMax.selector, needed, needed - 1)); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, needed - 1, address(eSIMWallet1)); + } + + function test_settle_revertsWhenTheCallerHasNotFundedIt() public { + uint256 needed = settlementAmount(PRICE_CENTS); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.SettlementNotFunded.selector, needed, 0)); + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, needed, address(eSIMWallet1)); + } + + /// @notice A fee-on-transfer token delivers less than was sent, so it can never fund a settlement + function test_settle_revertsForAFeeOnTransferToken() public { + MockFeeOnTransferERC20 token = new MockFeeOnTransferERC20("Fee Token", "FEE", 6, 100); + _register(bytes32("FEE"), true, 6, address(token)); + + uint256 needed = settlementAmount(PRICE_CENTS); + token.mint(address(eSIMWallet1), needed); + + vm.prank(address(eSIMWallet1)); + token.transfer(address(paymentAdapter), needed); + + uint256 arrived = token.balanceOf(address(paymentAdapter)); + assertLt(arrived, needed, "The fee should have eaten part of the transfer"); + + vm.prank(address(eSIMWallet1)); + vm.expectRevert(abi.encodeWithSelector(Errors.SettlementNotFunded.selector, needed, arrived)); + paymentAdapter.settle(bytes32("FEE"), PRICE_CENTS, needed, address(eSIMWallet1)); + } + + // --------------------------------------------------------------------------------------------- + // What settle leaves alone + // --------------------------------------------------------------------------------------------- + + /// @notice A token sent here by mistake belongs to nobody, and a purchase must not carry it off + function test_settle_leavesATokenSentHereByMistake() public { + uint256 donation = 500e6; + fundSettlementToken(address(paymentAdapter), donation); + + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(paymentAdapter), needed); + + vm.prank(address(eSIMWallet1)); + (uint256 spent, uint256 refunded) = + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, needed, address(eSIMWallet1)); + + assertEq(spent, needed, "Only the price should move to the vault"); + assertEq(refunded, 0, "The donation is not a refund"); + assertEq(settlementERC20.balanceOf(address(paymentAdapter)), donation, "The donation must still be here"); + } + + /// @notice A token calling back mid-transfer arrives as itself, and the registry has no record + /// of it, so the caller gate refuses it before the reentrancy guard has to + function test_settle_refusesAReentrantCallFromTheToken() public { + MockReenteringERC20 token = new MockReenteringERC20("Callback Token", "CB", 6); + _register(bytes32("CB"), true, 6, address(token)); + + uint256 needed = settlementAmount(PRICE_CENTS); + token.mint(address(paymentAdapter), needed); + token.setReentry( + address(paymentAdapter), + abi.encodeCall(paymentAdapter.settle, (bytes32("CB"), PRICE_CENTS, needed, address(eSIMWallet1))) + ); + + vm.prank(address(eSIMWallet1)); + paymentAdapter.settle(bytes32("CB"), PRICE_CENTS, needed, address(eSIMWallet1)); + + assertTrue(token.reentered(), "The token should have tried to call back"); + assertTrue(token.reentryReverted(), "The call back should have been refused"); + assertEq(token.balanceOf(vault), needed, "The vault should have been paid once"); + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + function _register(bytes32 _symbol, bool _isDollarUnit, uint8 _decimals, address _token) private { + vm.prank(upgradeManager); + paymentAdapter.registerAsset(_symbol, Asset({ + allowed: true, + isDollarUnit: _isDollarUnit, + decimals: _decimals, + token: _token + })); + } +} From b2bf754daeaded1c327038e1d8f04e46f23b2e66 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:42:42 +0530 Subject: [PATCH 27/75] Drive the token path through the invariant campaign Adds two accounting invariants: the adapter holds nothing between calls, and the vault holds exactly what the purchases came to. --- .../HandlerDistribution.t.sol | 9 ++- .../invariant-testing/PaymentInvariants.t.sol | 23 +++++++ .../invariant-testing/base/CampaignBase.sol | 2 +- .../invariant-testing/base/InvariantBase.sol | 14 ++-- .../handler/PaymentHandler.sol | 66 ++++++++++++++++++- 5 files changed, 104 insertions(+), 10 deletions(-) diff --git a/test/foundry/invariant-testing/HandlerDistribution.t.sol b/test/foundry/invariant-testing/HandlerDistribution.t.sol index a5d35fb8..e8477d16 100644 --- a/test/foundry/invariant-testing/HandlerDistribution.t.sol +++ b/test/foundry/invariant-testing/HandlerDistribution.t.sol @@ -35,8 +35,11 @@ contract HandlerDistributionTest is CampaignBase { /// @notice Offset placing switch destinations outside the identifiers the lazy path populates uint256 internal constant SWITCH_SEEDS = 100_000; - /// @notice Offset separating the settled path's payment references from the ETH path's - uint256 internal constant PAYMENT_REFERENCE_SEEDS = 64; + /// @notice Width of the reference block each payment path draws from + /// @dev Three paths, one block each, laid out end to end. The handler wraps its seeds into a + /// pool of 128, so the three blocks together have to stay inside that or one path + /// re-presents a reference another already spent. + uint256 internal constant PAYMENT_REFERENCE_SEEDS = DRIVE_ROUNDS; /// @notice Every entry point can reach the protocol and change its state function test_handlersReachEveryEntryPoint() public { @@ -82,6 +85,7 @@ contract HandlerDistributionTest is CampaignBase { // which is a revert rather than the count this drive is checking paymentHandler.buyDataBundle(round, 100, 1 gwei, round); paymentHandler.recordSettledPurchase(round, 100, round + PAYMENT_REFERENCE_SEEDS, 0, false); + paymentHandler.buyDataBundleWithToken(round, 100, 10e6, round + 2 * PAYMENT_REFERENCE_SEEDS); paymentHandler.quote(0, 100); // Withdrawn and put back inside the round, so the next round starts from the same // currency table this one did @@ -145,6 +149,7 @@ contract HandlerDistributionTest is CampaignBase { _assertExercised("setESIMIdentifier"); _assertExercised("toggleAccessToFunds"); _assertExercised("buyDataBundle"); + _assertExercised("buyDataBundleWithToken"); _assertExercised("recordSettledPurchase"); _assertExercised("quote"); _assertExercised("updateAsset"); diff --git a/test/foundry/invariant-testing/PaymentInvariants.t.sol b/test/foundry/invariant-testing/PaymentInvariants.t.sol index 83a1a628..9a2696b8 100644 --- a/test/foundry/invariant-testing/PaymentInvariants.t.sol +++ b/test/foundry/invariant-testing/PaymentInvariants.t.sol @@ -38,6 +38,29 @@ contract PaymentInvariantsTest is CampaignBase { } } + /// @notice The adapter holds no tokens between transactions + /// @dev Money passes through it in one call. A balance left behind would mean a settlement paid + /// the vault less than the buyer funded, with the difference sitting where the next + /// purchase could carry it off. + function invariant_theAdapterHoldsNoTokensAtRest() public view { + assertEq( + settlementERC20.balanceOf(address(paymentAdapter)), + 0, + "The payment adapter is holding tokens" + ); + } + + /// @notice The vault holds exactly what the purchases that went through were priced at + /// @dev The adapter converts the price itself, so this is the check that the figure it worked + /// out is the figure that arrived. Nothing else in the campaign sends the vault tokens. + function invariant_theVaultHoldsWhatWasSettled() public view { + assertEq( + settlementERC20.balanceOf(VAULT), + paymentHandler.ghost_settledToVault(), + "The vault balance does not match what the purchases came to" + ); + } + /// @notice A currency the table has withdrawn never returns a price /// @dev The withdrawal is what stops a purchase being recorded against a currency the protocol /// no longer accepts, so an answer that outlives it would leave the withdrawal meaning diff --git a/test/foundry/invariant-testing/base/CampaignBase.sol b/test/foundry/invariant-testing/base/CampaignBase.sol index 4d0f7515..cc734b9b 100644 --- a/test/foundry/invariant-testing/base/CampaignBase.sol +++ b/test/foundry/invariant-testing/base/CampaignBase.sol @@ -67,7 +67,7 @@ abstract contract CampaignBase is InvariantBase { symbols[0] = bytes32("USD"); symbols[1] = bytes32("USDC"); symbols[2] = bytes32("ETH"); - paymentHandler = new PaymentHandler(config, paymentAdapter, symbols); + paymentHandler = new PaymentHandler(config, paymentAdapter, settlementERC20, symbols); // The second implementations are built here rather than inside the handler so the beacon // swap starts from the same wallet logic the campaign deployed against diff --git a/test/foundry/invariant-testing/base/InvariantBase.sol b/test/foundry/invariant-testing/base/InvariantBase.sol index b04791f2..0b10357f 100644 --- a/test/foundry/invariant-testing/base/InvariantBase.sol +++ b/test/foundry/invariant-testing/base/InvariantBase.sol @@ -18,6 +18,7 @@ import "test/utils/mocks/MockRegistry.sol"; import "test/utils/mocks/MockLazyWalletRegistry.sol"; import "test/utils/mocks/MockDeviceWallet.sol"; import "test/utils/mocks/MockESIMWallet.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; /// @notice Deploys the whole protocol once for an invariant campaign to run against. /// @dev Deliberately not a subclass of `DeployerBase`. That base carries the fixture arrays every @@ -41,8 +42,10 @@ contract InvariantBase is Test { address internal constant ATTACKER = address(0xbADc0DE000000000000000000000000000000001); uint64 internal constant DEFAULT_PRICE_CAP_USD_CENTS = 100_000; // $1000 - /// @dev A stand-in for USDC. Nothing settles onchain yet, so it only has to be resolvable. - address internal constant SETTLEMENT_TOKEN = address(0x036CbD53842c5426634e7929541eC2318f3dCF7e); + /// @dev A stand-in for USDC, deployed rather than a fixed address because the campaign settles + /// real balances through it. + MockERC20 internal settlementERC20; + address internal settlementToken; /// @notice The address the admin role rotates onto, and back off /// @dev Carries a budget of its own. Every admin path reads the role out of the registry, so @@ -120,12 +123,15 @@ contract InvariantBase is Test { ); lazyWalletRegistry = MockLazyWalletRegistry(address(lazyWalletRegistryProxy)); + settlementERC20 = new MockERC20("USD Coin", "USDC", 6); + settlementToken = address(settlementERC20); + PaymentAdapter paymentAdapterImpl = new PaymentAdapter(); ERC1967Proxy paymentAdapterProxy = new ERC1967Proxy( address(paymentAdapterImpl), abi.encodeCall( paymentAdapterImpl.initialize, - (address(registry), SETTLEMENT_TOKEN, UPGRADE_MANAGER) + (address(registry), settlementToken, UPGRADE_MANAGER) ) ); paymentAdapter = PaymentAdapter(address(paymentAdapterProxy)); @@ -147,7 +153,7 @@ contract InvariantBase is Test { allowed: true, isDollarUnit: true, decimals: 6, - token: SETTLEMENT_TOKEN + token: settlementToken })); paymentAdapter.registerAsset("ETH", Asset({ allowed: true, diff --git a/test/foundry/invariant-testing/handler/PaymentHandler.sol b/test/foundry/invariant-testing/handler/PaymentHandler.sol index 0d158f72..5d481ed4 100644 --- a/test/foundry/invariant-testing/handler/PaymentHandler.sol +++ b/test/foundry/invariant-testing/handler/PaymentHandler.sol @@ -7,6 +7,7 @@ import {ESIMWallet} from "contracts/esim-wallet/ESIMWallet.sol"; import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; import {HandlerBase, HandlerConfig} from "test/foundry/invariant-testing/handler/HandlerBase.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; /// @notice Drives both payment paths and the currency table against one another. /// @dev The two paths spend payment references through the same adapter but reach it by different @@ -24,10 +25,20 @@ contract PaymentHandler is HandlerBase { /// catching here. At this size a sequence collides several times over. uint256 private constant REFERENCE_POOL = 128; + /// @notice Decimals of the currency the token path settles in + uint8 private constant SETTLEMENT_DECIMALS = 6; + + /// @notice Symbol the token path settles in + bytes32 private constant SETTLEMENT_SYMBOL = bytes32("USDC"); + PaymentAdapter internal immutable paymentAdapter; + MockERC20 internal immutable settlementERC20; bytes32[] internal symbols; + /// @notice What every settled purchase should have moved to the vault, added up + uint256 public ghost_settledToVault; + /// @notice How many times each reference has been spent by a call that went through mapping(bytes32 paymentReference => uint256 spends) public ghost_spendCount; @@ -40,10 +51,14 @@ contract PaymentHandler is HandlerBase { /// @notice Set when `quote` answered for a currency the table had already withdrawn bool public ghost_withdrawnCurrencyQuoted; - constructor(HandlerConfig memory config, PaymentAdapter _paymentAdapter, bytes32[] memory _symbols) - HandlerBase(config) - { + constructor( + HandlerConfig memory config, + PaymentAdapter _paymentAdapter, + MockERC20 _settlementERC20, + bytes32[] memory _symbols + ) HandlerBase(config) { paymentAdapter = _paymentAdapter; + settlementERC20 = _settlementERC20; symbols = _symbols; } @@ -123,6 +138,51 @@ contract PaymentHandler is HandlerBase { } } + /// @notice A wallet buys a data bundle with the settlement token + /// @dev The funding is minted to the eSIM wallet rather than to its device wallet, so whether + /// the call goes through turns on the reference rather than on an access flag another + /// handler happens to have toggled. + /// @param eSIMIndex Picks the wallet + /// @param priceUSDCents Price to charge, bound under whichever ceiling applies + /// @param fundingSeed How much of the price the wallet is given, so both funded and short cases run + /// @param referenceSeed Picks the payment reference from the pool + function buyDataBundleWithToken( + uint256 eSIMIndex, + uint64 priceUSDCents, + uint256 fundingSeed, + uint256 referenceSeed + ) external counted { + address wallet = _pickESIMWallet(eSIMIndex); + if (wallet == address(0)) { + state.recordRevert("buyDataBundleWithToken"); + return; + } + + bytes32 paymentReference = _reference(referenceSeed); + priceUSDCents = uint64(bound(priceUSDCents, 1, _effectiveCap(wallet))); + + uint256 needed = (uint256(priceUSDCents) * 10 ** SETTLEMENT_DECIMALS) / 100; + settlementERC20.mint(wallet, bound(fundingSeed, 0, needed * 2)); + + vm.prank(_currentAdmin()); + try ESIMWallet(payable(wallet)).buyDataBundleWithToken( + DataBundleDetails({ + id: "token", + priceUSDCents: priceUSDCents, + settlement: Settlement.Fiat + }), + SETTLEMENT_SYMBOL, + needed, + paymentReference + ) { + _recordSpend(paymentReference); + ghost_settledToVault += needed; + state.recordCall("buyDataBundleWithToken"); + } catch { + state.recordRevert("buyDataBundleWithToken"); + } + } + /// @notice The admin records a purchase paid for outside the protocol /// @param eSIMIndex Picks the wallet /// @param priceUSDCents Price to record, bound under whichever ceiling applies From 918e6668f6baa9a8cbed9f8956be2ec3df4569e6 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:44:02 +0530 Subject: [PATCH 28/75] Sweep settle across every price and currency --- .../fuzz-testing/TokenSettlement.t.sol | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 test/foundry/fuzz-testing/TokenSettlement.t.sol diff --git a/test/foundry/fuzz-testing/TokenSettlement.t.sol b/test/foundry/fuzz-testing/TokenSettlement.t.sol new file mode 100644 index 00000000..0ce72532 --- /dev/null +++ b/test/foundry/fuzz-testing/TokenSettlement.t.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.36; + +import "forge-std/Test.sol"; + +import {Errors} from "contracts/Errors.sol"; +import {Asset} from "contracts/payments/PaymentAdapter.sol"; + +import {FuzzBase} from "test/foundry/fuzz-testing/base/FuzzBase.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; + +/// @notice Sweeps `settle` across every price and every currency the table will hold. +/// @dev `quote` is swept on its own elsewhere. What this adds is the transfer: the amount the +/// adapter worked out has to be the amount that reaches the vault, for every price and every +/// decimals, with nothing left behind. +contract TokenSettlementTest is FuzzBase { + + /// @dev Matches MIN_ASSET_DECIMALS and MAX_ASSET_DECIMALS in the adapter, which are private there. + uint8 private constant MIN_DECIMALS = 2; + uint8 private constant MAX_DECIMALS = 36; + + bytes32 private constant FUZZED = bytes32("FUZZ"); + + MockERC20 private token; + + function setUp() public override { + super.setUp(); + _deployFuzzWallets(); + } + + /// @notice Registers the fuzzed currency against a token with matching decimals + function _register(uint8 _decimals) private returns (uint8 decimals) { + decimals = uint8(bound(uint256(_decimals), MIN_DECIMALS, MAX_DECIMALS)); + token = new MockERC20("Fuzz Token", "FUZZ", decimals); + + vm.prank(upgradeManager); + paymentAdapter.registerAsset(FUZZED, Asset({ + allowed: true, + isDollarUnit: true, + decimals: decimals, + token: address(token) + })); + } + + /// @notice The expected settlement for a price, worked out independently of the adapter + function _expected(uint64 _priceUSDCents, uint8 _decimals) private pure returns (uint256) { + return (uint256(_priceUSDCents) * 10 ** _decimals) / 100; + } + + // --------------------------------------------------------------------------------------------- + // What reaches the vault + // --------------------------------------------------------------------------------------------- + + /// @notice Any price in any currency moves the quoted amount and nothing else + function testFuzz_settle_movesTheQuotedAmount(uint64 _priceUSDCents, uint8 _decimals) public { + vm.assume(_priceUSDCents != 0); + uint8 decimals = _register(_decimals); + uint256 needed = _expected(_priceUSDCents, decimals); + + token.mint(address(paymentAdapter), needed); + + vm.prank(address(fuzzESIMWallet)); + (uint256 spent, uint256 refunded) = + paymentAdapter.settle(FUZZED, _priceUSDCents, needed, address(fuzzESIMWallet)); + + assertEq(spent, needed, "The spend must equal the quote"); + assertEq(refunded, 0, "An exactly funded settlement refunds nothing"); + assertEq(token.balanceOf(vault), needed, "The vault must hold the quoted amount"); + assertEq(token.balanceOf(address(paymentAdapter)), 0, "The adapter must be left empty"); + } + + /// @notice What settle spends is always what quote said it would + function testFuzz_settle_neverDisagreesWithQuote(uint64 _priceUSDCents, uint8 _decimals) public { + vm.assume(_priceUSDCents != 0); + uint8 decimals = _register(_decimals); + uint256 quoted = paymentAdapter.quote(FUZZED, _priceUSDCents); + + token.mint(address(paymentAdapter), quoted); + + vm.prank(address(fuzzESIMWallet)); + (uint256 spent,) = paymentAdapter.settle(FUZZED, _priceUSDCents, quoted, address(fuzzESIMWallet)); + + assertEq(spent, quoted, "settle and quote must agree on every price"); + assertEq(spent, _expected(_priceUSDCents, decimals), "Both must agree with the conversion"); + } + + /// @notice Funding above the price sends the excess back and pays the vault the same + function testFuzz_settle_refundsTheExcess(uint64 _priceUSDCents, uint8 _decimals, uint128 _extra) public { + vm.assume(_priceUSDCents != 0); + uint8 decimals = _register(_decimals); + uint256 needed = _expected(_priceUSDCents, decimals); + uint256 funded = needed + _extra; + + token.mint(address(paymentAdapter), funded); + + vm.prank(address(fuzzESIMWallet)); + (uint256 spent, uint256 refunded) = + paymentAdapter.settle(FUZZED, _priceUSDCents, funded, address(fuzzESIMWallet)); + + assertEq(spent, needed, "The vault takes the price whatever was funded"); + assertEq(refunded, _extra, "Everything above the price comes back"); + assertEq(token.balanceOf(vault), needed, "The vault must hold the price"); + assertEq(token.balanceOf(address(fuzzESIMWallet)), _extra, "The refund must reach the caller"); + } + + // --------------------------------------------------------------------------------------------- + // What never happens + // --------------------------------------------------------------------------------------------- + + /// @notice A settlement one unit short of its funding is refused rather than part paid + function testFuzz_settle_revertsWhenFundedShort(uint64 _priceUSDCents, uint8 _decimals) public { + uint8 decimals = _register(_decimals); + _priceUSDCents = uint64(bound(_priceUSDCents, 1, type(uint64).max)); + uint256 needed = _expected(_priceUSDCents, decimals); + vm.assume(needed != 0); + + token.mint(address(paymentAdapter), needed - 1); + + vm.prank(address(fuzzESIMWallet)); + vm.expectRevert(abi.encodeWithSelector(Errors.SettlementNotFunded.selector, needed, needed - 1)); + paymentAdapter.settle(FUZZED, _priceUSDCents, needed, address(fuzzESIMWallet)); + + assertEq(token.balanceOf(vault), 0, "A refused settlement pays nobody"); + } + + /// @notice A ceiling below the price is refused, whatever the currency + function testFuzz_settle_revertsAboveTheCeiling(uint64 _priceUSDCents, uint8 _decimals) public { + uint8 decimals = _register(_decimals); + _priceUSDCents = uint64(bound(_priceUSDCents, 1, type(uint64).max)); + uint256 needed = _expected(_priceUSDCents, decimals); + vm.assume(needed != 0); + + token.mint(address(paymentAdapter), needed); + + vm.prank(address(fuzzESIMWallet)); + vm.expectRevert(abi.encodeWithSelector(Errors.SettlementAboveMax.selector, needed, needed - 1)); + paymentAdapter.settle(FUZZED, _priceUSDCents, needed - 1, address(fuzzESIMWallet)); + } + + /// @notice A token sent to the adapter is never carried off by a later settlement + function testFuzz_settle_leavesADonationBehind(uint64 _priceUSDCents, uint8 _decimals, uint128 _donation) public { + vm.assume(_priceUSDCents != 0); + uint8 decimals = _register(_decimals); + uint256 needed = _expected(_priceUSDCents, decimals); + + token.mint(address(paymentAdapter), uint256(_donation) + needed); + + vm.prank(address(fuzzESIMWallet)); + paymentAdapter.settle(FUZZED, _priceUSDCents, needed, address(fuzzESIMWallet)); + + assertEq(token.balanceOf(address(paymentAdapter)), _donation, "The donation must still be there"); + assertEq(token.balanceOf(vault), needed, "The vault takes the price and no more"); + } + + /// @notice Nobody outside the registry's eSIM wallets can settle, at any price + function testFuzz_settle_revertsForAnyOtherCaller(address _caller, uint64 _priceUSDCents) public { + vm.assume(_caller != address(fuzzESIMWallet)); + vm.assume(registry.isESIMWalletValid(_caller) == address(0)); + uint8 decimals = _register(6); + uint256 needed = _expected(_priceUSDCents, decimals); + + token.mint(address(paymentAdapter), needed); + + vm.prank(_caller); + vm.expectRevert(abi.encodeWithSelector(Errors.NotAProtocolESIMWallet.selector, _caller)); + paymentAdapter.settle(FUZZED, _priceUSDCents, needed, _caller); + } + + // --------------------------------------------------------------------------------------------- + // The wallet path on top of it + // --------------------------------------------------------------------------------------------- + + /// @notice Any price under the ceiling leaves the vault paid and the wallet empty + function testFuzz_buyDataBundleWithToken_paysTheVaultAndKeepsNothing(uint64 _priceUSDCents) public { + _priceUSDCents = uint64(bound(_priceUSDCents, 1, defaultPriceCapUSDCents)); + uint256 needed = settlementAmount(_priceUSDCents); + fundSettlementToken(address(fuzzDeviceWallet), needed); + + vm.prank(eSIMWalletAdmin); + fuzzESIMWallet.buyDataBundleWithToken(bundle("DB_FUZZ", _priceUSDCents), ASSET_USDC, needed, nextRef()); + + assertEq(settlementERC20.balanceOf(vault), needed, "The vault must hold the price"); + assertEq(settlementERC20.balanceOf(address(fuzzESIMWallet)), 0, "The eSIM wallet must keep nothing"); + assertEq(settlementERC20.balanceOf(address(fuzzDeviceWallet)), 0, "The whole amount came from the device wallet"); + } + + /// @notice A ceiling the price fits under never changes what gets spent + function testFuzz_buyDataBundleWithToken_spendsTheQuoteNotTheCeiling(uint64 _priceUSDCents, uint128 _headroom) public { + _priceUSDCents = uint64(bound(_priceUSDCents, 1, defaultPriceCapUSDCents)); + uint256 needed = settlementAmount(_priceUSDCents); + fundSettlementToken(address(fuzzDeviceWallet), needed); + + vm.prank(eSIMWalletAdmin); + fuzzESIMWallet.buyDataBundleWithToken( + bundle("DB_FUZZ", _priceUSDCents), + ASSET_USDC, + needed + _headroom, + nextRef() + ); + + assertEq(settlementERC20.balanceOf(vault), needed, "Only the quote reaches the vault"); + assertEq(settlementERC20.balanceOf(address(fuzzDeviceWallet)), 0, "Only the quote was pulled"); + } +} From 1575bdcb827bbccb5ed8afc9fc264234f85c26bf Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Fri, 28 Aug 2026 23:47:50 +0530 Subject: [PATCH 29/75] Measure the token path and refresh the baselines Also warms the vault before the buyDataBundle pair, so the funded and unfunded figures no longer differ by a cold balance slot. --- .gas-snapshot | 637 ++++++++++-------- snapshots/DeviceWallet.Operations.json | 2 + snapshots/ESIMWallet.Operations.json | 5 +- snapshots/PaymentAdapter.Operations.json | 3 + storage-layouts/DeviceWallet.json | 2 +- .../gas/DeviceWallet.Operations.gas.t.sol | 19 + .../gas/ESIMWallet.Operations.gas.t.sol | 42 ++ .../gas/PaymentAdapter.Operations.gas.t.sol | 25 + 8 files changed, 445 insertions(+), 290 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 45e153e4..599ec5f8 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,27 +1,27 @@ -Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 971095) -Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926463) -Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 931857) -Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1021012) -Account4337Test:test_executeBatch_runsEveryCall() (gas: 1003894) -Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977837) -Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 931148) -Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 929230) -Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924334) -Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924694) -Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 963201) -Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 928870) -Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 928964) -Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007721) -Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966658) -Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970342) -Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964446) +Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 971257) +Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926625) +Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 932019) +Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1021174) +Account4337Test:test_executeBatch_runsEveryCall() (gas: 1004056) +Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977999) +Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 931310) +Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 929392) +Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924474) +Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924834) +Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 963363) +Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 929032) +Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 929126) +Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007861) +Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966798) +Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970482) +Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964586) AdminRotationTest:test_acceptAdminUpdate() (gas: 47187) AdminRotationTest:test_acceptAdminUpdate_afterRevoke() (gas: 48918) AdminRotationTest:test_acceptAdminUpdate_clearsASuspension() (gas: 49065) AdminRotationTest:test_acceptAdminUpdate_currentAdmin() (gas: 42653) AdminRotationTest:test_acceptAdminUpdate_reachesEveryReader() (gas: 68118) AdminRotationTest:test_acceptAdminUpdate_withoutRequest() (gas: 19779) -AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175401) +AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175583) AdminRotationTest:test_disableAdmin_endsThePauseLoopAgainstACompromisedKey() (gas: 64565) AdminRotationTest:test_disableAdmin_rejectsARepeat() (gas: 29532) AdminRotationTest:test_disableAdmin_rejectsAnyoneButTheOwner() (gas: 38707) @@ -34,7 +34,7 @@ AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentLiftsASuspension() ( AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 285017) AdminRotationTest:test_requestAdminUpdate_rejectsAnyoneButTheOwner() (gas: 45957) AdminRotationTest:test_requestAdminUpdate_revoke() (gas: 43550) -AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1221763) +AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1221945) AdminRotationTest:test_requestAdminUpdate_zeroAddress() (gas: 19658) AdminStorageLayoutTest:test_layout_aDoneOperationNeverReadsReady() (gas: 8064) AdminStorageLayoutTest:test_layout_accessControlRolesReadSlotZero() (gas: 16215) @@ -44,14 +44,14 @@ AdminStorageLayoutTest:test_layout_readinessComesOnlyFromTheTimestamp() (gas: 90 AdminStorageLayoutTest:test_layout_theContractAddsNoStorageOfItsOwn() (gas: 7864) AdminStorageLayoutTest:test_layout_theDelayFloorIsNotInStorage() (gas: 8697) AdminStorageLayoutTest:test_layout_theGuardianRoleLivesInTheSameMappingAsTheRest() (gas: 17469) -AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2248578) +AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2807468) AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 4252888) -AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3466902) -AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2320196) +AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3615351) +AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2879086) AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12932702) AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3658751) AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 12981881) -AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5233849) +AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5940751) AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 4286072) AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 4225962) AdminUpgradesTest:test_upgrade_rejectsAProposerActingDirectly() (gas: 4225984) @@ -64,95 +64,95 @@ Deployer:test_lazyWalletRegistry_setUp() (gas: 21019) Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45819) Deployer:test_registry_ownable2step() (gas: 47346) Deployer:test_registry_setUp() (gas: 54915) -DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3711117) -DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3392780) -DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3448920) -DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3394294) -DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3398523) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3430647) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3400141) -DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3387504) -DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3403184) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3470248) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3393269) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPullETH() (gas: 3688808) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3389122) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3394275) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3456798) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3395775) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3417574) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3402590) -DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3710525) -DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoETHAccess() (gas: 1270390) -DeviceWalletETHTest:test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() (gas: 3392825) -DeviceWalletETHTest:test_deployESIMWallet_cannotGrantETHAccess() (gas: 3684115) -DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3401562) -DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3437177) -DeviceWalletETHTest:test_getVaultAddress() (gas: 3395051) -DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3417665) -DeviceWalletETHTest:test_pullETH() (gas: 3404294) -DeviceWalletETHTest:test_pullETH_revertsWhilePaused() (gas: 3420902) -DeviceWalletETHTest:test_pullETH_revokedESIMWallet() (gas: 3394634) -DeviceWalletETHTest:test_pullETH_unauthorise() (gas: 3396332) -DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4112993) -DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 3970607) -DeviceWalletETHTest:test_toggleAccessToETH_grant_deviceWalletHasETH() (gas: 3617633) -DeviceWalletETHTest:test_toggleAccessToETH_grant_userHasETH() (gas: 3611278) -DeviceWalletETHTest:test_toggleAccessToETH_revoke_deviceWalletHasETH() (gas: 3456080) -DeviceWalletETHTest:test_toggleAccessToETH_revoke_eSIMWalletHasETH() (gas: 3563440) -DeviceWalletETHTest:test_toggleAccessToETH_revoke_userHasETH() (gas: 3571324) -DeviceWalletETHTest:test_toggleAccessToETH_unauthorised() (gas: 3395764) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4550894) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936747) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960779) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977425) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931797) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930315) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946334) +DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3713118) +DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3394163) +DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3450254) +DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395677) +DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399950) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3432091) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3401414) +DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388865) +DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3404523) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471656) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3394608) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPullETH() (gas: 3690052) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390593) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395658) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3458269) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3397180) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3419040) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403885) +DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3712526) +DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoETHAccess() (gas: 1270437) +DeviceWalletETHTest:test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() (gas: 3394208) +DeviceWalletETHTest:test_deployESIMWallet_cannotGrantETHAccess() (gas: 3685515) +DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402945) +DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3438538) +DeviceWalletETHTest:test_getVaultAddress() (gas: 3396434) +DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3419136) +DeviceWalletETHTest:test_pullETH() (gas: 3405723) +DeviceWalletETHTest:test_pullETH_revertsWhilePaused() (gas: 3422355) +DeviceWalletETHTest:test_pullETH_revokedESIMWallet() (gas: 3396041) +DeviceWalletETHTest:test_pullETH_unauthorise() (gas: 3397730) +DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4114732) +DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 3972432) +DeviceWalletETHTest:test_toggleAccessToFunds_grant_deviceWalletHasETH() (gas: 3619331) +DeviceWalletETHTest:test_toggleAccessToFunds_grant_userHasETH() (gas: 3612733) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_deviceWalletHasETH() (gas: 3457609) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_eSIMWalletHasETH() (gas: 3564961) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_userHasETH() (gas: 3572449) +DeviceWalletETHTest:test_toggleAccessToFunds_unauthorised() (gas: 3397213) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4552254) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936931) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960919) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977565) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931937) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930455) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946474) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_withoutAdminOrRegistry() (gas: 76765) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959612) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959752) DeviceWalletFactoryConfigTest:test_addRegistryAddress_onlyOnce() (gas: 22234) DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutAdmin() (gas: 37790) DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutOwner() (gas: 21191) -DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3845919) +DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3994533) DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation_admin() (gas: 36155) -DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 565934) -DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller() (gas: 382309) -DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller_cannotCallPostCreateAccount() (gas: 363001) -DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579551) -DeviceWalletFactoryCreateAccountTest:test_createAccount_forwardsValueToAnAlreadyDeployedWallet() (gas: 391118) -DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369820) -DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351677) -DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928150) -DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885323) -DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886786) +DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 566022) +DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller() (gas: 382397) +DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller_cannotCallPostCreateAccount() (gas: 363023) +DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579639) +DeviceWalletFactoryCreateAccountTest:test_createAccount_forwardsValueToAnAlreadyDeployedWallet() (gas: 391140) +DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369842) +DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351699) +DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928290) +DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885367) +DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886830) DeviceWalletFactoryGuardsTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 3556215) DeviceWalletFactoryGuardsTest:test_createAccount_rejectsAnEmptyDeviceIdentifier() (gas: 19711) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsADepositAboveTheETHSent() (gas: 53937) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938152) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936076) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938292) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936216) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortDepositArray() (gas: 58027) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortKeyArray() (gas: 52070) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortSaltArray() (gas: 57168) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyBatch() (gas: 35714) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyDeviceIdentifier() (gas: 57816) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975785) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975925) DeviceWalletFactoryGuardsTest:test_eSIMWalletAdmin_isEmptyUntilTheRegistryIsAdded() (gas: 3565235) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroESIMWalletFactory() (gas: 3141656) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 3141332) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3142144) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroVerifier() (gas: 3141855) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539381) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412189) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 917962) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539403) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412211) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 918102) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressThatIsNotADeviceWallet() (gas: 88221) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressWithNoCode() (gas: 75397) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnEmptyDeviceIdentifier() (gas: 42131) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnIdentifierTheWalletDoesNotCarry() (gas: 408236) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnOffCurveOwnerKey() (gas: 368123) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsTheWrongSalt() (gas: 391816) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914834) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 917975) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnIdentifierTheWalletDoesNotCarry() (gas: 408258) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnOffCurveOwnerKey() (gas: 368145) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsTheWrongSalt() (gas: 391838) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914974) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 918115) DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheCurrentImplementation() (gas: 31777) DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheZeroAddress() (gas: 19621) DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnOffCurveOwnerKey() (gas: 41069) @@ -162,93 +162,135 @@ DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnOffCur DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOwnerKey() (gas: 83875) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnOffCurveOwnerKey() (gas: 19826) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnZeroOwnerKey() (gas: 19342) -DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961267) -DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954081) -DeviceWalletGuardsTest:test_init_rejectsAZeroESIMWalletFactory() (gas: 117215) -DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117810) -DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115147) -DeviceWalletGuardsTest:test_pullETH_rejectsAZeroAmount() (gas: 954615) -DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972275) -DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963377) -DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957004) -DeviceWalletGuardsTest:test_toggleAccessToETH_rejectsAnUnknownESIMWallet() (gas: 961325) -DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3411126) -DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4421847) -DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3426970) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3401337) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3412141) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3401340) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3401083) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3401408) -DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128595) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458615) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149253) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1131838) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120249) -DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3393931) +DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961429) +DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954265) +DeviceWalletGuardsTest:test_init_rejectsAZeroESIMWalletFactory() (gas: 117237) +DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117854) +DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115169) +DeviceWalletGuardsTest:test_pullETH_rejectsAZeroAmount() (gas: 954823) +DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972481) +DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963583) +DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957188) +DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960981) +DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3412531) +DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4423678) +DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3428397) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3402703) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3413568) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402746) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3402489) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402814) +DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1129021) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1459063) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149679) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132264) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120675) +DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3395336) DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion() (gas: 30674) +DeviceWalletTokensTest:test_pullToken_emitsTokenSent() (gas: 77501) +DeviceWalletTokensTest:test_pullToken_movesATokenThatReturnsNothing() (gas: 327533) +DeviceWalletTokensTest:test_pullToken_movesTheAmountToTheCallingWallet() (gas: 79146) +DeviceWalletTokensTest:test_pullToken_revertsForACallerThatIsNotAnAssociatedWallet() (gas: 24150) +DeviceWalletTokensTest:test_pullToken_revertsForATokenTheWalletDoesNotHold() (gas: 653145) +DeviceWalletTokensTest:test_pullToken_revertsForAWalletWithoutAccess() (gas: 44181) +DeviceWalletTokensTest:test_pullToken_revertsForAnotherDevicesWallet() (gas: 23858) +DeviceWalletTokensTest:test_pullToken_revertsOnTheZeroTokenAddress() (gas: 39574) +DeviceWalletTokensTest:test_pullToken_revertsOnZeroAmount() (gas: 41332) +DeviceWalletTokensTest:test_pullToken_revertsWhenTheBalanceIsShort() (gas: 49128) +DeviceWalletTokensTest:test_pullToken_revertsWhileTheProtocolIsPaused() (gas: 53608) +DeviceWalletTokensTest:test_pullToken_stopsOnceAccessIsRevoked() (gas: 86396) ESIMWalletFactoryTest:test_addRegistryAddress_onlyOnce() (gas: 25701) ESIMWalletFactoryTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 2412279) ESIMWalletFactoryTest:test_addRegistryAddress_withoutOwner() (gas: 21210) -ESIMWalletFactoryTest:test_deployESIMWallet() (gas: 343525) -ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837687) -ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324772) -ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() (gas: 331706) -ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617193) +ESIMWalletFactoryTest:test_deployESIMWallet() (gas: 343709) +ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837783) +ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324868) +ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() (gas: 331736) +ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617341) ESIMWalletFactoryTest:test_deployESIMWallet_unauthorised() (gas: 35389) ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065366) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3260027) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3819513) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_rejectsTheZeroAddress() (gas: 18268) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_unauthorised() (gas: 21012) -ESIMWalletGuardsTest:test_buyDataBundle_rejectsAZeroPrice() (gas: 1007430) -ESIMWalletGuardsTest:test_buyDataBundle_rejectsAnEmptyDataBundleID() (gas: 1007556) -ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105686) -ESIMWalletGuardsTest:test_initialize_rejectsAZeroFactory() (gas: 104808) -ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979398) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 977902) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039184) -ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1203193) -ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2396173) -ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2460668) -ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2429672) -ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2400963) -ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411607) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395731) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397479) -ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510146) -ESIMWalletTest:test_buyDataBundle_allFundsFromUser() (gas: 1705669) -ESIMWalletTest:test_buyDataBundle_followsTheRotatedAdmin() (gas: 1727975) -ESIMWalletTest:test_buyDataBundle_noFundsFromESIMWallet() (gas: 1767754) -ESIMWalletTest:test_buyDataBundle_partialFundsFromESIMWallet() (gas: 1768844) -ESIMWalletTest:test_buyDataBundle_partialFundsFromUserAndESIMWallet() (gas: 1781678) -ESIMWalletTest:test_buyDataBundle_recordsTheHistoryBeforeTheVaultIsPaid() (gas: 1865665) -ESIMWalletTest:test_buyDataBundle_rejectsAPriceAboveTheRegistryDefault() (gas: 1548747) -ESIMWalletTest:test_buyDataBundle_revertsAboveTheRegistryDefault() (gas: 1559115) -ESIMWalletTest:test_buyDataBundle_revertsAboveTheWalletCap() (gas: 1568468) -ESIMWalletTest:test_buyDataBundle_revertsWhilePaused() (gas: 1728893) -ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2462333) -ESIMWalletTest:test_buyDataBundle_theWalletCapOverridesTheRegistryDefault() (gas: 1747293) -ESIMWalletTest:test_owner() (gas: 1505411) -ESIMWalletTest:test_populateHistory() (gas: 1795240) -ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906487) -ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844040) -ESIMWalletTest:test_requestTransferOwnership() (gas: 2404864) -ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425082) -ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3244889) -ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263568) -ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423234) -ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434550) -ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1516923) -ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510309) -ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517097) -ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2476129) -ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508279) -ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533199) -ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511038) -ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508005) -ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510319) -ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1510867) -ESIMWalletTest:test_transferOwnership() (gas: 1508171) +ESIMWalletGuardsTest:test_buyDataBundle_rejectsAZeroPrice() (gas: 1007771) +ESIMWalletGuardsTest:test_buyDataBundle_rejectsAnEmptyDataBundleID() (gas: 1007885) +ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105710) +ESIMWalletGuardsTest:test_initialize_rejectsAZeroFactory() (gas: 104832) +ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979670) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 978130) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039412) +ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1203959) +ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2397213) +ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2461818) +ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2430906) +ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2402025) +ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2412625) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2396749) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2398497) +ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510809) +ESIMWalletTest:test_buyDataBundle_allFundsFromUser() (gas: 1706580) +ESIMWalletTest:test_buyDataBundle_followsTheRotatedAdmin() (gas: 1729013) +ESIMWalletTest:test_buyDataBundle_noFundsFromESIMWallet() (gas: 1769032) +ESIMWalletTest:test_buyDataBundle_partialFundsFromESIMWallet() (gas: 1770122) +ESIMWalletTest:test_buyDataBundle_partialFundsFromUserAndESIMWallet() (gas: 1782956) +ESIMWalletTest:test_buyDataBundle_recordsTheHistoryBeforeTheVaultIsPaid() (gas: 1866687) +ESIMWalletTest:test_buyDataBundle_rejectsAPriceAboveTheRegistryDefault() (gas: 1549545) +ESIMWalletTest:test_buyDataBundle_revertsAboveTheRegistryDefault() (gas: 1559913) +ESIMWalletTest:test_buyDataBundle_revertsAboveTheWalletCap() (gas: 1569244) +ESIMWalletTest:test_buyDataBundle_revertsWhilePaused() (gas: 1730002) +ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2463552) +ESIMWalletTest:test_buyDataBundle_theWalletCapOverridesTheRegistryDefault() (gas: 1748450) +ESIMWalletTest:test_owner() (gas: 1506052) +ESIMWalletTest:test_populateHistory() (gas: 1795962) +ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1907281) +ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844809) +ESIMWalletTest:test_requestTransferOwnership() (gas: 2405816) +ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2426161) +ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3246152) +ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3264809) +ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2424402) +ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2435630) +ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517606) +ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510972) +ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517738) +ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2477323) +ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508920) +ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533862) +ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511701) +ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508646) +ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510960) +ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511552) +ESIMWalletTest:test_transferOwnership() (gas: 1508834) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 276492) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 263271) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 273024) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 270004) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 284848) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 276849) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 270253) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() (gas: 338239) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedOnTheETHPath() (gas: 288327) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1489275) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86949) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 105182) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 146606) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 70602) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 128970) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 287670) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79787) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79398) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 152308) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 125105) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 130692) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88591) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 265777) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 267536) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() (gas: 53620) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsATokenTheProtocolNeverPricedIn() (gas: 687143) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsTheBalance() (gas: 53854) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsForAnyoneElse() (gas: 58165) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnTheZeroTokenAddress() (gas: 22369) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnZeroAmount() (gas: 24016) GuardianPowersTest:test_disableAdminInstantly_cannotChooseAReplacement() (gas: 36672) GuardianPowersTest:test_disableAdminInstantly_cannotReinstate() (gas: 95421) GuardianPowersTest:test_disableAdminInstantly_emitsTheSuspension() (gas: 39312) @@ -256,7 +298,7 @@ GuardianPowersTest:test_disableAdminInstantly_endsThePauseLoop() (gas: 64087) GuardianPowersTest:test_disableAdminInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 35593) GuardianPowersTest:test_disableAdminInstantly_suspendsWithoutWaiting() (gas: 43602) GuardianPowersTest:test_disableAndNominate_rejectsEveryCallerButTheTimelock() (gas: 34230) -GuardianPowersTest:test_disableAndNominate_stripsAndNominatesOnceTheDelayIsServed() (gas: 101897) +GuardianPowersTest:test_disableAndNominate_stripsAndNominatesOnceTheDelayIsServed() (gas: 101900) GuardianPowersTest:test_execute_closesToOutsidersOnceOpenExecutionIsRevoked() (gas: 110078) GuardianPowersTest:test_guardian_canStillExecuteOnceOpenExecutionIsClosed() (gas: 134114) GuardianPowersTest:test_guardian_cannotCancel() (gas: 55808) @@ -281,26 +323,26 @@ GuardianPowersTest:test_unpauseInstantly_rejectsAnAccountWithoutTheGuardianRole( GuardianPowersTest:test_unpauseInstantly_rejectsTheProposer() (gas: 17244) GuardianPowersTest:test_unpauseInstantly_releasesAPauseWithoutWaiting() (gas: 42192) GuardianPowersTest:test_unpauseInstantly_revertsOnATargetWithNoCode() (gas: 20187) -IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1943419) -IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1105682) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2594911) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928639) -IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 987791) +IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1943988) +IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1105910) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2595331) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928779) +IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 988041) IdentifierCollisionTest:test_assignESIMIdentifier_rejectsAWalletTheProtocolDoesNotKnow() (gas: 33971) -IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1225034) -IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008424) -IdentifierCollisionTest:test_postCreateAccount_cannotClaimAReservedDeviceIdentifier() (gas: 562941) -IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1228858) -IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1829694) +IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1225281) +IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008652) +IdentifierCollisionTest:test_postCreateAccount_cannotClaimAReservedDeviceIdentifier() (gas: 562963) +IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1229130) +IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1830128) ImplementationLocksTest:test_DeviceWalletFactory_implementationCannotBeInitialized() (gas: 3065558) -ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3333988) +ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3482510) ImplementationLocksTest:test_ESIMWalletFactory_implementationCannotBeInitialized() (gas: 1991966) -ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2134274) +ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2702220) ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3591688) ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 4224587) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2177908) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168001) -LazySaltCollisionTest:test_getCounterFactualAddress_matchesTheDeployedAddress() (gas: 325465) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2178557) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168606) +LazySaltCollisionTest:test_getCounterFactualAddress_matchesTheDeployedAddress() (gas: 325495) LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortDataBundleArray() (gas: 35457) LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortESIMIdentifierArray() (gas: 34648) LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyDeviceIdentifier() (gas: 35399) @@ -322,52 +364,52 @@ LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_reve LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3229497) LazyWalletRegistryTest:test_batchPopulateHistory_acceptsAnIdentifierAtTheLimit() (gas: 204715) LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4422448) -LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6267984) +LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6269579) LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2943885) LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3287905) LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3321324) -LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1607201) +LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1607451) LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongDeviceIdentifier() (gas: 46482) LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongESIMIdentifier() (gas: 60738) LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 235233) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6230035) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1673772) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6094296) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2203446) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27255623) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12138157) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6061837) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6231630) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1674044) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6095458) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2203792) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27266895) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12142739) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6062999) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutAdmin() (gas: 44658) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutESIMIdentifier() (gas: 51638) -LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6077115) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3530501) +LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6078277) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3531663) LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_refusesADeviceWithNoFirstBatch() (gas: 461277) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2373117) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6231634) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2373823) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6233229) LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3234439) LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4427566) LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_unregisteredIdentfier() (gas: 17775) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2032590) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2519509) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1346260) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1347971) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2033015) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2520087) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1346510) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1348221) LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnESIMItNeverDeployed() (gas: 329181) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1346083) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1551255) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6764343) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2771610) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2046927) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1346333) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1551580) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6764809) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2772343) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2047277) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3333481) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1602282) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6255567) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6252650) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1602532) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6257162) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6254245) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_unregistered() (gas: 37057) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_withoutAdmin() (gas: 32465) -NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157722) -NemesisIdentifierSquatPoC:test_NM007_extraESIMWalletsGiveNoRouteIn() (gas: 361543) -NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143142) +NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157854) +NemesisIdentifierSquatPoC:test_NM007_extraESIMWalletsGiveNoRouteIn() (gas: 361617) +NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143274) NemesisIdentifierSquatPoC:test_NM007_theOldClaimSelectorIsUnreachable() (gas: 13826) -NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55505) +NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55571) NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 49456) NemesisPausePoC:test_NM005_suspendingTheAdminRemovesThePauseLever() (gas: 49382) NemesisPausePoC:test_NM005_theProtocolStaysUnpausedWhileThePauseIsUnreachable() (gas: 31162) @@ -388,47 +430,66 @@ P256VerificationTest:test_verifySignature_rejectsAnAssertionMadeForAnotherChalle P256VerificationTest:test_verify_bothPathsAcceptAValidSignature() (gas: 236037) P256VerificationTest:test_verify_bothPathsRejectACorruptedSignature() (gas: 239570) P256VerificationTest:test_verify_theFallbackCostsFarMoreThanThePrecompile() (gas: 239733) -PaymentAdapterTest:test_consumePaymentReference_marksTheReference() (gas: 46249) -PaymentAdapterTest:test_consumePaymentReference_rejectsAReferenceAlreadySpent() (gas: 45249) -PaymentAdapterTest:test_consumePaymentReference_rejectsAnEmptyReference() (gas: 18468) -PaymentAdapterTest:test_consumePaymentReference_rejectsAnyoneButTheRegistry() (gas: 18346) -PaymentAdapterTest:test_consumePaymentReference_rejectsTheOwner() (gas: 18192) -PaymentAdapterTest:test_implementationCannotBeInitialized() (gas: 1339077) -PaymentAdapterTest:test_initialize_cannotRunTwice() (gas: 23521) -PaymentAdapterTest:test_initialize_rejectsAZeroRegistry() (gas: 1412889) -PaymentAdapterTest:test_initialize_rejectsAZeroSettlementToken() (gas: 1412996) -PaymentAdapterTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 1413606) -PaymentAdapterTest:test_initialize_storesTheAddressesItWasGiven() (gas: 25831) -PaymentAdapterTest:test_quote_convertsCentsToSixDecimals() (gas: 14387) -PaymentAdapterTest:test_quote_convertsCentsToTwoDecimals() (gas: 13195) -PaymentAdapterTest:test_quote_keepsASingleCent() (gas: 13683) -PaymentAdapterTest:test_quote_rejectsACurrencyThatNeedsARate() (gas: 16119) -PaymentAdapterTest:test_quote_rejectsAWithdrawnCurrency() (gas: 31762) -PaymentAdapterTest:test_quote_rejectsAnUnregisteredCurrency() (gas: 16806) -PaymentAdapterTest:test_quote_returnsZeroForAZeroPrice() (gas: 13699) +PaymentAdapterSettleTest:test_settle_emitsPaymentSettled() (gas: 104044) +PaymentAdapterSettleTest:test_settle_handlesTheSmallestPrice() (gas: 733055) +PaymentAdapterSettleTest:test_settle_leavesATokenSentHereByMistake() (gas: 123493) +PaymentAdapterSettleTest:test_settle_movesATokenThatReturnsNothing() (gas: 341287) +PaymentAdapterSettleTest:test_settle_paysTheVaultCurrentlySet() (gas: 118467) +PaymentAdapterSettleTest:test_settle_paysTheVaultTheQuotedAmount() (gas: 103697) +PaymentAdapterSettleTest:test_settle_refundsWhatThePriceDidNotNeed() (gas: 128419) +PaymentAdapterSettleTest:test_settle_refusesAReentrantCallFromTheToken() (gas: 1280973) +PaymentAdapterSettleTest:test_settle_revertsForACallerTheRegistryDoesNotKnow() (gas: 80743) +PaymentAdapterSettleTest:test_settle_revertsForADeviceWallet() (gas: 81429) +PaymentAdapterSettleTest:test_settle_revertsForAFeeOnTransferToken() (gas: 853158) +PaymentAdapterSettleTest:test_settle_revertsForAWithdrawnAsset() (gas: 51510) +PaymentAdapterSettleTest:test_settle_revertsForAnAssetThatNeedsASwap() (gas: 675945) +PaymentAdapterSettleTest:test_settle_revertsForAnUnregisteredSymbol() (gas: 36700) +PaymentAdapterSettleTest:test_settle_revertsForFiat() (gas: 36722) +PaymentAdapterSettleTest:test_settle_revertsOnAZeroPrice() (gas: 34165) +PaymentAdapterSettleTest:test_settle_revertsOnAZeroRefundAddress() (gas: 34653) +PaymentAdapterSettleTest:test_settle_revertsWhenTheCallerHasNotFundedIt() (gas: 43129) +PaymentAdapterSettleTest:test_settle_revertsWhenThePriceIsAboveWhatTheCallerWillSpend() (gas: 88627) +PaymentAdapterTest:test_consumePaymentReference_marksTheReference() (gas: 46315) +PaymentAdapterTest:test_consumePaymentReference_rejectsAReferenceAlreadySpent() (gas: 45293) +PaymentAdapterTest:test_consumePaymentReference_rejectsAnEmptyReference() (gas: 18490) +PaymentAdapterTest:test_consumePaymentReference_rejectsAnyoneButTheRegistry() (gas: 18368) +PaymentAdapterTest:test_consumePaymentReference_rejectsTheOwner() (gas: 18214) +PaymentAdapterTest:test_implementationCannotBeInitialized() (gas: 1660321) +PaymentAdapterTest:test_initialize_cannotRunTwice() (gas: 23543) +PaymentAdapterTest:test_initialize_rejectsAZeroRegistry() (gas: 1734111) +PaymentAdapterTest:test_initialize_rejectsAZeroSettlementToken() (gas: 1734218) +PaymentAdapterTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 1734828) +PaymentAdapterTest:test_initialize_storesTheAddressesItWasGiven() (gas: 25897) +PaymentAdapterTest:test_quote_convertsCentsToSixDecimals() (gas: 14487) +PaymentAdapterTest:test_quote_convertsCentsToTwoDecimals() (gas: 13295) +PaymentAdapterTest:test_quote_keepsASingleCent() (gas: 13783) +PaymentAdapterTest:test_quote_rejectsACurrencyThatNeedsARate() (gas: 16192) +PaymentAdapterTest:test_quote_rejectsAWithdrawnCurrency() (gas: 31846) +PaymentAdapterTest:test_quote_rejectsAnUnregisteredCurrency() (gas: 16868) +PaymentAdapterTest:test_quote_returnsZeroForAZeroPrice() (gas: 13799) PaymentAdapterTest:test_registerAsset_rejectsASymbolAlreadyRegistered() (gas: 24147) PaymentAdapterTest:test_registerAsset_rejectsAnEmptySymbol() (gas: 22630) PaymentAdapterTest:test_registerAsset_rejectsFewerThanTwoDecimals() (gas: 24228) PaymentAdapterTest:test_registerAsset_rejectsTheAdmin() (gas: 21684) PaymentAdapterTest:test_registerAsset_rejectsZeroDecimals() (gas: 24042) -PaymentAdapterTest:test_registerAsset_storesTheEntry() (gas: 52154) -PaymentAdapterTest:test_renounceOwnership_alwaysReverts() (gas: 16646) -PaymentAdapterTest:test_resolveAsset_rejectsAnUnregisteredCurrency() (gas: 17552) -PaymentAdapterTest:test_resolveAsset_returnsACurrencyThatNeedsARate() (gas: 13920) -PaymentAdapterTest:test_resolveAsset_returnsTheStoredEntry() (gas: 16770) -PaymentAdapterTest:test_updateAsset_rejectsAnUnregisteredSymbol() (gas: 21989) -PaymentAdapterTest:test_updateAsset_rejectsFewerThanTwoDecimals() (gas: 24858) -PaymentAdapterTest:test_updateAsset_rejectsTheAdmin() (gas: 22570) -PaymentAdapterTest:test_updateAsset_rewritesTheEntry() (gas: 31284) -PaymentAdapterTest:test_updateAsset_withdrawsACurrency() (gas: 34361) -PaymentAdapterTest:test_upgradeManager_matchesTheOwner() (gas: 13917) -PaymentAdapterTest:test_upgrade_keepsTheSpentReferences() (gas: 1379237) -PaymentAdapterTest:test_upgrade_rejectsAnyoneButTheOwner() (gas: 1343546) -PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011216) -PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1178811) -PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982600) -PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1135158) -PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814231) +PaymentAdapterTest:test_registerAsset_storesTheEntry() (gas: 52176) +PaymentAdapterTest:test_renounceOwnership_alwaysReverts() (gas: 16668) +PaymentAdapterTest:test_resolveAsset_rejectsAnUnregisteredCurrency() (gas: 17574) +PaymentAdapterTest:test_resolveAsset_returnsACurrencyThatNeedsARate() (gas: 13942) +PaymentAdapterTest:test_resolveAsset_returnsTheStoredEntry() (gas: 16792) +PaymentAdapterTest:test_updateAsset_rejectsAnUnregisteredSymbol() (gas: 22011) +PaymentAdapterTest:test_updateAsset_rejectsFewerThanTwoDecimals() (gas: 24880) +PaymentAdapterTest:test_updateAsset_rejectsTheAdmin() (gas: 22592) +PaymentAdapterTest:test_updateAsset_rewritesTheEntry() (gas: 31328) +PaymentAdapterTest:test_updateAsset_withdrawsACurrency() (gas: 34405) +PaymentAdapterTest:test_upgradeManager_matchesTheOwner() (gas: 13961) +PaymentAdapterTest:test_upgrade_keepsTheSpentReferences() (gas: 1700526) +PaymentAdapterTest:test_upgrade_rejectsAnyoneButTheOwner() (gas: 1664768) +PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011444) +PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1179361) +PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982850) +PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1135474) +PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814504) PriceCapSlotMigrationTest:test_migration_theOldCeilingSlotIsReadAsTheAdapter() (gas: 11352) ProtocolAdminTest:test_cancel_leavesThePayloadSchedulableAgain() (gas: 96360) ProtocolAdminTest:test_cancel_rejectsAnAccountHoldingNeitherRole() (gas: 56421) @@ -464,12 +525,12 @@ ProtocolAdminTest:test_updateDelay_cannotBringTheDelayBelowTheFloor() (gas: 6904 ProtocolAdminTest:test_updateDelay_rejectsACallerThatIsNotTheContractItself() (gas: 12158) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheAdmin() (gas: 25405) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheZeroAddress() (gas: 18743) -RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295340) -RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272120) +RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295502) +RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272387) RegistryGuardsTest:test_bindESIMWallet_rejectsACallerThatIsNotADeviceWallet() (gas: 20718) -RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297559) +RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297743) RegistryGuardsTest:test_deployLazyWallet_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 30823) -RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927036) +RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927176) RegistryGuardsTest:test_deployMoreLazyESIMWallets_rejectsACallerOtherThanTheLazyRegistry() (gas: 23540) RegistryGuardsTest:test_initialize_rejectsAZeroAdmin() (gas: 4345432) RegistryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 4344569) @@ -486,9 +547,9 @@ RegistryInitializeTest:test_initialize_revertsWhenDeviceWalletFactoryIsZero() (g RegistryInitializeTest:test_initialize_revertsWhenESIMWalletFactoryIsZero() (gas: 4344569) RegistryInitializeTest:test_initialize_revertsWhenPriceCapIsZero() (gas: 4346107) RegistryInitializeTest:test_initialize_revertsWhenVaultIsZero() (gas: 4346706) -RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1771627) -RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922278) -RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770164) +RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1772100) +RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922484) +RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770637) RegistryTest:test_pause_onlyTheAdminCanTripIt() (gas: 47066) RegistryTest:test_pause_survivesAnAdminRotation() (gas: 55219) RegistryTest:test_requireNotPaused_revertsOnlyWhilePaused() (gas: 28433) @@ -501,7 +562,7 @@ RegistryTest:test_updateVaultAddress_rejectsTheCurrentAddress() (gas: 23282) RegistryTest:test_updateVaultAddress_rejectsTheZeroAddress() (gas: 26180) RenounceOwnershipTest:test_renounceOwnership_revertsOnDeviceWalletFactory() (gas: 30236) RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletFactory() (gas: 29394) -RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 923884) +RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 924117) RenounceOwnershipTest:test_renounceOwnership_revertsOnLazyWalletRegistry() (gas: 22029) RenounceOwnershipTest:test_renounceOwnership_revertsOnRegistry() (gas: 22063) RoleRecoveryTest:test_grantRole_keepsALaterGuardianExecutingOnceExecutionIsClosed() (gas: 236681) @@ -521,50 +582,50 @@ RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 14520 RoleRecoveryTest:test_recovery_nobodyCanStepDownOnAnotherAccountsBehalf() (gas: 19003) RoleRecoveryTest:test_revokeRole_evictsAGuardianCompletelyInOneBatch() (gas: 92280) RoleRecoveryTest:test_revokeRole_leavesAnEvictedGuardianAbleToExecute() (gas: 76667) -SettledPurchaseTest:test_buyDataBundle_rejectsAReferenceSpentBySettlement() (gas: 1119794) +SettledPurchaseTest:test_buyDataBundle_rejectsAReferenceSpentBySettlement() (gas: 1120251) SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19325) -SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1131410) -SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6294286) -SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1111532) -SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1076067) -SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1130996) -SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1127392) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002073) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1056523) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1167471) -SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1118024) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037467) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002252) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1002829) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 1001181) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021066) +SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1131748) +SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6295612) +SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1111789) +SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1076383) +SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1131290) +SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1127649) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002279) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1056817) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1168124) +SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1118406) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037739) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002458) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1003035) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 1001453) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021316) SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49208) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1004881) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6082700) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009619) -SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1106149) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005087) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6083884) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009825) +SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1106465) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24201) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20780) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20532) SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29750) StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21373) -StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544294) +StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544338) StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16047) -StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 854990) +StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 855159) StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34461) -StorageLayoutTest:test_layout_paymentAdapterSlotsAreUnchanged() (gas: 18564) +StorageLayoutTest:test_layout_paymentAdapterSlotsAreUnchanged() (gas: 18652) StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 57163) UpgradeAuthorityTest:test_upgradeManager_acceptsAContractAsTheUpgradeAuthority() (gas: 4404048) UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWalletRegistry() (gas: 38767) UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39810) UpgradeAuthorityTest:test_upgradeManager_matchesTheOwner() (gas: 29385) UpgradeAuthorityTest:test_upgradeManager_theRetiredOwnerCannotUpgrade() (gas: 4245476) -UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975772) -UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967264) -UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976285) -UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938299) -UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 975948) -UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 947207) +UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975934) +UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967426) +UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976447) +UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938461) +UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 976110) +UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 947369) WebAuthnTest:test_verifySignature_rejectsARegistrationCeremonyType() (gas: 20365) WebAuthnTest:test_verifySignature_rejectsAbsentUserPresentFlag() (gas: 29039) WebAuthnTest:test_verifySignature_rejectsAbsentUserVerifiedFlagWhenRequired() (gas: 28673) diff --git a/snapshots/DeviceWallet.Operations.json b/snapshots/DeviceWallet.Operations.json index 5fb2890a..14781984 100644 --- a/snapshots/DeviceWallet.Operations.json +++ b/snapshots/DeviceWallet.Operations.json @@ -4,6 +4,8 @@ "execute: ETH transfer to an EOA": "36317", "executeBatch: 3 ETH transfers": "79993", "pullETH: 1 ether to the eSIM wallet": "13418", + "pullToken: first pull, the wallet holds none of it": "30862", + "pullToken: the wallet already holds some": "8962", "toggleAccessToFunds: grant": "23507", "toggleAccessToFunds: revoke": "3607" } \ No newline at end of file diff --git a/snapshots/ESIMWallet.Operations.json b/snapshots/ESIMWallet.Operations.json index efa14e9a..6cd5cc2c 100644 --- a/snapshots/ESIMWallet.Operations.json +++ b/snapshots/ESIMWallet.Operations.json @@ -1,10 +1,13 @@ { "acceptOwnershipTransfer": "3551", "buyDataBundle: pulls from the device wallet": "108255", - "buyDataBundle: wallet already holds the price": "156649", + "buyDataBundle: wallet already holds the price": "94249", + "buyDataBundleWithToken: pulls from the device wallet": "156943", + "buyDataBundleWithToken: wallet already holds the price": "127357", "deployESIMWallet: through the factory": "289547", "populateHistory: 10 entries": "489385", "requestTransferOwnership": "64378", + "sendTokenToDeviceWallet: the whole balance": "28723", "setESIMUniqueIdentifier: first time": "27249", "setPriceCapUSDCents: back to the registry ceiling": "2565", "setPriceCapUSDCents: set": "22465" diff --git a/snapshots/PaymentAdapter.Operations.json b/snapshots/PaymentAdapter.Operations.json index f6ac12d8..dff7ab24 100644 --- a/snapshots/PaymentAdapter.Operations.json +++ b/snapshots/PaymentAdapter.Operations.json @@ -4,5 +4,8 @@ "quote: 6 decimals": "7842", "registerAsset: a currency not in the table": "32911", "resolveAsset": "7991", + "settle: funded above the price, with a refund": "40529", + "settle: funded to the exact price": "15314", + "settle: the vault's first payment in this currency": "49714", "updateAsset: withdrawing a currency": "16144" } \ No newline at end of file diff --git a/storage-layouts/DeviceWallet.json b/storage-layouts/DeviceWallet.json index 325c7f46..a1285f2f 100644 --- a/storage-layouts/DeviceWallet.json +++ b/storage-layouts/DeviceWallet.json @@ -40,7 +40,7 @@ "slot": "6", "offset": 0, "bytes": "32", - "label": "canPullETH", + "label": "canPullFunds", "type": "t_mapping(t_address,t_bool)" } ] diff --git a/test/foundry/gas/DeviceWallet.Operations.gas.t.sol b/test/foundry/gas/DeviceWallet.Operations.gas.t.sol index 0070237a..4e82b592 100644 --- a/test/foundry/gas/DeviceWallet.Operations.gas.t.sol +++ b/test/foundry/gas/DeviceWallet.Operations.gas.t.sol @@ -84,6 +84,25 @@ contract DeviceWalletOperationsGasTest is GasBase { vm.snapshotGasLastCall(NAMESPACE, "pullETH: 1 ether to the eSIM wallet"); } + /// @notice An eSIM wallet pulling an ERC-20 from the device wallet that owns it + /// @dev Two cases because the eSIM wallet's balance slot is cold on its first purchase and warm + /// after, and the difference is what a wallet buying more than once actually pays. + function test_pullToken() public { + _deploy(); + fundSettlementToken(address(wallet), 1_000e6); + + vm.prank(address(wallet)); + wallet.toggleAccessToFunds(address(firstESIMWallet), true); + + vm.prank(address(firstESIMWallet)); + wallet.pullToken(settlementToken, 100e6); + vm.snapshotGasLastCall(NAMESPACE, "pullToken: first pull, the wallet holds none of it"); + + vm.prank(address(firstESIMWallet)); + wallet.pullToken(settlementToken, 100e6); + vm.snapshotGasLastCall(NAMESPACE, "pullToken: the wallet already holds some"); + } + /// @notice Funding the wallet's entry point deposit function test_addDeposit() public { _deploy(); diff --git a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol index 5bdfbeaf..fa0346b5 100644 --- a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol +++ b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol @@ -41,10 +41,16 @@ contract ESIMWalletOperationsGasTest is GasBase { /// @notice Buying a data bundle, funded and unfunded /// @dev The unfunded case is the common one. A wallet holds no float, so every purchase pulls /// from the device wallet and pays the vault in the same call. + /// + /// The first purchase gives the vault a balance it did not have, which costs more than + /// every one after it, so one runs unmeasured before either figure is taken. function test_buyDataBundle() public { _deploy(); vm.deal(address(eSIMWallet), 10 ether); + vm.prank(address(wallet)); + eSIMWallet.buyDataBundle(bundle("DB_GAS_0", TEST_PRICE_CENTS), 1 ether, paymentRef("gas-0")); + vm.prank(address(wallet)); eSIMWallet.buyDataBundle(bundle("DB_GAS_1", TEST_PRICE_CENTS), 1 ether, paymentRef("gas-1")); vm.snapshotGasLastCall(NAMESPACE, "buyDataBundle: wallet already holds the price"); @@ -55,6 +61,42 @@ contract ESIMWalletOperationsGasTest is GasBase { vm.snapshotGasLastCall(NAMESPACE, "buyDataBundle: pulls from the device wallet"); } + /// @notice Buying a data bundle with an ERC-20, funded and unfunded + /// @dev The unfunded case is the common one and costs a pull on top. A first purchase writes + /// the vault a balance it did not have, so one runs unmeasured before either figure is + /// taken and the two are then comparable. + /// + /// Both are dearer than the ETH path, which moves money once where this moves it into the + /// wallet, on to the adapter and then to the vault. + function test_buyDataBundleWithToken() public { + _deploy(); + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + + fundSettlementToken(address(eSIMWallet), needed); + vm.prank(address(wallet)); + eSIMWallet.buyDataBundleWithToken(bundle("DB_TOK_0", TEST_PRICE_CENTS), ASSET_USDC, needed, paymentRef("gas-tok-0")); + + fundSettlementToken(address(eSIMWallet), needed); + vm.prank(address(wallet)); + eSIMWallet.buyDataBundleWithToken(bundle("DB_TOK_1", TEST_PRICE_CENTS), ASSET_USDC, needed, paymentRef("gas-tok-1")); + vm.snapshotGasLastCall(NAMESPACE, "buyDataBundleWithToken: wallet already holds the price"); + + fundSettlementToken(address(wallet), needed); + vm.prank(address(wallet)); + eSIMWallet.buyDataBundleWithToken(bundle("DB_TOK_2", TEST_PRICE_CENTS), ASSET_USDC, needed, paymentRef("gas-tok-2")); + vm.snapshotGasLastCall(NAMESPACE, "buyDataBundleWithToken: pulls from the device wallet"); + } + + /// @notice Returning a token balance to the owning device wallet + function test_sendTokenToDeviceWallet() public { + _deploy(); + fundSettlementToken(address(eSIMWallet), 100e6); + + vm.prank(address(wallet)); + eSIMWallet.sendTokenToDeviceWallet(settlementToken, 100e6); + vm.snapshotGasLastCall(NAMESPACE, "sendTokenToDeviceWallet: the whole balance"); + } + /// @notice Naming an eSIM after its wallet exists /// @dev One-shot per wallet, so the second wallet on the device is the one measured here. function test_setESIMUniqueIdentifier() public { diff --git a/test/foundry/gas/PaymentAdapter.Operations.gas.t.sol b/test/foundry/gas/PaymentAdapter.Operations.gas.t.sol index ef3908a9..b8a520f6 100644 --- a/test/foundry/gas/PaymentAdapter.Operations.gas.t.sol +++ b/test/foundry/gas/PaymentAdapter.Operations.gas.t.sol @@ -6,6 +6,7 @@ import "contracts/CustomStructs.sol"; import {Asset} from "contracts/payments/PaymentAdapter.sol"; import {GasBase} from "test/foundry/gas/base/GasBase.sol"; +import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; /// @notice Gas for the currency table and the payment references. /// @dev Every purchase on both paths spends a reference and reads a currency, so what is measured @@ -65,6 +66,30 @@ contract PaymentAdapterOperationsGasTest is GasBase { vm.snapshotGasLastCall(NAMESPACE, "resolveAsset"); } + /// @notice Paying the vault out of tokens already sent to the adapter + /// @dev A first settlement writes the vault a balance it did not have, which costs more than + /// every one after it and would swamp the difference the two cases are here to show. So + /// the vault is paid once before either measurement and all three are recorded. + function test_settle() public { + (, MockESIMWallet eSIMWallet) = _deployDeviceWallet("Device_Settle", 0, 4_001); + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + + fundSettlementToken(address(paymentAdapter), needed); + vm.prank(address(eSIMWallet)); + paymentAdapter.settle(ASSET_USDC, TEST_PRICE_CENTS, needed, address(eSIMWallet)); + vm.snapshotGasLastCall(NAMESPACE, "settle: the vault's first payment in this currency"); + + fundSettlementToken(address(paymentAdapter), needed); + vm.prank(address(eSIMWallet)); + paymentAdapter.settle(ASSET_USDC, TEST_PRICE_CENTS, needed, address(eSIMWallet)); + vm.snapshotGasLastCall(NAMESPACE, "settle: funded to the exact price"); + + fundSettlementToken(address(paymentAdapter), needed * 2); + vm.prank(address(eSIMWallet)); + paymentAdapter.settle(ASSET_USDC, TEST_PRICE_CENTS, needed * 2, address(eSIMWallet)); + vm.snapshotGasLastCall(NAMESPACE, "settle: funded above the price, with a refund"); + } + /// @notice Spending a payment reference /// @dev The cold SSTORE both payment paths carry. Nothing ever reads the slot again, so this /// is the floor under every purchase and there is no warm case to measure against it. From 8ebcf708e2b3723b621ca575b4e97320fca0f657 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:07:59 +0530 Subject: [PATCH 30/75] Regenerate the contract docs 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. --- docs/CustomStructs.md | 24 ++- docs/Errors.md | 118 ++++++++++- docs/LazyWalletRegistry.md | 24 +++ docs/Registry.md | 127 ++++++++--- docs/RegistryHelper.md | 70 ++++++- docs/device-wallet/DeviceWallet.md | 119 ++++++----- docs/esim-wallet/ESIMWallet.md | 140 +++++++++++-- docs/interfaces/IPaymentRegistry.md | 25 +++ docs/payments/PaymentAdapter.md | 314 ++++++++++++++++++++++++++++ 9 files changed, 847 insertions(+), 114 deletions(-) create mode 100644 docs/interfaces/IPaymentRegistry.md create mode 100644 docs/payments/PaymentAdapter.md diff --git a/docs/CustomStructs.md b/docs/CustomStructs.md index 38c69747..1be3db4f 100644 --- a/docs/CustomStructs.md +++ b/docs/CustomStructs.md @@ -1,13 +1,33 @@ # Solidity API +## Settlement + +Which contract, if any, saw the money for a data bundle move + +_Only `DeviceWallet` can be proven onchain. The other two are the admin's word, so the + price cap is the only check on them._ + +```solidity +enum Settlement { + DeviceWallet, + ExternalWallet, + Fiat +} +``` + ## DataBundleDetails Data Bundle related details stored in the eSIM wallet +_Two slots: `id`, then `priceUSDCents` and `settlement` packed together. `id` is + `bytes32` because the provider's ids fit in 32 bytes and a `string` would cost an extra + slot on every entry. No timestamp field: the event log already has one._ + ```solidity struct DataBundleDetails { - string dataBundleID; - uint256 dataBundlePrice; + bytes32 id; + uint64 priceUSDCents; + enum Settlement settlement; } ``` diff --git a/docs/Errors.md b/docs/Errors.md index 5b1c87aa..e344ce2a 100644 --- a/docs/Errors.md +++ b/docs/Errors.md @@ -338,6 +338,18 @@ error FailedToTransfer() error InsufficientBalance(uint256 balance, uint256 amount) ``` +### ZeroAmount + +```solidity +error ZeroAmount() +``` + +### AssetNotTransferable + +```solidity +error AssetNotTransferable(bytes32 asset) +``` + ### OnlyRegistry ```solidity @@ -353,7 +365,7 @@ error OnlyDeviceWalletOrESIMWalletAdmin() ### DataBundlePriceAboveCap ```solidity -error DataBundlePriceAboveCap(uint256 price, uint256 cap) +error DataBundlePriceAboveCap(uint64 priceUSDCents, uint64 cap) ``` ### ESIMIdentifierAlreadySet @@ -404,22 +416,16 @@ error UseAcceptOwnershipTransfer() error UnknownESIMWallet(address eSIMWallet) ``` -### ZeroAmount +### FundsAccessRevoked ```solidity -error ZeroAmount() +error FundsAccessRevoked(address eSIMWallet) ``` -### ETHAccessRevoked +### FundsAccessNotGrantableAtBind ```solidity -error ETHAccessRevoked(address eSIMWallet) -``` - -### ETHAccessNotGrantableAtBind - -```solidity -error ETHAccessNotGrantableAtBind(address eSIMWallet) +error FundsAccessNotGrantableAtBind(address eSIMWallet) ``` ### ESIMWalletAlreadyAdded @@ -464,3 +470,93 @@ error OnlyAssociatedESIMWallets() error OnlyESIMWalletAdmin() ``` +### PaymentAdapterNotSet + +```solidity +error PaymentAdapterNotSet() +``` + +### PaymentAdapterUnchanged + +```solidity +error PaymentAdapterUnchanged(address paymentAdapter) +``` + +### SettlementNotAsserted + +```solidity +error SettlementNotAsserted() +``` + +### HistoryNotFullyCopied + +```solidity +error HistoryNotFullyCopied(string eSIMIdentifier, uint256 outstanding) +``` + +### EmptyAssetSymbol + +```solidity +error EmptyAssetSymbol() +``` + +### EmptyPaymentReference + +```solidity +error EmptyPaymentReference() +``` + +### AssetNotAllowed + +```solidity +error AssetNotAllowed(bytes32 asset) +``` + +### AssetAlreadyRegistered + +```solidity +error AssetAlreadyRegistered(bytes32 asset) +``` + +### AssetNotRegistered + +```solidity +error AssetNotRegistered(bytes32 asset) +``` + +### AssetDecimalsTooLow + +```solidity +error AssetDecimalsTooLow(bytes32 asset, uint8 decimals) +``` + +### AssetDecimalsTooHigh + +```solidity +error AssetDecimalsTooHigh(bytes32 asset, uint8 decimals) +``` + +### AssetNeedsSwap + +```solidity +error AssetNeedsSwap(bytes32 asset) +``` + +### PaymentReferenceAlreadyUsed + +```solidity +error PaymentReferenceAlreadyUsed(bytes32 paymentReference) +``` + +### SettlementAboveMax + +```solidity +error SettlementAboveMax(uint256 required, uint256 maxAmountIn) +``` + +### SettlementNotFunded + +```solidity +error SettlementNotFunded(uint256 amountIn, uint256 balance) +``` + diff --git a/docs/LazyWalletRegistry.md b/docs/LazyWalletRegistry.md index 0a70cc58..d57a85ba 100644 --- a/docs/LazyWalletRegistry.md +++ b/docs/LazyWalletRegistry.md @@ -501,6 +501,30 @@ _Reads through to the owner rather than holding its own copy. `_authorizeUpgrade gated on `onlyOwner`, so the owner is the upgrade authority by definition and a second copy could only ever disagree with it._ +### outstandingHistoryEntries + +```solidity +function outstandingHistoryEntries(string _eSIMIdentifier) external view returns (uint256) +``` + +How many history entries are still waiting to be copied into this eSIM's wallet + +_Needed because the public getter on `deviceIdentifierToESIMDetails` takes an index and + never returns a length, so nothing outside this contract can count the entries. + Returns zero for an eSIM this contract never handled, which has nothing waiting anyway._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _eSIMIdentifier | string | eSIM being asked about | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | uint256 | Entries still waiting to be copied | + ### isDeviceIdentifierReserved ```solidity diff --git a/docs/Registry.md b/docs/Registry.md index ad145be7..6ec51d88 100644 --- a/docs/Registry.md +++ b/docs/Registry.md @@ -88,18 +88,37 @@ _Packs into the spare bytes beside `paused`, so it costs no slot of its own. Sus lifting it is an owner action and therefore waits. A key that could restore itself as fast as it was suspended would leave the two sides trading transactions forever._ -### defaultDataBundlePriceCap +### defaultPriceCapUSDCents ```solidity -uint256 defaultDataBundlePriceCap +uint64 defaultPriceCapUSDCents ``` -Most an eSIM wallet may be charged for one data bundle unless it sets its own limit +Most an eSIM wallet may be charged for one data bundle, in USD cents, unless it sets + its own limit -_Held here rather than only on each wallet because a wallet deployed before this existed - reads zero, and there is no enumerable list to write a value into. Never zero: `initialize` - and `setDefaultDataBundlePriceCap` both reject it, since a zero here or on a wallet's own - cap reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`._ +_Held here because there is no way to list every wallet and write into each one, so one + write here is how a change reaches all of them. Never zero: both setters reject it, + because zero on a wallet means "follow the registry" and would mean nothing here._ + +### paymentAdapter + +```solidity +address paymentAdapter +``` + +Contract holding the accepted currencies and the spent payment references + +_A pointer and a setter, the same shape this registry already uses for the lazy wallet + registry._ + +### onlyESIMWallet + +```solidity +modifier onlyESIMWallet() +``` + +Restricts a call to an eSIM wallet this registry has recorded ### onlyDeviceWallet @@ -143,7 +162,7 @@ _Locks the implementation contract itself. Without this, anyone can call initial ### initialize ```solidity -function initialize(address _eSIMWalletAdmin, address _vault, address _upgradeManager, address _deviceWalletFactory, address _eSIMWalletFactory, contract IEntryPoint _entryPoint, uint256 _defaultDataBundlePriceCap) external +function initialize(address _eSIMWalletAdmin, address _vault, address _upgradeManager, address _deviceWalletFactory, address _eSIMWalletFactory, contract IEntryPoint _entryPoint, uint64 _defaultPriceCapUSDCents) external ``` Wires the registry to the two factories and sets the protocol's addresses @@ -158,7 +177,7 @@ Wires the registry to the two factories and sets the protocol's addresses | _deviceWalletFactory | address | Factory that deploys device wallets | | _eSIMWalletFactory | address | Factory that deploys eSIM wallets | | _entryPoint | contract IEntryPoint | ERC-4337 EntryPoint singleton for this chain | -| _defaultDataBundlePriceCap | uint256 | Starting price ceiling. Must be non-zero: a zero cap, here or on a wallet's own, reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`. | +| _defaultPriceCapUSDCents | uint64 | Starting price ceiling in USD cents. Must be non-zero: a zero cap, here or on a wallet's own, reads as "no ceiling" in `ESIMWallet._requirePriceWithinCap`. | ### requestAdminUpdate @@ -319,10 +338,10 @@ Reverts while the protocol is paused _Device wallets and eSIM wallets call this rather than reading `paused` and reverting themselves, so the revert reason is the same wherever it comes from._ -### setDefaultDataBundlePriceCap +### setDefaultPriceCapUSDCents ```solidity -function setDefaultDataBundlePriceCap(uint256 _cap) external +function setDefaultPriceCapUSDCents(uint64 _cap) external ``` Sets the price ceiling eSIM wallets fall back to when they hold none of their own @@ -336,7 +355,64 @@ _Owner and not admin, deliberately. The admin is the party this ceiling constrai | Name | Type | Description | | ---- | ---- | ----------- | -| _cap | uint256 | Maximum price in wei, non-zero | +| _cap | uint64 | Maximum price in USD cents, non-zero | + +### setPaymentAdapter + +```solidity +function setPaymentAdapter(address _paymentAdapter) external +``` + +Points this registry at the payment adapter + +_Owner and not admin. The adapter holds the spent payment references, so an admin that + could swap it would get an empty set back and record every purchase a second time._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _paymentAdapter | address | Address of the payment adapter | + +### consumePaymentReference + +```solidity +function consumePaymentReference(bytes32 _paymentReference) external +``` + +Spends a payment reference for an eSIM wallet buying with ETH + +_The adapter only accepts this from the registry, so the wallet has to come through + here. One reference then cannot be spent once on each path._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _paymentReference | bytes32 | Hash tying the purchase to the offchain order behind it | + +### recordSettledPurchase + +```solidity +function recordSettledPurchase(address _eSIMWallet, struct DataBundleDetails _dataBundleDetail, bytes32 _asset, uint256 _tokenAmount, bytes32 _paymentReference) external +``` + +Records a data bundle paid for through Moonpay, a card or an external wallet + +_No money moves here. Three things bound what the admin can state: the price ceiling, + the payment reference being spendable once, and the settlement not being + `DeviceWallet`. Written here and not by the adapter, because eSIM wallets accept + history from this address alone._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _eSIMWallet | address | Wallet the purchase belongs to | +| _dataBundleDetail | struct DataBundleDetails | The purchase, priced in USD cents like everywhere else | +| _asset | bytes32 | Symbol of the currency the user paid in | +| _tokenAmount | uint256 | What the user actually paid, in that currency's smallest unit. Recorded for offchain matching, never checked: it and the price both come from the admin. | +| _paymentReference | bytes32 | Hash of the Moonpay charge or the Stripe payment intent | ### updateDeviceWalletInfo @@ -426,28 +502,31 @@ _The association is a registration: once the registry has named a device wallet | _eSIMWalletAddress | address | Address of the eSIM wallet | | _deviceWalletAddress | address | The device wallet taking it on, which must be the caller | -### claimESIMIdentifier +### assignESIMIdentifier ```solidity -function claimESIMIdentifier(string _eSIMUniqueIdentifier, address _eSIMWalletAddress) external +function assignESIMIdentifier(address _eSIMWalletAddress, string _eSIMUniqueIdentifier) external returns (string) ``` -Records that an eSIM wallet now holds an eSIM identifier, refusing a second holder +Binds an eSIM identifier to an eSIM wallet and writes it onto the wallet -_The guard lives here rather than in `DeviceWallet` because a device wallet can reach - this directly through `execute`, which would skip anything sitting on the wallet side. - For the same reason the caller's device identifier is read from it rather than taken as - an argument. - - A reservation is compared against the caller's own identifier rather than refused - outright, since the lazy route reaches this while deploying against its own._ +_Admin only. Which identifier a wallet is owed is known offchain when the eSIM is + bought, and no onchain check replaces that: a device wallet reaches every external + function through `execute`, and any fact it could present about its own wallets is one + it writes itself. The lazy route shares the same internal claim._ #### Parameters | Name | Type | Description | | ---- | ---- | ----------- | -| _eSIMUniqueIdentifier | string | Identifier being claimed | -| _eSIMWalletAddress | address | Wallet claiming it, which must be one the caller owns | +| _eSIMWalletAddress | address | Wallet receiving the identifier | +| _eSIMUniqueIdentifier | string | Identifier being assigned | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | string | The identifier now on the wallet | ### toggleESIMWalletStandbyStatus diff --git a/docs/RegistryHelper.md b/docs/RegistryHelper.md index a3dd3890..9e83d83e 100644 --- a/docs/RegistryHelper.md +++ b/docs/RegistryHelper.md @@ -240,14 +240,33 @@ event Unpaused(address _owner) Emitted when the owner releases the pause -### DefaultDataBundlePriceCapUpdated +### DefaultPriceCapUSDCentsUpdated ```solidity -event DefaultDataBundlePriceCapUpdated(uint256 _cap) +event DefaultPriceCapUSDCentsUpdated(uint64 _cap) ``` Emitted when the owner changes the price ceiling eSIM wallets fall back to +### PaymentAdapterUpdated + +```solidity +event PaymentAdapterUpdated(address _paymentAdapter) +``` + +Emitted when the owner points the registry at a payment adapter + +### DataBundleSettled + +```solidity +event DataBundleSettled(address _eSIMWallet, bytes32 _dataBundleID, uint64 _priceUSDCents, enum Settlement _settlement, bytes32 _asset, address _token, uint256 _tokenAmount, bytes32 _paymentReference) +``` + +Emitted for a purchase paid for outside the protocol + +_On the registry and not the wallet, so an indexer follows one address instead of one + per wallet. `_tokenAmount` is unchecked: it and the price both come from the admin._ + ### ESIMWalletSetOnStandby ```solidity @@ -378,6 +397,53 @@ _Shared by the first batch and every batch after it so the two cannot drift apar | ---- | ---- | ----------- | | [0] | address | Address of the eSIM wallet deployed | +### _assignESIMIdentifier + +```solidity +function _assignESIMIdentifier(address _eSIMWalletAddress, string _eSIMUniqueIdentifier) internal returns (string) +``` + +Records an eSIM identifier against a wallet and writes it onto the wallet + +_Internal on purpose. Only the admin knows which identifier a wallet is owed, and a + device wallet can call anything through `execute`, so an external claim let any owner + take a string bought by someone else. `Registry.assignESIMIdentifier` is the way in. + + Both slots are written here so they cannot disagree. Claim first: the wallet's slot is + set once, so a claim failing after it would strand an identifier the registry never saw._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _eSIMWalletAddress | address | Wallet receiving the identifier | +| _eSIMUniqueIdentifier | string | Identifier being assigned | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | string | The identifier now on the wallet | + +### _claimESIMIdentifier + +```solidity +function _claimESIMIdentifier(string _eSIMUniqueIdentifier, address _eSIMWalletAddress, address _deviceWallet) internal +``` + +Records the wallet holding an eSIM identifier, refusing a second holder + +_A reservation is compared against the device wallet's own identifier rather than + refused outright, since the lazy route claims against its own reservation._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _eSIMUniqueIdentifier | string | Identifier being claimed | +| _eSIMWalletAddress | address | Wallet claiming it | +| _deviceWallet | address | Device wallet holding that eSIM wallet | + ### _updateDeviceWalletInfo ```solidity diff --git a/docs/device-wallet/DeviceWallet.md b/docs/device-wallet/DeviceWallet.md index bff9009e..4199e763 100644 --- a/docs/device-wallet/DeviceWallet.md +++ b/docs/device-wallet/DeviceWallet.md @@ -5,7 +5,7 @@ A user's device: an ERC-4337 account that owns the eSIM wallets bought for that device _A beacon proxy deployed by `DeviceWalletFactory`, owned by a P256 key the user holds. It - funds its eSIM wallets, decides which of them may pull ETH, and is the only party that can + funds its eSIM wallets, decides which of them may spend its money, and is the only party that can move one to another device. Its own owner key rotates through `transferOwnership`, which also tells the registry so the two records cannot drift apart._ @@ -41,21 +41,24 @@ mapping(address => bool) isValidESIMWallet Set to true if the eSIM wallet belongs to this device wallet -### canPullETH +### canPullFunds ```solidity -mapping(address => bool) canPullETH +mapping(address => bool) canPullFunds ``` -Tracks if an associated eSIM wallet can pull ETH or not +Tracks if an associated eSIM wallet may pull this wallet's ETH and tokens -### ETHAccessUpdated +_One flag for both. A wallet trusted with the ETH can already drain the owner, so a + second flag per asset would cost another signature and limit nothing._ + +### FundsAccessUpdated ```solidity -event ETHAccessUpdated(address _eSIMWalletAddress, bool _hasAccessToETH) +event FundsAccessUpdated(address _eSIMWalletAddress, bool _hasAccessToFunds) ``` -Emitted when owner updates ETH access to a particular eSIM wallet +Emitted when owner updates an eSIM wallet's access to this wallet's money ### ETHSent @@ -67,10 +70,20 @@ Emitted when ETH is sent out from the contract _mostly when an eSIM wallet pulls ETH from this contract_ +### TokenSent + +```solidity +event TokenSent(address _token, address _eSIMWalletAddress, uint256 _amount) +``` + +Emitted when an ERC-20 leaves this contract + +_mostly when an eSIM wallet pulls tokens to pay for a data bundle_ + ### ESIMWalletAdded ```solidity -event ESIMWalletAdded(address _eSIMWalletAddress, bool _hasAccessToETH, address _caller) +event ESIMWalletAdded(address _eSIMWalletAddress, bool _hasAccessToFunds, address _caller) ``` Emitted when eSIM wallet is added to this Device Wallet @@ -116,14 +129,6 @@ modifier onlySelfOrESIMWalletBeingRemoved(address _eSIMWalletAddress) Restricts a call to this wallet itself or to the eSIM wallet being removed -### onlyESIMWalletAdminOrRegistry - -```solidity -modifier onlyESIMWalletAdminOrRegistry() -``` - -Restricts a call to the registry or the eSIM wallet admin - ### onlyAssociatedESIMWallets ```solidity @@ -179,7 +184,7 @@ _Called as the beacon proxy's constructor argument, so it always runs in the sam ### deployESIMWallet ```solidity -function deployESIMWallet(bool _hasAccessToETH, uint256 _salt) external returns (address) +function deployESIMWallet(bool _hasAccessToFunds, uint256 _salt) external returns (address) ``` Deploys an eSIM wallet for this device and binds it @@ -187,13 +192,14 @@ Deploys an eSIM wallet for this device and binds it _The new wallet has no eSIM identifier yet. That arrives through `setESIMUniqueIdentifierForAnESIMWallet` once the eSIM itself has been created. - ETH access is granted only afterwards, by the owner, with `toggleAccessToETH`._ + Access to this wallet's money is granted only afterwards, by the owner, with + `toggleAccessToFunds`._ #### Parameters | Name | Type | Description | | ---- | ---- | ----------- | -| _hasAccessToETH | bool | Must be false | +| _hasAccessToFunds | bool | Must be false | | _salt | uint256 | CREATE2 salt for the new eSIM wallet | #### Return Values @@ -210,8 +216,8 @@ function pullETH(uint256 _amount) external returns (uint256) Allow the eSIM wallets associated with this device wallet to pull ETH (for data bundles) -_Refused while the protocol is paused, and refused for a wallet whose ETH access the - owner has revoked._ +_Refused while the protocol is paused, and refused for a wallet whose access the owner + has revoked._ #### Parameters @@ -225,87 +231,84 @@ _Refused while the protocol is paused, and refused for a wallet whose ETH access | ---- | ---- | ----------- | | [0] | uint256 | The amount pulled | -### transferOwnership +### pullToken ```solidity -function transferOwnership(bytes32[2] newOwner) public returns (bytes32[2]) +function pullToken(address _token, uint256 _amount) external returns (uint256) ``` -Replaces the P256 key that owns this account - -_The registry holds its own record of which key owns this wallet, and the deploy paths - keep one key to one wallet. Rotating without telling it leaves the retired key named - as the owner and leaves the key taking over unregistered, free for a second wallet to - claim. `super` runs after the key check because it carries the `onlySelf` guard and - because the registry call is an external one, so the local write has to land before it. +Allow an associated eSIM wallet to pull an ERC-20 (for data bundles) - A key that cannot verify a signature bricks the wallet for good: this function is - reachable only through `execute`, which needs a signature, so there is no rotating - back and no reaching the balance. The deploy paths reject such a key and this path - writes the same storage, so it has to reject it too._ +_Same gate and pause check as `pullETH`. It exists so the admin can charge this wallet + without an owner signature in that transaction; an owner buying for themselves can + batch the transfer and the purchase through `executeBatch` instead._ #### Parameters | Name | Type | Description | | ---- | ---- | ----------- | -| newOwner | bytes32[2] | X,Y co-ordinates of the P256 key taking over | +| _token | address | ERC-20 being pulled | +| _amount | uint256 | Amount in that token's smallest unit | #### Return Values | Name | Type | Description | | ---- | ---- | ----------- | -| [0] | bytes32[2] | The owner key now in force | +| [0] | uint256 | The amount pulled | -### setESIMUniqueIdentifierForAnESIMWallet +### transferOwnership ```solidity -function setESIMUniqueIdentifierForAnESIMWallet(address _eSIMWalletAddress, string _eSIMUniqueIdentifier) public returns (string) +function transferOwnership(bytes32[2] newOwner) public returns (bytes32[2]) ``` -Allow wallet owner or admin to set unique identifier for their eSIM wallet +Replaces the P256 key that owns this account -_The registry is also a caller, which is how a wallet deployed on the lazy path gets its - identifier in the same transaction as its deployment. +_The registry holds its own record of which key owns this wallet, and the deploy paths + keep one key to one wallet. Rotating without telling it leaves the retired key named + as the owner and leaves the key taking over unregistered, free for a second wallet to + claim. `super` runs after the key check because it carries the `onlySelf` guard and + because the registry call is an external one, so the local write has to land before it. - The claim goes in before the wallet is written, and the order matters: the wallet's own - slot is set once and for good, so a claim that failed afterwards would leave a wallet - holding an identifier the registry does not record._ + A key that cannot verify a signature bricks the wallet for good: this function is + reachable only through `execute`, which needs a signature, so there is no rotating + back and no reaching the balance. The deploy paths reject such a key and this path + writes the same storage, so it has to reject it too._ #### Parameters | Name | Type | Description | | ---- | ---- | ----------- | -| _eSIMWalletAddress | address | Address of the eSIM wallet smart contract | -| _eSIMUniqueIdentifier | string | String unique identifier for the eSIM wallet | +| newOwner | bytes32[2] | X,Y co-ordinates of the P256 key taking over | #### Return Values | Name | Type | Description | | ---- | ---- | ----------- | -| [0] | string | The identifier now written on the eSIM wallet | +| [0] | bytes32[2] | The owner key now in force | -### toggleAccessToETH +### toggleAccessToFunds ```solidity -function toggleAccessToETH(address _eSIMWalletAddress, bool _hasAccessToETH) public +function toggleAccessToFunds(address _eSIMWalletAddress, bool _hasAccessToFunds) public ``` -Allow owner to revoke or give access to any associated eSIM wallet for pulling ETH +Allow owner to revoke or give an associated eSIM wallet access to this wallet's money -_The only way ETH access is ever granted. Binding a wallet never carries it, so a +_The only way that access is ever granted. Binding a wallet never carries it, so a revocation stands until the owner signs a grant._ #### Parameters | Name | Type | Description | | ---- | ---- | ----------- | -| _eSIMWalletAddress | address | Address of the eSIM wallet to toggle ETH access for | -| _hasAccessToETH | bool | Set to true to give access, false to revoke access | +| _eSIMWalletAddress | address | Address of the eSIM wallet to toggle access for | +| _hasAccessToFunds | bool | Set to true to give access, false to revoke access | ### addESIMWallet ```solidity -function addESIMWallet(address _eSIMWalletAddress, bool _hasAccessToETH) public +function addESIMWallet(address _eSIMWalletAddress, bool _hasAccessToFunds) public ``` Allow the device wallet factory or the wallet owner to add new eSIM wallet to this device wallet @@ -315,7 +318,7 @@ Allow the device wallet factory or the wallet owner to add new eSIM wallet to th | Name | Type | Description | | ---- | ---- | ----------- | | _eSIMWalletAddress | address | Address of the eSIM wallet to be added | -| _hasAccessToETH | bool | Must be false. ETH access is granted only through `toggleAccessToETH` | +| _hasAccessToFunds | bool | Must be false. Access is granted only through `toggleAccessToFunds` | ### removeESIMWallet @@ -335,7 +338,7 @@ Allow the device wallet owner or the eSIM wallet to remove any eSIM wallet bound ### _addESIMWallet ```solidity -function _addESIMWallet(address _eSIMWalletAddress, bool _hasAccessToETH) internal +function _addESIMWallet(address _eSIMWalletAddress, bool _hasAccessToFunds) internal ``` Binds an eSIM wallet to this device wallet and records it with the registry @@ -343,7 +346,7 @@ Binds an eSIM wallet to this device wallet and records it with the registry _Refuses a wallet this device wallet does not already own, so binding cannot run ahead of the ownership handover. - A bind never carries ETH access. `toggleAccessToETH` is `onlySelf` and the only writer + A bind never carries access to this wallet's money. `toggleAccessToFunds` is `onlySelf` and the only writer of a `true`, so no bind can undo the owner's revocation. Asking for access here reverts rather than being downgraded in silence._ @@ -352,7 +355,7 @@ _Refuses a wallet this device wallet does not already own, so binding cannot run | Name | Type | Description | | ---- | ---- | ----------- | | _eSIMWalletAddress | address | Address of the eSIM wallet to bind | -| _hasAccessToETH | bool | Must be false | +| _hasAccessToFunds | bool | Must be false | ### _transferETH diff --git a/docs/esim-wallet/ESIMWallet.md b/docs/esim-wallet/ESIMWallet.md index f8319f2a..62c05c66 100644 --- a/docs/esim-wallet/ESIMWallet.md +++ b/docs/esim-wallet/ESIMWallet.md @@ -49,17 +49,18 @@ address newRequestedOwner Address of the owner (device wallet) that becomes the new owner -### dataBundlePriceCap +### priceCapUSDCents ```solidity -uint256 dataBundlePriceCap +uint64 priceCapUSDCents ``` -Most this wallet may be charged for one data bundle, or zero to follow the registry +Most this wallet may be charged for one data bundle, in USD cents, or zero to follow + the registry -_Appended, and this contract is a leaf, so the slot lands past everything a live proxy - already holds and reads zero there. Zero has to keep meaning "no limit of my own" for - that reason, which is why the fallback lives on the registry rather than here._ +_Declared here so it shares a slot with `newRequestedOwner`. Solidity packs in + declaration order, so moving this line costs that slot. A handover clears both, which + is then one write instead of two. Zero means "follow the registry", not "no ceiling"._ ### ESIMWalletDeployed @@ -72,11 +73,39 @@ Emitted when the eSIM wallet is deployed ### DataBundleBought ```solidity -event DataBundleBought(string _dataBundleID, uint256 _dataBundlePrice, uint256 _ethFromUser) +event DataBundleBought(bytes32 _dataBundleID, uint64 _priceUSDCents, uint256 _priceWei, uint256 _ethFromUser, bytes32 _paymentReference) ``` Emitted when the payment for a data bundle is made +### DataBundleBoughtWithToken + +```solidity +event DataBundleBoughtWithToken(bytes32 _dataBundleID, uint64 _priceUSDCents, bytes32 _asset, address _token, uint256 _amountSpent, bytes32 _paymentReference) +``` + +Emitted when a data bundle is paid for in an ERC-20 + +_The adapter emits the settlement. This one is for an indexer watching one wallet._ + +### TokenSentToDeviceWallet + +```solidity +event TokenSentToDeviceWallet(address _token, address _deviceWallet, uint256 _amount) +``` + +Emitted when an ERC-20 is returned to the owning device wallet + +### DataBundleSettlementRecorded + +```solidity +event DataBundleSettlementRecorded(bytes32 _dataBundleID, uint64 _priceUSDCents, enum Settlement _settlement) +``` + +Emitted when a purchase paid for outside the protocol is recorded here + +_The registry emits the full record. This one is for an indexer watching one wallet._ + ### ESIMUniqueIdentifierInitialised ```solidity @@ -119,10 +148,10 @@ event OwnershipTransferRevoked(address _currentOwner, address _revokedOwner) Emitted when the current owner revoked the ownership transfer request -### DataBundlePriceCapUpdated +### PriceCapUSDCentsUpdated ```solidity -event DataBundlePriceCapUpdated(uint256 _cap) +event PriceCapUSDCentsUpdated(uint64 _cap) ``` Emitted when the owner sets this wallet's own price ceiling @@ -191,7 +220,9 @@ function setESIMUniqueIdentifier(string _eSIMUniqueIdentifier) external Since buying the eSIM (along with data bundle) happens before the identifier is generated, the identifier is to be set separately after the wallet is deployed and eSIM is created -_This function can only be called once_ +_Set once, and only by the registry, which records the claim in the same call. The + owning device wallet used to be the caller, which let an owner write a string the + registry has no record of._ #### Parameters @@ -199,10 +230,10 @@ _This function can only be called once_ | ---- | ---- | ----------- | | _eSIMUniqueIdentifier | string | String that uniquely identifies eSIM wallet | -### setDataBundlePriceCap +### setPriceCapUSDCents ```solidity -function setDataBundlePriceCap(uint256 _cap) external +function setPriceCapUSDCents(uint64 _cap) external ``` Sets the most this wallet may be charged for one data bundle @@ -217,7 +248,7 @@ _Only the owning device wallet, which means the person holding its P256 key: rea | Name | Type | Description | | ---- | ---- | ----------- | -| _cap | uint256 | Maximum price in wei, or zero to follow the registry | +| _cap | uint64 | Maximum price in USD cents, or zero to follow the registry | ### populateHistory @@ -298,20 +329,53 @@ Deliberately not nonReentrant. removeESIMWallet calls this from inside a try/cat ### buyDataBundle ```solidity -function buyDataBundle(struct DataBundleDetails _dataBundleDetail) public payable returns (bool) +function buyDataBundle(struct DataBundleDetails _dataBundleDetail, uint256 _priceWei, bytes32 _paymentReference) public payable returns (bool) ``` -Pays the vault for one data bundle and records the purchase +Pays the vault for one data bundle in ETH and records the purchase _Callable by the owning device wallet or by the admin, since the admin is the party that knows the price. Any shortfall is pulled from the device wallet, which is why the price - is checked against a ceiling the admin cannot raise._ + is checked against a ceiling the admin cannot raise. + + The ceiling is in cents and the ETH sent is in wei, and nothing onchain converts + between them. So the ceiling limits what gets recorded, not what gets sent, and + `_priceWei` is taken on trust. The token path closes that gap by working the amount + out from the price instead._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _dataBundleDetail | struct DataBundleDetails | Data bundle being bought. Its settlement field is overwritten here. | +| _priceWei | uint256 | ETH actually being sent to the vault | +| _paymentReference | bytes32 | The offchain order id. Spent once, so a retry of a call that already landed cannot charge the user twice. | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | bool | True if the transaction is successful | + +### buyDataBundleWithToken + +```solidity +function buyDataBundleWithToken(struct DataBundleDetails _dataBundleDetail, bytes32 _asset, uint256 _maxAmountIn, bytes32 _paymentReference) public returns (bool) +``` + +Pays the vault for one data bundle in an ERC-20 and records the purchase + +_The adapter works the amount out from the price, so unlike the ETH path there is no + second figure to take on trust. Any shortfall is pulled from the device wallet._ #### Parameters | Name | Type | Description | | ---- | ---- | ----------- | -| _dataBundleDetail | struct DataBundleDetails | Details of the data bundle being bought. (dataBundleID, dataBundlePrice) | +| _dataBundleDetail | struct DataBundleDetails | Data bundle being bought. Its settlement field is overwritten here. | +| _asset | bytes32 | Symbol of the currency to pay in | +| _maxAmountIn | uint256 | Most of that currency the buyer will spend, in its smallest unit | +| _paymentReference | bytes32 | The offchain order id. Spent once, so a retry of a call that already landed cannot charge the user twice. | #### Return Values @@ -319,6 +383,48 @@ _Callable by the owning device wallet or by the admin, since the admin is the pa | ---- | ---- | ----------- | | [0] | bool | True if the transaction is successful | +### sendTokenToDeviceWallet + +```solidity +function sendTokenToDeviceWallet(address _token, uint256 _amount) external returns (uint256) +``` + +Sends an ERC-20 held here back to the owning device wallet + +_The callback on `removeESIMWallet` moves ETH only, so without this a token balance + would be stranded when the wallet changes hands._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _token | address | ERC-20 to send back | +| _amount | uint256 | Amount in that token's smallest unit | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | uint256 | The amount sent | + +### recordSettledPurchase + +```solidity +function recordSettledPurchase(struct DataBundleDetails _dataBundleDetail) external +``` + +Appends a purchase paid for outside the protocol + +_No money moves here. Nothing onchain saw this payment, so the ceiling is the only + limit on what the admin can write into a user's history. Checked here and not on the + registry because the wallet's own ceiling lives here._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _dataBundleDetail | struct DataBundleDetails | The purchase to record | + ### transferOwnership ```solidity diff --git a/docs/interfaces/IPaymentRegistry.md b/docs/interfaces/IPaymentRegistry.md new file mode 100644 index 00000000..0829b942 --- /dev/null +++ b/docs/interfaces/IPaymentRegistry.md @@ -0,0 +1,25 @@ +# Solidity API + +## IPaymentRegistry + +The two things the payment adapter asks the registry on a settlement + +_An interface rather than an import of `Registry`, which already imports the adapter. It + also keeps the adapter's view of the registry down to what it reads._ + +### vault + +```solidity +function vault() external view returns (address) +``` + +Address that receives payments for data bundles + +### isESIMWalletValid + +```solidity +function isESIMWalletValid(address eSIMWallet) external view returns (address) +``` + +The device wallet an eSIM wallet belongs to, or zero if the registry has no record + diff --git a/docs/payments/PaymentAdapter.md b/docs/payments/PaymentAdapter.md new file mode 100644 index 00000000..0b297bf1 --- /dev/null +++ b/docs/payments/PaymentAdapter.md @@ -0,0 +1,314 @@ +# Solidity API + +## Asset + +One currency the protocol will price a data bundle in + +_23 bytes, so it fits one slot with nine to spare. New fields have to stay inside those + nine bytes: a second slot moves every entry in the mapping, and a live table cannot be + moved._ + +```solidity +struct Asset { + bool allowed; + bool isDollarUnit; + uint8 decimals; + address token; +} +``` + +## PaymentAdapter + +Holds the accepted currencies, converts prices, moves tokens to the vault, and spends + payment references + +_Prices cross contract boundaries in USD cents only, and this is the one place a cent + figure becomes a token amount, so a decimals mismatch cannot happen rather than having to + be checked for. There are no price feeds, so a currency not already in dollars has no + conversion here. UUPS and not swappable: `usedReferences` is replay protection, and a + fresh copy would re-open every reference already spent. + + Tokens pass through in one call and never rest here._ + +### registry + +```solidity +address registry +``` + +Registry contract address, the only caller allowed to spend a payment reference + +### settlementToken + +```solidity +address settlementToken +``` + +Currency the vault is meant to end up holding + +_Nothing reads it yet. Set at initialisation anyway, so adding the swap path later needs + no migration transaction on every chain._ + +### assets + +```solidity +mapping(bytes32 => struct Asset) assets +``` + +Every currency the protocol will accept or record a payment in + +### usedReferences + +```solidity +mapping(bytes32 => bool) usedReferences +``` + +Payment references already spent, protocol-wide + +_One reference is one offchain payment. The backend retries the whole onchain step on + any failure, so without this a retry of a call that already landed records it twice._ + +### PaymentAdapterInitialized + +```solidity +event PaymentAdapterInitialized(address _registry, address _settlementToken) +``` + +Emitted when this contract is wired up + +### AssetUpdated + +```solidity +event AssetUpdated(bytes32 _symbol, bool _allowed, bool _isDollarUnit, uint8 _decimals, address _token) +``` + +Emitted when a currency enters the vocabulary or its entry changes + +### PaymentReferenceConsumed + +```solidity +event PaymentReferenceConsumed(bytes32 _paymentReference) +``` + +Emitted when a payment reference is spent + +### PaymentSettled + +```solidity +event PaymentSettled(bytes32 _symbol, address _eSIMWallet, address _vault, uint64 _priceUSDCents, uint256 _spent, uint256 _refunded) +``` + +Emitted when a data bundle is paid for in tokens through this contract + +_One address for an indexer to watch instead of every eSIM wallet. The vault is + recorded because it can be rotated, and reconciliation needs the one that was paid._ + +### onlyRegistry + +```solidity +modifier onlyRegistry() +``` + +Restricts a call to the registry + +### onlyProtocolESIMWallet + +```solidity +modifier onlyProtocolESIMWallet() +``` + +Restricts a call to an eSIM wallet the registry has a record of + +_Read from the registry on every call rather than held here, so a wallet the registry + has let go cannot keep paying through this contract._ + +### constructor + +```solidity +constructor() public +``` + +_Locks the implementation contract itself, so nobody can initialise and own it directly._ + +### initialize + +```solidity +function initialize(address _registry, address _settlementToken, address _upgradeManager) external +``` + +Wires the adapter to the registry and names the currency the vault should hold + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _registry | address | Registry contract address | +| _settlementToken | address | Token the vault is meant to end up holding, normally USDC | +| _upgradeManager | address | Address that owns this contract and authorises its upgrades | + +### registerAsset + +```solidity +function registerAsset(bytes32 _symbol, struct Asset _asset) external +``` + +Adds a currency the protocol will accept or record a payment in + +_Owner and not admin. The admin names the price on every purchase, so letting it add + currencies too would let it invent a token address to be paid into._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _symbol | bytes32 | Short ASCII symbol, "USDC" or "USD" or "TON" | +| _asset | struct Asset | The entry to write | + +### updateAsset + +```solidity +function updateAsset(bytes32 _symbol, struct Asset _asset) external +``` + +Changes an existing currency entry, including withdrawing it + +_Separate from `registerAsset` so a typo in a new symbol cannot silently overwrite a + currency already in use. Set `allowed` to false to withdraw one._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _symbol | bytes32 | Symbol of the currency being changed | +| _asset | struct Asset | The entry to write in its place | + +### quote + +```solidity +function quote(bytes32 _symbol, uint64 _priceUSDCents) external view returns (uint256 amountIn) +``` + +Turns a price in USD cents into an amount of one currency's smallest unit + +_The only place in the protocol that does this, so there is never a second figure to + check this one against. A currency not already in dollars needs a rate, and there are + no price feeds here, so it reverts instead of guessing._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _symbol | bytes32 | Currency the price is being expressed in | +| _priceUSDCents | uint64 | Price in USD cents | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| amountIn | uint256 | Amount in that currency's own smallest unit | + +### resolveAsset + +```solidity +function resolveAsset(bytes32 _symbol) external view returns (struct Asset) +``` + +Reads back a currency entry, reverting if it is not allowed + +_Callers read the token address and decimals from here rather than passing them in, so + they stay the same across every record._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _symbol | bytes32 | Currency to read | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | struct Asset | The stored entry | + +### settle + +```solidity +function settle(bytes32 _symbol, uint64 _priceUSDCents, uint256 _amountIn, address _refundTo) external returns (uint256 spent, uint256 refunded) +``` + +Pays the vault for one data bundle out of tokens the caller has already sent here + +_The caller funds this contract and calls settle in the same transaction, which leaves + the tokens here for a swap to be added later without changing this signature. + + The refund is bounded by what the caller funded rather than by the balance, so a token + sent here by mistake is not swept out by the next purchase. A fee-on-transfer token + delivers less than declared and fails the funding check._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _symbol | bytes32 | Currency being paid in | +| _priceUSDCents | uint64 | Price of the data bundle, in USD cents | +| _amountIn | uint256 | Amount the caller has funded, and the most it is willing to spend | +| _refundTo | address | Address anything unspent goes back to | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| spent | uint256 | Amount that reached the vault | +| refunded | uint256 | Amount returned to `_refundTo`, always zero until a swap can overshoot | + +### consumePaymentReference + +```solidity +function consumePaymentReference(bytes32 _paymentReference) external +``` + +Spends a payment reference, refusing one already spent + +_Registry only, on both payment paths, so one reference cannot be spent once on each._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _paymentReference | bytes32 | Hash tying this purchase to the offchain payment behind it | + +### renounceOwnership + +```solidity +function renounceOwnership() public pure +``` + +Ownership of this contract is never renounced + +_The owner is the only caller `_authorizeUpgrade` accepts and the only one that can + change the list of currencies. Renouncing would freeze both for good._ + +### _authorizeUpgrade + +```solidity +function _authorizeUpgrade(address newImplementation) internal +``` + +Restricts UUPS upgrades to the owner + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| newImplementation | address | Address of the implementation being moved to | + +### upgradeManager + +```solidity +function upgradeManager() public view returns (address) +``` + +Address that can upgrade this contract + +_Reads through to the owner rather than holding a second copy that could disagree._ + From d4587b388ad735e46e50384757ace9ff61991d13 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:16:52 +0530 Subject: [PATCH 31/75] Act on the static analysis of the token path Records the purchase before any token moves, emits what the adapter reports spending rather than what it was sent, and puts the reentrancy guard ahead of the gates that call out to the registry. --- .gas-snapshot | 64 ++++++++++----------- certora/specs/PaymentAdapter.spec | 73 ++++++++++++++++++++---- contracts/device-wallet/DeviceWallet.sol | 2 +- contracts/esim-wallet/ESIMWallet.sol | 14 +++-- contracts/payments/PaymentAdapter.sol | 2 +- snapshots/ESIMWallet.Operations.json | 2 +- 6 files changed, 106 insertions(+), 51 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 599ec5f8..02609046 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -44,14 +44,14 @@ AdminStorageLayoutTest:test_layout_readinessComesOnlyFromTheTimestamp() (gas: 90 AdminStorageLayoutTest:test_layout_theContractAddsNoStorageOfItsOwn() (gas: 7864) AdminStorageLayoutTest:test_layout_theDelayFloorIsNotInStorage() (gas: 8697) AdminStorageLayoutTest:test_layout_theGuardianRoleLivesInTheSameMappingAsTheRest() (gas: 17469) -AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2807468) +AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2803252) AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 4252888) AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3615351) -AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2879086) +AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2874870) AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12932702) AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3658751) AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 12981881) -AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5940751) +AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5936539) AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 4286072) AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 4225962) AdminUpgradesTest:test_upgrade_rejectsAProposerActingDirectly() (gas: 4225984) @@ -190,10 +190,10 @@ DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion( DeviceWalletTokensTest:test_pullToken_emitsTokenSent() (gas: 77501) DeviceWalletTokensTest:test_pullToken_movesATokenThatReturnsNothing() (gas: 327533) DeviceWalletTokensTest:test_pullToken_movesTheAmountToTheCallingWallet() (gas: 79146) -DeviceWalletTokensTest:test_pullToken_revertsForACallerThatIsNotAnAssociatedWallet() (gas: 24150) +DeviceWalletTokensTest:test_pullToken_revertsForACallerThatIsNotAnAssociatedWallet() (gas: 29202) DeviceWalletTokensTest:test_pullToken_revertsForATokenTheWalletDoesNotHold() (gas: 653145) DeviceWalletTokensTest:test_pullToken_revertsForAWalletWithoutAccess() (gas: 44181) -DeviceWalletTokensTest:test_pullToken_revertsForAnotherDevicesWallet() (gas: 23858) +DeviceWalletTokensTest:test_pullToken_revertsForAnotherDevicesWallet() (gas: 28910) DeviceWalletTokensTest:test_pullToken_revertsOnTheZeroTokenAddress() (gas: 39574) DeviceWalletTokensTest:test_pullToken_revertsOnZeroAmount() (gas: 41332) DeviceWalletTokensTest:test_pullToken_revertsWhenTheBalanceIsShort() (gas: 49128) @@ -209,7 +209,7 @@ ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617341) ESIMWalletFactoryTest:test_deployESIMWallet_unauthorised() (gas: 35389) ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065366) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3819513) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3815296) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_rejectsTheZeroAddress() (gas: 18268) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_unauthorised() (gas: 21012) ESIMWalletGuardsTest:test_buyDataBundle_rejectsAZeroPrice() (gas: 1007771) @@ -261,28 +261,28 @@ ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510960) ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511552) ESIMWalletTest:test_transferOwnership() (gas: 1508834) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 276492) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 263271) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 273024) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 270004) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 284848) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 276849) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 270253) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() (gas: 338239) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedOnTheETHPath() (gas: 288327) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1489275) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86949) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 105182) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 146606) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 70602) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 128970) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 287670) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79787) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79398) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 152308) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 125105) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 130692) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88591) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 276475) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 263254) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 273007) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 269987) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 284831) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 276832) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 270236) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() (gas: 338228) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedOnTheETHPath() (gas: 288310) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1485428) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86955) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 105188) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 146612) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 75657) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 128976) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 287659) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79793) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79404) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 219241) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 125111) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 130698) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88597) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 265777) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 267536) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() (gas: 53620) @@ -337,7 +337,7 @@ IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: ImplementationLocksTest:test_DeviceWalletFactory_implementationCannotBeInitialized() (gas: 3065558) ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3482510) ImplementationLocksTest:test_ESIMWalletFactory_implementationCannotBeInitialized() (gas: 1991966) -ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2702220) +ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2698011) ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3591688) ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 4224587) LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2178557) @@ -437,9 +437,9 @@ PaymentAdapterSettleTest:test_settle_movesATokenThatReturnsNothing() (gas: 34128 PaymentAdapterSettleTest:test_settle_paysTheVaultCurrentlySet() (gas: 118467) PaymentAdapterSettleTest:test_settle_paysTheVaultTheQuotedAmount() (gas: 103697) PaymentAdapterSettleTest:test_settle_refundsWhatThePriceDidNotNeed() (gas: 128419) -PaymentAdapterSettleTest:test_settle_refusesAReentrantCallFromTheToken() (gas: 1280973) -PaymentAdapterSettleTest:test_settle_revertsForACallerTheRegistryDoesNotKnow() (gas: 80743) -PaymentAdapterSettleTest:test_settle_revertsForADeviceWallet() (gas: 81429) +PaymentAdapterSettleTest:test_settle_refusesAReentrantCallFromTheToken() (gas: 1277381) +PaymentAdapterSettleTest:test_settle_revertsForACallerTheRegistryDoesNotKnow() (gas: 85771) +PaymentAdapterSettleTest:test_settle_revertsForADeviceWallet() (gas: 86457) PaymentAdapterSettleTest:test_settle_revertsForAFeeOnTransferToken() (gas: 853158) PaymentAdapterSettleTest:test_settle_revertsForAWithdrawnAsset() (gas: 51510) PaymentAdapterSettleTest:test_settle_revertsForAnAssetThatNeedsASwap() (gas: 675945) diff --git a/certora/specs/PaymentAdapter.spec b/certora/specs/PaymentAdapter.spec index ba281110..4bc2031c 100644 --- a/certora/specs/PaymentAdapter.spec +++ b/certora/specs/PaymentAdapter.spec @@ -6,16 +6,19 @@ /// because a reference is one offchain payment and the backend retries the whole onchain step on /// any failure, so a reference that could be spent again is a user charged once and billed twice. /// -/// Scope. This contract calls nothing out, so there is no cross-contract statement to make here. -/// The dispatch list is empty, which leaves the one delegate call, `upgradeToAndCall` reaching -/// `Address.functionDelegateCall`, on the NONDET default. Nothing below says anything about what a -/// new implementation does to this storage on the way through an upgrade. That is deliberate, since -/// the implementation is not known here, but it means the write-once rules cover every caller -/// except the one that replaces the code. +/// Scope. `settle` calls the registry and a token, and both live outside the scene, so each of +/// those four signatures is summarised NONDET by name. Naming them matters: a call whose selector +/// is known and whose callee is not never reaches the `unresolved external` line, so leaving them +/// out hands the prover an AUTO havoc instead. The dispatch list is empty, which leaves the one +/// delegate call, `upgradeToAndCall` reaching `Address.functionDelegateCall`, on the NONDET +/// default. Nothing below says anything about what a new implementation does to this storage on the +/// way through an upgrade, which is deliberate since the implementation is not known here. /// -/// The reference rules are the release 1 form of the property section 9 states over `settle`. There -/// is no `settle` yet, so what is provable now is the half that does not depend on it: a reference -/// is marked by one entry point, only for the registry, and never goes back. +/// What is not stated here. Whether the vault actually received the tokens is a fact about the +/// token, which is not in the scene and is chosen by the owner when a currency is registered. The +/// rules below cover the half that is this contract's own: the amount it decides to move is the +/// amount it quoted, and settling changes neither the currency table nor the reference mapping. +/// The transfer itself is carried by the unit and invariant tests. /// /// `renounceOwnership()` is overridden to revert unconditionally, so no rule body can complete on /// it. It is filtered out of every parametric rule and pinned by `theRenouncePathAlwaysReverts` @@ -38,7 +41,13 @@ methods { function settlementToken() external returns (address) envfree; function owner() external returns (address) envfree; - /// Nothing outside this contract is in the scene. + /// The four calls `settle` makes out of the scene, named one signature at a time. + function _.vault() external => NONDET; + function _.isESIMWalletValid(address) external => NONDET; + function _.balanceOf(address) external => NONDET; + function _.transfer(address, uint256) external => NONDET; + + /// Nothing else outside this contract is in the scene. unresolved external in _._ => DISPATCH [] default NONDET; } @@ -243,6 +252,50 @@ rule spendingAReferenceTouchesNoOther(method f, bytes32 spent, bytes32 other) assert !usedReferences(other), "spending one payment reference marked another"; } +// --------------------------------------------------------------------------------------------- +// Settlement +// --------------------------------------------------------------------------------------------- + +/// A settlement spends what the quote said and no more. +/// +/// The two figures are worked out by the same code, and this is what says so. If they could differ, +/// the wallet would be told one price and the vault paid another, with nothing else in the protocol +/// holding a second copy to catch it. +rule aSettlementSpendsTheQuotedAmount(bytes32 symbol, uint64 priceUSDCents, uint256 amountIn, address refundTo) { + env callEnv; + uint256 spent; + uint256 refunded; + spent, refunded = settle(callEnv, symbol, priceUSDCents, amountIn, refundTo); + + assert spent == quote(symbol, priceUSDCents), "a settlement spent something other than the quote"; +} + +/// A settlement never moves more than the caller funded. +/// +/// The refund is bounded by what came in rather than by the balance, which is what keeps a token +/// sent here by mistake from leaving with the next purchase. +rule aSettlementNeverMovesMoreThanWasFunded(bytes32 symbol, uint64 priceUSDCents, uint256 amountIn, address refundTo) { + env callEnv; + uint256 spent; + uint256 refunded; + spent, refunded = settle(callEnv, symbol, priceUSDCents, amountIn, refundTo); + + assert spent + refunded == to_mathint(amountIn), "a settlement moved a different amount than was funded"; +} + +/// A settlement in a currency the table has withdrawn is refused. +/// +/// The withdrawal has to close the money path as well as the pricing one, or a currency the +/// protocol no longer accepts could still be paid in. +rule aWithdrawnCurrencyNeverSettles(bytes32 symbol, uint64 priceUSDCents, uint256 amountIn, address refundTo) { + require !currencyIsAllowed(symbol); + + env callEnv; + settle@withrevert(callEnv, symbol, priceUSDCents, amountIn, refundTo); + + assert lastReverted, "a currency the table does not allow was settled in"; +} + // --------------------------------------------------------------------------------------------- // Write-once state // --------------------------------------------------------------------------------------------- diff --git a/contracts/device-wallet/DeviceWallet.sol b/contracts/device-wallet/DeviceWallet.sol index a0c5226c..ab1df51e 100644 --- a/contracts/device-wallet/DeviceWallet.sol +++ b/contracts/device-wallet/DeviceWallet.sol @@ -227,8 +227,8 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// @return The amount pulled function pullToken(address _token, uint256 _amount) external - onlyAssociatedESIMWallets nonReentrant + onlyAssociatedESIMWallets returns (uint256) { registry.requireNotPaused(); diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 1d162bad..b182b4bb 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -374,7 +374,7 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade bytes32 _asset, uint256 _maxAmountIn, bytes32 _paymentReference - ) public onlyDeviceWalletOrESIMWalletAdmin nonReentrant returns (bool) { + ) external nonReentrant onlyDeviceWalletOrESIMWalletAdmin returns (bool) { Registry registry = deviceWallet.registry(); registry.requireNotPaused(); if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); @@ -396,24 +396,26 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade uint256 amountIn = adapter.quote(_asset, _dataBundleDetail.priceUSDCents); if(amountIn > _maxAmountIn) revert Errors.SettlementAboveMax(amountIn, _maxAmountIn); + // Recorded before any token moves, so nothing downstream reads a history missing this + transactionHistory.push(_dataBundleDetail); + IERC20 token = IERC20(asset.token); uint256 held = token.balanceOf(address(this)); if(held < amountIn) deviceWallet.pullToken(asset.token, amountIn - held); - // Recorded before the money leaves, so nothing downstream reads a history missing this - transactionHistory.push(_dataBundleDetail); - // Funded first, then told to settle. That is what lets a swap be added there later // without changing anything here. token.safeTransfer(adapterAddress, amountIn); - adapter.settle(_asset, _dataBundleDetail.priceUSDCents, amountIn, address(this)); + (uint256 spent,) = adapter.settle(_asset, _dataBundleDetail.priceUSDCents, amountIn, address(this)); + // What the adapter reports spending, not what this wallet sent it. Anything it did not + // need comes back here, and the event should say which of the two happened. emit DataBundleBoughtWithToken( _dataBundleDetail.id, _dataBundleDetail.priceUSDCents, _asset, asset.token, - amountIn, + spent, _paymentReference ); diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index 673f7029..692271f0 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -216,7 +216,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab uint64 _priceUSDCents, uint256 _amountIn, address _refundTo - ) external onlyProtocolESIMWallet nonReentrant returns (uint256 spent, uint256 refunded) { + ) external nonReentrant onlyProtocolESIMWallet returns (uint256 spent, uint256 refunded) { if(_priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); if(_refundTo == address(0)) revert Errors.ZeroAddress("_refundTo"); diff --git a/snapshots/ESIMWallet.Operations.json b/snapshots/ESIMWallet.Operations.json index 6cd5cc2c..51843b51 100644 --- a/snapshots/ESIMWallet.Operations.json +++ b/snapshots/ESIMWallet.Operations.json @@ -2,7 +2,7 @@ "acceptOwnershipTransfer": "3551", "buyDataBundle: pulls from the device wallet": "108255", "buyDataBundle: wallet already holds the price": "94249", - "buyDataBundleWithToken: pulls from the device wallet": "156943", + "buyDataBundleWithToken: pulls from the device wallet": "156926", "buyDataBundleWithToken: wallet already holds the price": "127357", "deployESIMWallet: through the factory": "289547", "populateHistory: 10 entries": "489385", From 07714bd10122a62a10671271e9826be7ad40e82c Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:32:26 +0530 Subject: [PATCH 32/75] Say spending rather than pulling ETH where one flag covers both --- test/foundry/invariant-testing/ETHInvariants.t.sol | 11 ++++++----- .../invariant-testing/WalletInvariants.t.sol | 8 ++++---- .../invariant-testing/handler/WalletHandler.sol | 8 ++++---- .../device-wallet/base/DeviceWalletFixture.sol | 14 +++++++------- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/test/foundry/invariant-testing/ETHInvariants.t.sol b/test/foundry/invariant-testing/ETHInvariants.t.sol index 7517a10d..010abfc4 100644 --- a/test/foundry/invariant-testing/ETHInvariants.t.sol +++ b/test/foundry/invariant-testing/ETHInvariants.t.sol @@ -38,11 +38,12 @@ contract ETHInvariantsTest is CampaignBase { assertEq(address(lazyWalletRegistry).balance, 0, "Lazy wallet registry is holding ETH"); } - /// @notice The right to pull ETH only ever comes from the device wallet owner - /// @dev A pair holding the right with no grant recorded is access the campaign got some other + /// @notice The right to spend a device wallet's money only ever comes from its owner + /// @dev One flag covers ETH and tokens, so this is the whole spending right rather than the ETH + /// half. A pair holding it with no grant recorded is access the campaign got some other /// way. Both the current device wallet and the last one are checked, since the two differ /// while a transfer is outstanding. - function invariant_ETHAccessOnlyEverCameFromTheOwner() public view { + function invariant_fundsAccessOnlyEverCameFromTheOwner() public view { uint256 count = state.eSIMWalletCount(); for (uint256 i = 0; i < count; ++i) { address wallet = state.eSIMWallets(i); @@ -51,14 +52,14 @@ contract ETHInvariantsTest is CampaignBase { } } - /// @notice Fails if a pair may pull ETH without the owner having granted it + /// @notice Fails if a pair may spend without the owner having granted it function _assertGranted(address device, address wallet) private view { if (device == address(0)) return; if (!MockDeviceWallet(payable(device)).canPullFunds(wallet)) return; assertTrue( state.ghost_ethAccessGranted(device, wallet), - "An eSIM wallet may pull ETH without the owner ever having granted it" + "An eSIM wallet may spend a device wallet's money without the owner ever having granted it" ); } } diff --git a/test/foundry/invariant-testing/WalletInvariants.t.sol b/test/foundry/invariant-testing/WalletInvariants.t.sol index 3771d9c6..ae296556 100644 --- a/test/foundry/invariant-testing/WalletInvariants.t.sol +++ b/test/foundry/invariant-testing/WalletInvariants.t.sol @@ -71,11 +71,11 @@ contract WalletInvariantsTest is CampaignBase { } } - /// @notice Nothing may pull ETH from a device wallet that the device wallet does not hold + /// @notice Nothing may spend from a device wallet that does not hold it /// @dev The pair is set together on the way in and cleared together on the way out, so the /// only way to separate them is a path that clears one and not the other. A detached - /// wallet that kept its right to pull would be reaching into someone else's balance. - function invariant_onlyHeldWalletsCanPullETH() public view { + /// wallet that kept its right to spend would be reaching into someone else's balance. + function invariant_onlyHeldWalletsCanSpend() public view { uint256 count = state.eSIMWalletCount(); for (uint256 i = 0; i < count; ++i) { address wallet = state.eSIMWallets(i); @@ -85,7 +85,7 @@ contract WalletInvariantsTest is CampaignBase { if (MockDeviceWallet(payable(device)).canPullFunds(wallet)) { assertTrue( MockDeviceWallet(payable(device)).isValidESIMWallet(wallet), - "An eSIM wallet may pull ETH from a device wallet that does not hold it" + "An eSIM wallet may spend from a device wallet that does not hold it" ); } } diff --git a/test/foundry/invariant-testing/handler/WalletHandler.sol b/test/foundry/invariant-testing/handler/WalletHandler.sol index 6a8df4e3..2fe88e31 100644 --- a/test/foundry/invariant-testing/handler/WalletHandler.sol +++ b/test/foundry/invariant-testing/handler/WalletHandler.sol @@ -198,8 +198,8 @@ contract WalletHandler is HandlerBase { /// @notice The wallet owner revokes or restores an eSIM wallet's right to pull ETH /// @param eSIMIndex Which eSIM wallet to toggle - /// @param hasAccessToETH The access it should end up with - function toggleAccessToFunds(uint256 eSIMIndex, bool hasAccessToETH) external counted { + /// @param hasAccessToFunds The access it should end up with + function toggleAccessToFunds(uint256 eSIMIndex, bool hasAccessToFunds) external counted { address wallet = _pickESIMWallet(eSIMIndex); if (wallet == address(0)) { state.recordRevert("toggleAccessToFunds"); @@ -212,10 +212,10 @@ contract WalletHandler is HandlerBase { } vm.prank(device); - try DeviceWallet(payable(device)).toggleAccessToFunds(wallet, hasAccessToETH) { + try DeviceWallet(payable(device)).toggleAccessToFunds(wallet, hasAccessToFunds) { // Recorded in ghost state rather than asserted here: an assertion that trips inside a // handler reverts the call and the campaign reads it as a skipped action - if (hasAccessToETH) { + if (hasAccessToFunds) { state.recordETHAccessGrant(device, wallet); } else { state.clearETHAccessGrant(device, wallet); diff --git a/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol b/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol index 2605ff20..c63a5266 100644 --- a/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol +++ b/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol @@ -71,7 +71,7 @@ abstract contract DeviceWalletFixture is DeployerBase { registry.assignESIMIdentifier(address(userESIMWallet), "ESIM_0_0"); vm.stopPrank(); - // A bind never carries ETH access, so the owner grants it here + // A bind never carries spending access, so the owner grants it here vm.prank(address(userDeviceWallet)); userDeviceWallet.toggleAccessToFunds(address(userESIMWallet), true); @@ -92,7 +92,7 @@ abstract contract DeviceWalletFixture is DeployerBase { assertEq(address(userDeviceWallet.registry()), address(registry), "Registry should have been correct for userDeviceWallet"); assertEq(address(userDeviceWallet.eSIMWalletFactory()), address(eSIMWalletFactory), "eSIMWalletFactory address in userDeviceWallet should have matched"); assertEq(userDeviceWallet.isValidESIMWallet(address(userESIMWallet)), true, "userESIMWallet should have been set to valid"); - assertEq(userDeviceWallet.canPullFunds(address(userESIMWallet)), true, "userESIMWallet should be able to pull ETH"); + assertEq(userDeviceWallet.canPullFunds(address(userESIMWallet)), true, "userESIMWallet should be able to spend the device wallet's money"); assertEq(address(userDeviceWallet.entryPoint()), address(entryPoint), "Entry point address should have been initialised in userDeviceWallet"); assertEq(address(userDeviceWallet.verifier()), address(p256Verifier), "P256Verifier address should have been initialised in userDeviceWallet"); @@ -154,7 +154,7 @@ abstract contract DeviceWalletFixture is DeployerBase { registry.assignESIMIdentifier(address(eSIMWallet3), "ESIM_1_1"); vm.stopPrank(); - // A bind never carries ETH access, so the two wallets that need it are granted it here + // A bind never carries spending access, so the two wallets that need it are granted it here vm.prank(address(deviceWallet)); deviceWallet.toggleAccessToFunds(address(eSIMWallet1), true); vm.prank(address(deviceWallet2)); @@ -204,9 +204,9 @@ abstract contract DeviceWalletFixture is DeployerBase { assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet1)), true, "ESIMWallet1 should have been set to valid"); assertEq(deviceWallet.isValidESIMWallet(address(eSIMWallet2)), true, "ESIMWallet2 should have been set to valid"); assertEq(deviceWallet2.isValidESIMWallet(address(eSIMWallet3)), true, "ESIMWallet3 should have been set to valid"); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should be able to pull ETH"); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "ESIMWallet2 should not be able to pull ETH"); - assertEq(deviceWallet2.canPullFunds(address(eSIMWallet3)), true, "ESIMWallet3 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should be able to spend the device wallet's money"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "ESIMWallet2 should not be able to spend the device wallet's money"); + assertEq(deviceWallet2.canPullFunds(address(eSIMWallet3)), true, "ESIMWallet3 should be able to spend the device wallet's money"); assertEq(address(deviceWallet.entryPoint()), address(entryPoint), "Entry point address should have been initialised in deviceWallet"); assertEq(address(deviceWallet2.entryPoint()), address(entryPoint), "Entry point address should have been initialised in deviceWallet2"); assertEq(address(deviceWallet.verifier()), address(p256Verifier), "P256Verifier address should have been initialised in deviceWallet"); @@ -256,7 +256,7 @@ abstract contract DeviceWalletFixture is DeployerBase { ) internal view { assertEq(registry.isESIMWalletOnStandby(address(_eSIMWallet)), _onStandby, "Unexpected standby status for the eSIM wallet"); assertEq(registry.isESIMWalletValid(address(_eSIMWallet)), _associatedDeviceWallet, "Unexpected device wallet associated with the eSIM wallet"); - assertEq(_deviceWallet.canPullFunds(address(_eSIMWallet)), _canPullFunds, "Unexpected ETH pull access for the eSIM wallet"); + assertEq(_deviceWallet.canPullFunds(address(_eSIMWallet)), _canPullFunds, "Unexpected spending access for the eSIM wallet"); assertEq(_deviceWallet.isValidESIMWallet(address(_eSIMWallet)), _isValidForDeviceWallet, "Unexpected eSIM wallet validity for the device wallet"); } } From 5c2e0195ab329c5f617d28114dc8446c1fa7441a Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:40:15 +0530 Subject: [PATCH 33/75] Correct two comments on the token path The refund is not always zero, and the event carries the adapter's figure for a reason worth stating. --- contracts/esim-wallet/ESIMWallet.sol | 4 ++-- contracts/payments/PaymentAdapter.sol | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index b182b4bb..f46b1447 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -408,8 +408,8 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade token.safeTransfer(adapterAddress, amountIn); (uint256 spent,) = adapter.settle(_asset, _dataBundleDetail.priceUSDCents, amountIn, address(this)); - // What the adapter reports spending, not what this wallet sent it. Anything it did not - // need comes back here, and the event should say which of the two happened. + // The adapter's figure, not what this wallet sent. The two match today, and will not once + // a swap can spend less than the ceiling it was funded to. emit DataBundleBoughtWithToken( _dataBundleDetail.id, _dataBundleDetail.priceUSDCents, diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index 692271f0..ccb54052 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -210,7 +210,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @param _amountIn Amount the caller has funded, and the most it is willing to spend /// @param _refundTo Address anything unspent goes back to /// @return spent Amount that reached the vault - /// @return refunded Amount returned to `_refundTo`, always zero until a swap can overshoot + /// @return refunded What `_amountIn` came to over the price, sent back to `_refundTo` function settle( bytes32 _symbol, uint64 _priceUSDCents, From 7314ca9862fee80e686fd3c31c35e76f50c31098 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:42:47 +0530 Subject: [PATCH 34/75] Hand the payment adapter to the timelock too It was left with the deployer. Owning it now sets the token address every purchase is paid into, and it is the contract the swap branch will need upgrading. --- scripts/deploy/Configure.s.sol | 2 +- scripts/deploy/Deploy.s.sol | 2 +- scripts/deploy/TransferOwnership.s.sol | 24 +++++++++++++----------- scripts/fork/ForkRehearsal.s.sol | 2 +- scripts/upgrade/UpgradeSingleton.s.sol | 4 +++- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/scripts/deploy/Configure.s.sol b/scripts/deploy/Configure.s.sol index cba0ae04..a79b5c3b 100644 --- a/scripts/deploy/Configure.s.sol +++ b/scripts/deploy/Configure.s.sol @@ -58,7 +58,7 @@ contract Configure is Script { error SettlementTokenNotTokenLike(address token); /// @notice Wires the protocol together and records that it is configured - /// @dev Broadcasts as the deployer, which is still the owner of all four singletons. + /// @dev Broadcasts as the deployer, which is still the owner of all five singletons. function run() external { DeployConfig.Config memory config = DeployConfig.load(); diff --git a/scripts/deploy/Deploy.s.sol b/scripts/deploy/Deploy.s.sol index c3329bf4..a8f54f24 100644 --- a/scripts/deploy/Deploy.s.sol +++ b/scripts/deploy/Deploy.s.sol @@ -24,7 +24,7 @@ import {DeploymentRecord} from "./config/DeploymentRecord.sol"; /// because only the first is expensive to repeat: a configuration call that runs out of gas /// should not mean redeploying eight contracts to try again. /// -/// The deployer is passed as `_upgradeManager` to all four singletons rather than +/// The deployer is passed as `_upgradeManager` to all five singletons rather than /// `ProtocolAdmin`, and that is deliberate. Three configuration calls are owner gated and /// structurally cannot be folded into any `initialize`, because both factories have to exist /// before the registry and the registry has to exist before either factory can be told about diff --git a/scripts/deploy/TransferOwnership.s.sol b/scripts/deploy/TransferOwnership.s.sol index 989a5b82..0602286e 100644 --- a/scripts/deploy/TransferOwnership.s.sol +++ b/scripts/deploy/TransferOwnership.s.sol @@ -12,25 +12,26 @@ import {ProtocolAdmin} from "../../contracts/admin/ProtocolAdmin.sol"; import {DeployConfig} from "./config/DeployConfig.sol"; import {DeploymentRecord} from "./config/DeploymentRecord.sol"; -/// @notice Hands the four upgradeable singletons to the timelock and closes the deployer's window +/// @notice Hands the five upgradeable singletons to the timelock and closes the deployer's window /// @dev The last of the three deployment scripts. Until this has run, one externally owned account -/// owns `Registry`, `LazyWalletRegistry` and both factories, and owning the factories reaches -/// every device wallet and every eSIM wallet through the two beacons. That is the state the -/// deployment is being moved off, so the gap between `Deploy.s.sol` and this script should be -/// minutes rather than days. +/// owns `Registry`, `LazyWalletRegistry`, both factories and `PaymentAdapter`. Owning the +/// factories reaches every device wallet and every eSIM wallet through the two beacons, and +/// owning the adapter sets the token address every purchase is paid into. That is the state +/// the deployment is being moved off, so the gap between `Deploy.s.sol` and this script should +/// be minutes rather than days. /// /// Two steps, because these contracts are `Ownable2Step`. The deployer offers ownership, then /// the new owner accepts it. `ProtocolAdmin.acceptOwnershipBatch` is permissionless and takes -/// all four in one transaction, so the second step needs no key at all and cannot be the thing +/// all five in one transaction, so the second step needs no key at all and cannot be the thing /// that strands a handover halfway. /// /// Nothing here is timelocked. Accepting an offer of ownership is not an operation the /// timelock schedules; it is a function on the timelock contract itself. After this runs, -/// every owner gated call on all four contracts is. +/// every owner gated call on all five contracts is. contract TransferOwnership is Script { /// @notice Number of contracts handed over in one run - uint256 private constant TARGET_COUNT = 4; + uint256 private constant TARGET_COUNT = 5; /// @notice A contract did not end the run owned by the timelock error OwnershipNotMoved(address target, address owner); @@ -38,7 +39,7 @@ contract TransferOwnership is Script { /// @notice The deployer does not own a contract it is supposed to hand over error NotOwner(address target, address owner, address deployer); - /// @notice Offers all four singletons to the timelock and has it accept them + /// @notice Offers all five singletons to the timelock and has it accept them /// @dev Broadcasts as the deployer for the offers. The acceptance is permissionless but is /// broadcast from the same key, since somebody has to pay for it. function run() external { @@ -64,11 +65,11 @@ contract TransferOwnership is Script { DeploymentRecord.writeStatus("ownershipTransferred", true); console.log(""); - console.log("All four singletons are owned by", protocolAdmin); + console.log("All five singletons are owned by", protocolAdmin); console.log("Every owner gated call now waits for the timelock delay."); } - /// @notice The four contracts whose owner is the upgrade authority + /// @notice The five contracts whose owner is the upgrade authority /// @dev Read from the record rather than taken as arguments. An address typed on a command line /// is the one way to hand the wrong contract to the wrong owner permanently. function _targets() private view returns (address[] memory targets) { @@ -77,6 +78,7 @@ contract TransferOwnership is Script { targets[1] = DeploymentRecord.readAddress("LazyWalletRegistryProxy"); targets[2] = DeploymentRecord.readAddress("DeviceWalletFactoryProxy"); targets[3] = DeploymentRecord.readAddress("ESIMWalletFactoryProxy"); + targets[4] = DeploymentRecord.readAddress("PaymentAdapterProxy"); } /// @notice Refuses to start unless the deployer still owns every target diff --git a/scripts/fork/ForkRehearsal.s.sol b/scripts/fork/ForkRehearsal.s.sol index 3829e3b0..18d9be8b 100644 --- a/scripts/fork/ForkRehearsal.s.sol +++ b/scripts/fork/ForkRehearsal.s.sol @@ -141,7 +141,7 @@ contract ForkRehearsal is Script { revert CheckFailed("Timelock delay is not what the deploy configured"); } - console.log("Ownership handover complete, all four singletons held by the timelock"); + console.log("Ownership handover complete, all five singletons held by the timelock"); } /// @notice Derives the wallet address offchain and checks the factory agrees diff --git a/scripts/upgrade/UpgradeSingleton.s.sol b/scripts/upgrade/UpgradeSingleton.s.sol index 11c6a598..2bd26e6f 100644 --- a/scripts/upgrade/UpgradeSingleton.s.sol +++ b/scripts/upgrade/UpgradeSingleton.s.sol @@ -8,6 +8,7 @@ import {Registry} from "../../contracts/Registry.sol"; import {LazyWalletRegistry} from "../../contracts/LazyWalletRegistry.sol"; import {DeviceWalletFactory} from "../../contracts/device-wallet/DeviceWalletFactory.sol"; import {ESIMWalletFactory} from "../../contracts/esim-wallet/ESIMWalletFactory.sol"; +import {PaymentAdapter} from "../../contracts/payments/PaymentAdapter.sol"; // Config import {DeploymentRecord} from "../deploy/config/DeploymentRecord.sol"; @@ -28,7 +29,7 @@ import {TimelockOperation} from "./TimelockOperation.sol"; /// any canceller can stop it, which is the whole protection the timelock buys. contract UpgradeSingleton is TimelockOperation { - /// @notice `UPGRADE_TARGET` does not name one of the four upgradeable singletons + /// @notice `UPGRADE_TARGET` does not name one of the five upgradeable singletons error UnknownTarget(string target); /// @notice `UPGRADE_ACTION` is neither `schedule` nor `execute` @@ -85,6 +86,7 @@ contract UpgradeSingleton is TimelockOperation { if(_is(target, "LazyWalletRegistryProxy")) return address(new LazyWalletRegistry()); if(_is(target, "DeviceWalletFactoryProxy")) return address(new DeviceWalletFactory()); if(_is(target, "ESIMWalletFactoryProxy")) return address(new ESIMWalletFactory()); + if(_is(target, "PaymentAdapterProxy")) return address(new PaymentAdapter()); revert UnknownTarget(target); } From d2ce4dce1f4f92a1a3337825070da041f7f1ac48 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:43:56 +0530 Subject: [PATCH 35/75] Check the adapter's owner in the fork rehearsal --- scripts/fork/ForkRehearsal.s.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/fork/ForkRehearsal.s.sol b/scripts/fork/ForkRehearsal.s.sol index 18d9be8b..8a5d051a 100644 --- a/scripts/fork/ForkRehearsal.s.sol +++ b/scripts/fork/ForkRehearsal.s.sol @@ -8,6 +8,7 @@ import {Registry} from "../../contracts/Registry.sol"; import {DeviceWallet} from "../../contracts/device-wallet/DeviceWallet.sol"; import {DeviceWalletFactory} from "../../contracts/device-wallet/DeviceWalletFactory.sol"; import {ESIMWalletFactory} from "../../contracts/esim-wallet/ESIMWalletFactory.sol"; +import {PaymentAdapter} from "../../contracts/payments/PaymentAdapter.sol"; import {ProtocolAdmin} from "../../contracts/admin/ProtocolAdmin.sol"; // Interfaces @@ -81,6 +82,7 @@ contract ForkRehearsal is Script { Registry private registry; DeviceWalletFactory private deviceWalletFactory; ESIMWalletFactory private eSIMWalletFactory; + PaymentAdapter private paymentAdapter; address private protocolAdmin; /// @notice Runs every rehearsal step in order and reverts on the first thing that is wrong @@ -110,6 +112,7 @@ contract ForkRehearsal is Script { DeviceWalletFactory(DeploymentRecord.readAddress("DeviceWalletFactoryProxy")); eSIMWalletFactory = ESIMWalletFactory(DeploymentRecord.readAddress("ESIMWalletFactoryProxy")); + paymentAdapter = PaymentAdapter(DeploymentRecord.readAddress("PaymentAdapterProxy")); protocolAdmin = DeploymentRecord.readRaw("admin.protocolAdmin"); ownerKey = WebAuthnSigner.publicKey(); @@ -117,6 +120,7 @@ contract ForkRehearsal is Script { console.log("Registry ", address(registry)); console.log("DeviceWalletFactory ", address(deviceWalletFactory)); console.log("ESIMWalletFactory ", address(eSIMWalletFactory)); + console.log("PaymentAdapter ", address(paymentAdapter)); console.log("ProtocolAdmin ", protocolAdmin); console.log(""); } @@ -133,6 +137,9 @@ contract ForkRehearsal is Script { if(eSIMWalletFactory.owner() != protocolAdmin) { revert CheckFailed("ESIMWalletFactory owner is not the timelock"); } + if(PaymentAdapter(paymentAdapter).owner() != protocolAdmin) { + revert CheckFailed("PaymentAdapter owner is not the timelock"); + } if(registry.owner() == config.deployer) revert CheckFailed("Deployer still owns the registry"); // The timelock's own floor, read back off the deployed contract rather than off the From f56c107cf9f97350b2a083c6f47900e7c0142074 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:52:44 +0530 Subject: [PATCH 36/75] Put the payment adapter in the README --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c27f9828..85576b30 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,12 @@ upgrade one at a time. Every wallet is a beacon proxy, so one beacon call moves | `DeviceWalletFactory` | Deploys device wallets at deterministic CREATE2 addresses and owns their beacon | UUPS proxy | | `DeviceWallet` | One per phone. Holds ETH, owns the eSIM wallets on that device, verifies the passkey | Beacon proxy | | `ESIMWalletFactory` | Deploys eSIM wallets and owns their beacon | UUPS proxy | -| `ESIMWallet` | One per eSIM. Buys data bundles, keeps purchase history, pulls ETH from its device wallet | Beacon proxy | +| `ESIMWallet` | One per eSIM. Buys data bundles, keeps purchase history, pulls ETH and tokens from its device wallet | Beacon proxy | +| `PaymentAdapter` | The currencies the protocol accepts, the one conversion from USD cents into a token amount, and the payment references each spendable once. Moves tokens to the vault | UUPS proxy | | `Account4337` | The ERC-4337 `IAccount` and `IERC1271` base that `DeviceWallet` builds on | Inherited by `DeviceWallet` | | `WebAuthn` | Verifies WebAuthn authentication assertions. Tries the RIP-7212 precompile first and falls back to FreshCryptoLib | Library | | `P256Verifier` | One immutable address for accounts to verify through, wrapping the WebAuthn library | Plain contract | -| `ProtocolAdmin` | Timelock meant to own the four singletons. Adds a delay floor that `updateDelay` cannot go under, and a guardian role with exactly two powers. **Written, not deployed** | Plain contract | +| `ProtocolAdmin` | Timelock meant to own the five singletons. Adds a delay floor that `updateDelay` cannot go under, and a guardian role with exactly two powers. **Written, not deployed** | Plain contract | | `Errors` | Every custom error in the suite | Library | | `CustomStructs` | Structs shared across contracts | Types | | `interfaces/` | `IPausable` and `IOwnable2Step`, the two calls `ProtocolAdmin` makes back into the protocol | Interfaces | @@ -115,7 +116,7 @@ slither . --filter-paths "test/,script/,lib/,node_modules/" aderyn . ``` -**Trust model, as it stands.** One EOA owns all four UUPS proxies and both factories that own the +**Trust model, as it stands.** One EOA owns every UUPS proxy and both factories that own the beacons, on both chains. A single key compromise reaches every wallet in one transaction, and admin transactions go into the public mempool with no private relay in front of them. `ProtocolAdmin` exists to replace that with a two day timelock and it is not deployed yet. Read the testnet @@ -170,9 +171,9 @@ on one chain and a plain EOA on the other. never leaves it. 2. **Deploy the wallets.** For a new device, the app asks the registry for a device wallet and one eSIM wallet, linked at deployment. -3. **Pick and buy a data bundle.** Paying in crypto deploys both wallets immediately. Paying in fiat - records the purchase in the lazy wallet registry, and the wallets are deployed later if the user - asks for them. +3. **Pick and buy a data bundle.** Paying in crypto deploys both wallets immediately, in ETH or in + an ERC-20 the payment adapter has been given a price for. Paying in fiat records the purchase in + the lazy wallet registry, and the wallets are deployed later if the user asks for them. 4. **Provision the eSIM.** The server generates the eSIM identifier, writes it into the eSIM wallet through the device wallet, and returns a QR code for activation. 5. **Use the device wallet.** It holds ETH and ERC-20 tokens and can be used as an ordinary wallet. From d00c3aeb4dbe6b3b29406036b8dcb688de184b8f Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 00:56:21 +0530 Subject: [PATCH 37/75] Say what settle really does with a caller-declared amount It trusts the figure against the balance, so the caller is the guard. Pins that, and drops the stale note on ProtocolAdmin. --- README.md | 4 ++-- contracts/payments/PaymentAdapter.sol | 9 +++++--- .../payments/PaymentAdapterSettle.t.sol | 21 +++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 85576b30..0480f3c3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ upgrade one at a time. Every wallet is a beacon proxy, so one beacon call moves | `Account4337` | The ERC-4337 `IAccount` and `IERC1271` base that `DeviceWallet` builds on | Inherited by `DeviceWallet` | | `WebAuthn` | Verifies WebAuthn authentication assertions. Tries the RIP-7212 precompile first and falls back to FreshCryptoLib | Library | | `P256Verifier` | One immutable address for accounts to verify through, wrapping the WebAuthn library | Plain contract | -| `ProtocolAdmin` | Timelock meant to own the five singletons. Adds a delay floor that `updateDelay` cannot go under, and a guardian role with exactly two powers. **Written, not deployed** | Plain contract | +| `ProtocolAdmin` | Timelock meant to own the five singletons. Adds a delay floor that `updateDelay` cannot go under, and a guardian role with exactly two powers | Plain contract | | `Errors` | Every custom error in the suite | Library | | `CustomStructs` | Structs shared across contracts | Types | | `interfaces/` | `IPausable` and `IOwnable2Step`, the two calls `ProtocolAdmin` makes back into the protocol | Interfaces | @@ -119,7 +119,7 @@ aderyn . **Trust model, as it stands.** One EOA owns every UUPS proxy and both factories that own the beacons, on both chains. A single key compromise reaches every wallet in one transaction, and admin transactions go into the public mempool with no private relay in front of them. `ProtocolAdmin` -exists to replace that with a two day timelock and it is not deployed yet. Read the testnet +exists to replace that with a two day timelock and does not own them yet. Read the testnet deployment below with that in mind. ## Deployments diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index ccb54052..7b14f626 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -202,9 +202,12 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @dev The caller funds this contract and calls settle in the same transaction, which leaves /// the tokens here for a swap to be added later without changing this signature. /// - /// The refund is bounded by what the caller funded rather than by the balance, so a token - /// sent here by mistake is not swept out by the next purchase. A fee-on-transfer token - /// delivers less than declared and fails the funding check. + /// `_amountIn` is the caller's word for what it sent, checked only against the balance, so + /// a caller naming more than it sent would carry off a token somebody left here by + /// mistake. What stops that is the caller: the one path into here passes `quote`, and an + /// eSIM wallet has no way to call this with a figure of its own choosing. Keep it that way. + /// + /// A fee-on-transfer token delivers less than declared and fails the funding check. /// @param _symbol Currency being paid in /// @param _priceUSDCents Price of the data bundle, in USD cents /// @param _amountIn Amount the caller has funded, and the most it is willing to spend diff --git a/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol b/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol index a7e5761d..d0ff2443 100644 --- a/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol +++ b/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol @@ -232,6 +232,27 @@ contract PaymentAdapterSettleTest is DeviceWalletFixture { // What settle leaves alone // --------------------------------------------------------------------------------------------- + /// @notice A caller naming more than it funded takes the difference, so nothing may name more + /// @dev Records what the contract does rather than what it should. `_amountIn` is checked + /// against the balance and nothing else, so the guard is the caller: `buyDataBundleWithToken` + /// is the one path in and it always passes `quote`. An eSIM wallet with a way to call + /// `settle` with a figure of its own would make this reachable. + function test_settle_aCallerNamingMoreThanItSentTakesTheDifference() public { + uint256 donation = 500e6; + fundSettlementToken(address(paymentAdapter), donation); + + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(paymentAdapter), needed); + + vm.prank(address(eSIMWallet1)); + (uint256 spent, uint256 refunded) = + paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, donation + needed, address(eSIMWallet1)); + + assertEq(spent, needed, "Only the price reaches the vault either way"); + assertEq(refunded, donation, "The declared surplus came back as a refund"); + assertEq(settlementERC20.balanceOf(address(eSIMWallet1)), donation, "The caller took what it never sent"); + } + /// @notice A token sent here by mistake belongs to nobody, and a purchase must not carry it off function test_settle_leavesATokenSentHereByMistake() public { uint256 donation = 500e6; From 6605df88a8e4749aa8abb2aec8088efddb44eaf5 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 09:00:16 +0530 Subject: [PATCH 38/75] Record the prover run behind the settle rules --- certora/specs/PaymentAdapter.spec | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/certora/specs/PaymentAdapter.spec b/certora/specs/PaymentAdapter.spec index 4bc2031c..c5f4f70b 100644 --- a/certora/specs/PaymentAdapter.spec +++ b/certora/specs/PaymentAdapter.spec @@ -29,9 +29,14 @@ /// record reading `Violated: --rule_not_vacuous` means the body was reachable, which /// is the outcome wanted. Count the records carrying no sanity suffix and ignore the fraction. /// -/// Verified 2026-08-28 at -/// `prover.certora.com/output/7200817/464cd89895e440c7845d7bb5e4cbd4f5`: 146 records verified, zero -/// assert failures, every violated record carrying a `-rule_not_vacuous` suffix. +/// Verified 2026-08-29 at +/// `prover.certora.com/output/7200817/d83cd3623dd144e0acd3520ffd9d345e`: zero assert failures, every +/// violated record carrying a `-rule_not_vacuous` suffix. +/// +/// `prover.certora.com/output/7200817/f15ea8d39bfe49caa9fea8cf9b7326e0` is the same source again and +/// is the one to read for per-rule verdicts. It lost the connection during its closing sanity checks +/// and so signs off `Violations were found`, which is that count being wrong again rather than a +/// result. It had reached 118 verified records and 62 violated, all 62 sanity. methods { function assets(bytes32) external returns (bool, bool, uint8, address) envfree; From 07bede1a7166232783bfa635f8ccca105cc89864 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 11:37:23 +0530 Subject: [PATCH 39/75] Refresh the gas baseline after the guard reorder --- .gas-snapshot | 113 +++++++++++++++++++++++++------------------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 02609046..1f8f4b6c 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -64,44 +64,44 @@ Deployer:test_lazyWalletRegistry_setUp() (gas: 21019) Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45819) Deployer:test_registry_ownable2step() (gas: 47346) Deployer:test_registry_setUp() (gas: 54915) -DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3713118) -DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3394163) -DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3450254) -DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395677) -DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399950) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3432091) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3401414) -DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388865) -DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3404523) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471656) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3394608) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPullETH() (gas: 3690052) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390593) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395658) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3458269) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3397180) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3419040) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403885) -DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3712526) +DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3713138) +DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3394182) +DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3450274) +DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395696) +DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399969) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3432110) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3401433) +DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388884) +DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3404543) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471676) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3394628) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPullETH() (gas: 3690072) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390612) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395677) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3458289) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3397201) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3419060) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403905) +DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3712546) DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoETHAccess() (gas: 1270437) -DeviceWalletETHTest:test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() (gas: 3394208) -DeviceWalletETHTest:test_deployESIMWallet_cannotGrantETHAccess() (gas: 3685515) -DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402945) -DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3438538) -DeviceWalletETHTest:test_getVaultAddress() (gas: 3396434) -DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3419136) -DeviceWalletETHTest:test_pullETH() (gas: 3405723) -DeviceWalletETHTest:test_pullETH_revertsWhilePaused() (gas: 3422355) -DeviceWalletETHTest:test_pullETH_revokedESIMWallet() (gas: 3396041) -DeviceWalletETHTest:test_pullETH_unauthorise() (gas: 3397730) -DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4114732) -DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 3972432) -DeviceWalletETHTest:test_toggleAccessToFunds_grant_deviceWalletHasETH() (gas: 3619331) -DeviceWalletETHTest:test_toggleAccessToFunds_grant_userHasETH() (gas: 3612733) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_deviceWalletHasETH() (gas: 3457609) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_eSIMWalletHasETH() (gas: 3564961) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_userHasETH() (gas: 3572449) -DeviceWalletETHTest:test_toggleAccessToFunds_unauthorised() (gas: 3397213) +DeviceWalletETHTest:test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() (gas: 3394227) +DeviceWalletETHTest:test_deployESIMWallet_cannotGrantETHAccess() (gas: 3685534) +DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402965) +DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3438558) +DeviceWalletETHTest:test_getVaultAddress() (gas: 3396454) +DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3419155) +DeviceWalletETHTest:test_pullETH() (gas: 3405743) +DeviceWalletETHTest:test_pullETH_revertsWhilePaused() (gas: 3422374) +DeviceWalletETHTest:test_pullETH_revokedESIMWallet() (gas: 3396060) +DeviceWalletETHTest:test_pullETH_unauthorise() (gas: 3397749) +DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4114751) +DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 3972451) +DeviceWalletETHTest:test_toggleAccessToFunds_grant_deviceWalletHasETH() (gas: 3619350) +DeviceWalletETHTest:test_toggleAccessToFunds_grant_userHasETH() (gas: 3612753) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_deviceWalletHasETH() (gas: 3457629) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_eSIMWalletHasETH() (gas: 3564980) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_userHasETH() (gas: 3572469) +DeviceWalletETHTest:test_toggleAccessToFunds_unauthorised() (gas: 3397233) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4552254) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936931) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960919) @@ -172,20 +172,20 @@ DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts( DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963583) DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957188) DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960981) -DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3412531) -DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4423678) -DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3428397) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3402703) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3413568) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402746) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3402489) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402814) +DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3412551) +DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4423698) +DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3428416) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3402722) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3413587) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402765) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3402508) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402833) DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1129021) DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1459063) DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149679) DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132264) DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120675) -DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3395336) +DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3395356) DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion() (gas: 30674) DeviceWalletTokensTest:test_pullToken_emitsTokenSent() (gas: 77501) DeviceWalletTokensTest:test_pullToken_movesATokenThatReturnsNothing() (gas: 327533) @@ -430,24 +430,25 @@ P256VerificationTest:test_verifySignature_rejectsAnAssertionMadeForAnotherChalle P256VerificationTest:test_verify_bothPathsAcceptAValidSignature() (gas: 236037) P256VerificationTest:test_verify_bothPathsRejectACorruptedSignature() (gas: 239570) P256VerificationTest:test_verify_theFallbackCostsFarMoreThanThePrecompile() (gas: 239733) -PaymentAdapterSettleTest:test_settle_emitsPaymentSettled() (gas: 104044) -PaymentAdapterSettleTest:test_settle_handlesTheSmallestPrice() (gas: 733055) -PaymentAdapterSettleTest:test_settle_leavesATokenSentHereByMistake() (gas: 123493) -PaymentAdapterSettleTest:test_settle_movesATokenThatReturnsNothing() (gas: 341287) +PaymentAdapterSettleTest:test_settle_aCallerNamingMoreThanItSentTakesTheDifference() (gas: 128849) +PaymentAdapterSettleTest:test_settle_emitsPaymentSettled() (gas: 104066) +PaymentAdapterSettleTest:test_settle_handlesTheSmallestPrice() (gas: 733077) +PaymentAdapterSettleTest:test_settle_leavesATokenSentHereByMistake() (gas: 123503) +PaymentAdapterSettleTest:test_settle_movesATokenThatReturnsNothing() (gas: 341309) PaymentAdapterSettleTest:test_settle_paysTheVaultCurrentlySet() (gas: 118467) -PaymentAdapterSettleTest:test_settle_paysTheVaultTheQuotedAmount() (gas: 103697) +PaymentAdapterSettleTest:test_settle_paysTheVaultTheQuotedAmount() (gas: 103719) PaymentAdapterSettleTest:test_settle_refundsWhatThePriceDidNotNeed() (gas: 128419) PaymentAdapterSettleTest:test_settle_refusesAReentrantCallFromTheToken() (gas: 1277381) PaymentAdapterSettleTest:test_settle_revertsForACallerTheRegistryDoesNotKnow() (gas: 85771) -PaymentAdapterSettleTest:test_settle_revertsForADeviceWallet() (gas: 86457) +PaymentAdapterSettleTest:test_settle_revertsForADeviceWallet() (gas: 86479) PaymentAdapterSettleTest:test_settle_revertsForAFeeOnTransferToken() (gas: 853158) PaymentAdapterSettleTest:test_settle_revertsForAWithdrawnAsset() (gas: 51510) -PaymentAdapterSettleTest:test_settle_revertsForAnAssetThatNeedsASwap() (gas: 675945) +PaymentAdapterSettleTest:test_settle_revertsForAnAssetThatNeedsASwap() (gas: 675967) PaymentAdapterSettleTest:test_settle_revertsForAnUnregisteredSymbol() (gas: 36700) PaymentAdapterSettleTest:test_settle_revertsForFiat() (gas: 36722) -PaymentAdapterSettleTest:test_settle_revertsOnAZeroPrice() (gas: 34165) -PaymentAdapterSettleTest:test_settle_revertsOnAZeroRefundAddress() (gas: 34653) -PaymentAdapterSettleTest:test_settle_revertsWhenTheCallerHasNotFundedIt() (gas: 43129) +PaymentAdapterSettleTest:test_settle_revertsOnAZeroPrice() (gas: 34187) +PaymentAdapterSettleTest:test_settle_revertsOnAZeroRefundAddress() (gas: 34675) +PaymentAdapterSettleTest:test_settle_revertsWhenTheCallerHasNotFundedIt() (gas: 43151) PaymentAdapterSettleTest:test_settle_revertsWhenThePriceIsAboveWhatTheCallerWillSpend() (gas: 88627) PaymentAdapterTest:test_consumePaymentReference_marksTheReference() (gas: 46315) PaymentAdapterTest:test_consumePaymentReference_rejectsAReferenceAlreadySpent() (gas: 45293) From cbd64b746f557d0de0528721209b40daa75a211a Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 13:19:19 +0530 Subject: [PATCH 40/75] Give the rehearsal the config the deploy actually reads --- scripts/fork/rehearse.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/fork/rehearse.sh b/scripts/fork/rehearse.sh index 9ec9c09c..f8f182d5 100755 --- a/scripts/fork/rehearse.sh +++ b/scripts/fork/rehearse.sh @@ -73,7 +73,12 @@ export DEPLOYER_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae export ESIM_WALLET_ADMIN_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d export ESIM_WALLET_ADMIN=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 export VAULT=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC -export DATA_BUNDLE_PRICE_CAP=1000000000000000000 +export PRICE_CAP_USD_CENTS=100000 + +# Base Sepolia USDC, which is the chain this forks. Checked with cast on 2026-08-29: has code, +# symbol USDC, 6 decimals. +export SETTLEMENT_TOKEN=0x036CbD53842c5426634e7929541eC2318f3dCF7e + export TIMELOCK_PROPOSERS=0x90F79bf6EB2c4f870365E785982E1f101E93b906 export TIMELOCK_CANCELLERS=0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 export TIMELOCK_GUARDIANS=0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc From fd6bdbe8e432008c277b39da0aad211b3517c9b6 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 13:22:46 +0530 Subject: [PATCH 41/75] Let hardhat run without a deployer key --- hardhat.config.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/hardhat.config.js b/hardhat.config.js index ebb259ea..de5142f9 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -4,7 +4,9 @@ require('@openzeppelin/hardhat-upgrades'); require("@nomicfoundation/hardhat-foundry"); require('solidity-docgen'); -const PRIV_KEY = process.env.PRIVATE_KEY_1; +// Same key Foundry deploys with. Hardhat is kept for docgen and compile parity, so an empty list +// is normal: without this, a missing key makes every hardhat command fail at config load. +const accounts = process.env.DEPLOYER_PRIVATE_KEY ? [process.env.DEPLOYER_PRIVATE_KEY] : []; const ALCHEMY_OP_SEPOLIA_HTTPS = process.env.ALCHEMY_OP_SEPOLIA_HTTPS; const ALCHEMY_TENDERLY_OP_SEPOLIA_HTTPS = process.env.ALCHEMY_TENDERLY_OP_SEPOLIA_HTTPS; const ALCHEMY_BASE_SEPOLIA_HTTPS = process.env.ALCHEMY_BASE_SEPOLIA_HTTPS; @@ -22,7 +24,7 @@ module.exports = { name: "sepolia", chainId: 11155111, url: `${ALCHEMY_SEPOLIA_HTTPS}`, - accounts: [PRIV_KEY], + accounts, saveDeployments: true, ignition: { maxFeePerGasLimit: 50_000_000_000n, // 50 gwei @@ -35,7 +37,7 @@ module.exports = { name: "optimism-sepolia", chainId: 11155420, url: `${ALCHEMY_OP_SEPOLIA_HTTPS}`, - accounts: [PRIV_KEY], + accounts, saveDeployments: true, ignition: { maxFeePerGasLimit: 50_000_000_000n, // 50 gwei @@ -48,7 +50,7 @@ module.exports = { name: "base-sepolia", chainId: 84532, url: `${ALCHEMY_BASE_SEPOLIA_HTTPS}`, - accounts: [PRIV_KEY], + accounts, saveDeployments: true, ignition: { maxFeePerGasLimit: 50_000_000_000n, // 50 gwei @@ -61,7 +63,7 @@ module.exports = { name: "Kokio-OP-Sepolia", chainId: 1212121, url: `${ALCHEMY_TENDERLY_OP_SEPOLIA_HTTPS}`, - accounts: [PRIV_KEY], + accounts, saveDeployments: true, ignition: { maxFeePerGasLimit: 50_000_000_000n, // 50 gwei @@ -74,7 +76,7 @@ module.exports = { name: "kokio-mainnet-fork", chainId: 1122334455, url: `${TENDERLY_KOKIO_MAINNET_FORK}`, - accounts: [PRIV_KEY], + accounts, saveDeployments: true, ignition: { maxFeePerGasLimit: 50_000_000_000n, // 50 gwei From 4d452c0d5449e73172c768e07b684948444c9eb7 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 13:22:46 +0530 Subject: [PATCH 42/75] Regenerate the contract docs --- docs/esim-wallet/ESIMWallet.md | 2 +- docs/payments/PaymentAdapter.md | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/esim-wallet/ESIMWallet.md b/docs/esim-wallet/ESIMWallet.md index 62c05c66..13936748 100644 --- a/docs/esim-wallet/ESIMWallet.md +++ b/docs/esim-wallet/ESIMWallet.md @@ -360,7 +360,7 @@ _Callable by the owning device wallet or by the admin, since the admin is the pa ### buyDataBundleWithToken ```solidity -function buyDataBundleWithToken(struct DataBundleDetails _dataBundleDetail, bytes32 _asset, uint256 _maxAmountIn, bytes32 _paymentReference) public returns (bool) +function buyDataBundleWithToken(struct DataBundleDetails _dataBundleDetail, bytes32 _asset, uint256 _maxAmountIn, bytes32 _paymentReference) external returns (bool) ``` Pays the vault for one data bundle in an ERC-20 and records the purchase diff --git a/docs/payments/PaymentAdapter.md b/docs/payments/PaymentAdapter.md index 0b297bf1..595d9921 100644 --- a/docs/payments/PaymentAdapter.md +++ b/docs/payments/PaymentAdapter.md @@ -241,9 +241,12 @@ Pays the vault for one data bundle out of tokens the caller has already sent her _The caller funds this contract and calls settle in the same transaction, which leaves the tokens here for a swap to be added later without changing this signature. - The refund is bounded by what the caller funded rather than by the balance, so a token - sent here by mistake is not swept out by the next purchase. A fee-on-transfer token - delivers less than declared and fails the funding check._ + `_amountIn` is the caller's word for what it sent, checked only against the balance, so + a caller naming more than it sent would carry off a token somebody left here by + mistake. What stops that is the caller: the one path into here passes `quote`, and an + eSIM wallet has no way to call this with a figure of its own choosing. Keep it that way. + + A fee-on-transfer token delivers less than declared and fails the funding check._ #### Parameters @@ -259,7 +262,7 @@ _The caller funds this contract and calls settle in the same transaction, which | Name | Type | Description | | ---- | ---- | ----------- | | spent | uint256 | Amount that reached the vault | -| refunded | uint256 | Amount returned to `_refundTo`, always zero until a swap can overshoot | +| refunded | uint256 | What `_amountIn` came to over the price, sent back to `_refundTo` | ### consumePaymentReference From abea3ec3306805fa5f9d4dfb996f722bd225f09b Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 13:23:02 +0530 Subject: [PATCH 43/75] Say which key hardhat reads --- env.sample | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/env.sample b/env.sample index 0f1f1fb6..d58ea500 100644 --- a/env.sample +++ b/env.sample @@ -5,7 +5,8 @@ # Deployment: scripts/deploy/Deploy.s.sol, Configure.s.sol, TransferOwnership.s.sol # --------------------------------------------------------------------------------------------- -# Deploys every contract and owns all four singletons until TransferOwnership.s.sol runs. +# Deploys every contract and owns all five singletons until TransferOwnership.s.sol runs. +# hardhat.config.js reads it too, and works without it, since hardhat only builds and writes docs. DEPLOYER_PRIVATE_KEY= # Hot key that registers device wallets and can pause the registry. Not an owner. From e351a988e0b7d022e0e104209caa5d592be48381 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 13:45:14 +0530 Subject: [PATCH 44/75] Retire the ETH purchase path The cents ceiling never bound the wei figure the caller passed, so it limited what got recorded rather than what got spent. ETH comes back on the token path once swaps land. --- contracts/device-wallet/DeviceWallet.sol | 44 ++-------------- contracts/esim-wallet/ESIMWallet.sol | 65 ------------------------ 2 files changed, 5 insertions(+), 104 deletions(-) diff --git a/contracts/device-wallet/DeviceWallet.sol b/contracts/device-wallet/DeviceWallet.sol index ab1df51e..665b4208 100644 --- a/contracts/device-wallet/DeviceWallet.sol +++ b/contracts/device-wallet/DeviceWallet.sol @@ -49,10 +49,6 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// @notice Emitted when owner updates an eSIM wallet's access to this wallet's money event FundsAccessUpdated(address indexed _eSIMWalletAddress, bool _hasAccessToFunds); - /// @notice Emitted when ETH is sent out from the contract - /// @dev mostly when an eSIM wallet pulls ETH from this contract - event ETHSent(address indexed _eSIMWalletAddress, uint256 _amount); - /// @notice Emitted when an ERC-20 leaves this contract /// @dev mostly when an eSIM wallet pulls tokens to pay for a data bundle event TokenSent(address indexed _token, address indexed _eSIMWalletAddress, uint256 _amount); @@ -203,25 +199,11 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 // Money movement // --------------------------------------------------------------------------------------------- - /// @notice Allow the eSIM wallets associated with this device wallet to pull ETH (for data bundles) - /// @dev Refused while the protocol is paused, and refused for a wallet whose access the owner - /// has revoked. - /// @param _amount Amount of ETH to pull - /// @return The amount pulled - function pullETH(uint256 _amount) external onlyAssociatedESIMWallets nonReentrant returns (uint256) { - registry.requireNotPaused(); - if(_amount == 0) revert Errors.ZeroAmount(); - if(!canPullFunds[msg.sender]) revert Errors.FundsAccessRevoked(msg.sender); - - _transferETH(msg.sender, _amount); - - return _amount; - } - /// @notice Allow an associated eSIM wallet to pull an ERC-20 (for data bundles) - /// @dev Same gate and pause check as `pullETH`. It exists so the admin can charge this wallet - /// without an owner signature in that transaction; an owner buying for themselves can - /// batch the transfer and the purchase through `executeBatch` instead. + /// @dev Refused while the protocol is paused, and refused for a wallet whose access the owner + /// has revoked. It exists so the admin can charge this wallet without an owner signature + /// in that transaction; an owner buying for themselves can batch the transfer and the + /// purchase through `executeBatch` instead. /// @param _token ERC-20 being pulled /// @param _amount Amount in that token's smallest unit /// @return The amount pulled @@ -369,25 +351,9 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 } // --------------------------------------------------------------------------------------------- - // ETH transfers and key checks + // Key checks // --------------------------------------------------------------------------------------------- - /// @notice Sends ETH out of this wallet, reverting if the call fails - /// @dev A zero amount is a no-op rather than a revert. - /// @param _recipient Address receiving the ETH - /// @param _amount Amount in wei - function _transferETH(address _recipient, uint256 _amount) internal virtual { - uint256 balance = address(this).balance; - if(_amount > balance) revert Errors.InsufficientBalance(balance, _amount); - if(_recipient == address(0)) revert Errors.ZeroAddress("_recipient"); - - if (_amount > 0) { - (bool success,) = _recipient.call{value: _amount}(""); - if (!success) revert Errors.FailedToTransfer(); - else emit ETHSent(_recipient, _amount); - } - } - /// @notice Rejects a P256 public key that is not a point on the curve /// @dev Same predicate the three deploy paths apply, repeated here because the factory holds /// its own copy privately and this contract is not in its inheritance chain. Sharing one diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index f46b1447..f2d6b51e 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -58,15 +58,6 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade address indexed _owner ); - /// @notice Emitted when the payment for a data bundle is made - event DataBundleBought( - bytes32 _dataBundleID, - uint64 _priceUSDCents, - uint256 _priceWei, - uint256 _ethFromUser, - bytes32 indexed _paymentReference - ); - /// @notice Emitted when a data bundle is paid for in an ERC-20 /// @dev The adapter emits the settlement. This one is for an indexer watching one wallet. event DataBundleBoughtWithToken( @@ -304,62 +295,6 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade return _amount; } - /// @notice Pays the vault for one data bundle in ETH and records the purchase - /// @dev Callable by the owning device wallet or by the admin, since the admin is the party that - /// knows the price. Any shortfall is pulled from the device wallet, which is why the price - /// is checked against a ceiling the admin cannot raise. - /// - /// The ceiling is in cents and the ETH sent is in wei, and nothing onchain converts - /// between them. So the ceiling limits what gets recorded, not what gets sent, and - /// `_priceWei` is taken on trust. The token path closes that gap by working the amount - /// out from the price instead. - /// @param _dataBundleDetail Data bundle being bought. Its settlement field is overwritten here. - /// @param _priceWei ETH actually being sent to the vault - /// @param _paymentReference The offchain order id. Spent once, so a retry of a call that - /// already landed cannot charge the user twice. - /// @return True if the transaction is successful - function buyDataBundle( - DataBundleDetails memory _dataBundleDetail, - uint256 _priceWei, - bytes32 _paymentReference - ) public payable onlyDeviceWalletOrESIMWalletAdmin nonReentrant returns (bool) { - Registry registry = deviceWallet.registry(); - registry.requireNotPaused(); - if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); - if(_dataBundleDetail.priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); - if(_priceWei == 0) revert Errors.ZeroDataBundlePrice(); - _requirePriceWithinCap(_dataBundleDetail.priceUSDCents, registry); - - // This is the only path that sees the money reach the vault, so it sets this itself. - _dataBundleDetail.settlement = Settlement.DeviceWallet; - - registry.consumePaymentReference(_paymentReference); - - uint256 walletBalance = address(this).balance; - - if (walletBalance < _priceWei) { - deviceWallet.pullETH(_priceWei - walletBalance); - } - - address vault = deviceWallet.getVaultAddress(); - - // Recorded before the transfer, so a vault that is a contract cannot observe a purchase - // that is not yet in the history it would be reading. - transactionHistory.push(_dataBundleDetail); - - _transferETH(vault, _priceWei); - - emit DataBundleBought( - _dataBundleDetail.id, - _dataBundleDetail.priceUSDCents, - _priceWei, - msg.value, - _paymentReference - ); - - return true; - } - /// @notice Pays the vault for one data bundle in an ERC-20 and records the purchase /// @dev The adapter works the amount out from the price, so unlike the ETH path there is no /// second figure to take on trust. Any shortfall is pulled from the device wallet. From 3065956fa0954fb861f5c9875d2b2562a97e19fb Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 14:29:46 +0530 Subject: [PATCH 45/75] Move the tests onto the token path Drops the ETH purchase and pull cases, keeps the ceiling and access coverage they carried by running it through buyDataBundleWithToken. --- test/foundry/fuzz-testing/ETHAmounts.t.sol | 181 +++++------- .../gas/DeviceWallet.Operations.gas.t.sol | 13 - .../gas/ESIMWallet.Operations.gas.t.sol | 26 -- .../HandlerDistribution.t.sol | 7 +- .../invariant-testing/base/CampaignBase.sol | 3 +- .../handler/AdminHandler.sol | 64 ---- .../invariant-testing/handler/HandlerBase.sol | 3 + .../handler/PaymentHandler.sol | 40 +-- .../handler/WalletHandler.sol | 21 +- .../DeviceWalletESIMWallets.t.sol | 188 ++++++------ .../device-wallet/DeviceWalletETH.t.sol | 256 +++++----------- .../device-wallet/DeviceWalletGuards.t.sol | 14 - .../unit-testing/esim-wallet/ESIMWallet.t.sol | 274 ++++-------------- .../esim-wallet/ESIMWalletGuards.t.sol | 36 +-- .../esim-wallet/ESIMWalletTokenPurchase.t.sol | 13 - .../unit-testing/registry/AdminRotation.t.sol | 14 +- .../registry/SettledPurchase.t.sol | 14 +- .../upgrade/PriceCapSlotMigration.t.sol | 12 +- test/utils/mocks/ReentrantESIMWallet.sol | 16 +- 19 files changed, 370 insertions(+), 825 deletions(-) diff --git a/test/foundry/fuzz-testing/ETHAmounts.t.sol b/test/foundry/fuzz-testing/ETHAmounts.t.sol index bcb4da03..da4b0a33 100644 --- a/test/foundry/fuzz-testing/ETHAmounts.t.sol +++ b/test/foundry/fuzz-testing/ETHAmounts.t.sol @@ -9,24 +9,16 @@ import {Errors} from "contracts/Errors.sol"; import {FuzzBase} from "test/foundry/fuzz-testing/base/FuzzBase.sol"; -/// @notice How much ETH each path moves, over arbitrary amounts and arbitrary starting balances. -/// @dev Three things are being held at once. ETH is conserved, meaning what leaves one balance -/// arrives in another and nothing settles in a contract that has no way to send it on. The -/// price cap is honoured whichever of its two levels supplies it. And an amount larger than -/// the balance behind it fails rather than moving a partial amount. -/// -/// The ceiling is in cents and the ETH is in wei, so the two are fuzzed separately. Nothing -/// onchain converts between them. -/// -/// The cap resolution is the part worth fuzzing rather than enumerating: the wallet's own cap +/// @notice Which price ceiling applies, over arbitrary caps and prices. +/// @dev The cap resolution is the part worth fuzzing rather than enumerating: the wallet's own cap /// wins when set, and the registry default applies when it is not. Zero on the wallet still /// means "follow the registry", but the registry itself can never hold zero: `initialize` and /// `setDefaultPriceCapUSDCents` both refuse it, so a real ceiling always applies somewhere. +/// +/// The ceiling now bounds what gets spent rather than only what gets recorded, because the +/// adapter works the amount out from the same price the cap is checked against. contract ETHAmountsTest is FuzzBase { - /// @dev Keeps fuzzed amounts inside what vm.deal can fund without the totals overflowing - uint256 private constant MAX_FUZZED_ETH = 1_000_000 ether; - /// @dev $10,000,000 in cents uint64 private constant MAX_FUZZED_CENTS = 1_000_000_000; @@ -38,59 +30,33 @@ contract ETHAmountsTest is FuzzBase { registry.assignESIMIdentifier(address(fuzzESIMWallet), "ESIM_FUZZ"); } - /// @notice Buying a bundle moves exactly its price to the vault and leaves nothing behind - /// @dev The eSIM wallet pulls from the device wallet only for the shortfall, so the split - /// between the two balances is what has to add up. Anything left in the eSIM wallet that - /// was not there before is ETH the accounting lost track of. - /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_movesExactlyThePriceToTheVault( - uint256 _price, - uint256 _walletBalance, - uint256 _deviceBalance - ) public { - uint256 price = bound(_price, 1, MAX_FUZZED_ETH); - uint256 walletBalance = bound(_walletBalance, 0, MAX_FUZZED_ETH); - uint256 deviceBalance = bound(_deviceBalance, price, MAX_FUZZED_ETH); - - // This test is about conservation of ETH on a successful purchase, not cap behaviour, so - // the wallet's own cap is raised out of the way. - vm.prank(address(fuzzDeviceWallet)); - fuzzESIMWallet.setPriceCapUSDCents(MAX_FUZZED_CENTS); - - vm.deal(address(fuzzESIMWallet), walletBalance); - vm.deal(address(fuzzDeviceWallet), deviceBalance); - uint256 vaultBefore = vault.balance; + // --------------------------------------------------------------------------------------------- + // Which ceiling applies + // --------------------------------------------------------------------------------------------- - vm.prank(eSIMWalletAdmin); - fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", TEST_PRICE_CENTS), price, nextRef()); - - assertEq(vault.balance - vaultBefore, price, "The vault must receive exactly the price"); - assertEq( - address(fuzzESIMWallet).balance + address(fuzzDeviceWallet).balance + price, - walletBalance + deviceBalance, - "ETH must be conserved across the two wallets and the vault" - ); - } - - /// @notice A price above the wallet's own cap is refused, and no ETH moves + /// @notice A price above the wallet's own cap is refused, and nothing moves /// @dev The revert has to carry both numbers, because the caller cannot otherwise tell which of /// the two levels refused it. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_refusesAPriceAboveTheWalletCap(uint64 _cap, uint64 _price) public { + function testFuzz_buyDataBundleWithToken_refusesAPriceAboveTheWalletCap(uint64 _cap, uint64 _price) public { uint64 cap = uint64(bound(_cap, 1, MAX_FUZZED_CENTS - 1)); uint64 price = uint64(bound(_price, uint256(cap) + 1, MAX_FUZZED_CENTS)); vm.prank(address(fuzzDeviceWallet)); fuzzESIMWallet.setPriceCapUSDCents(cap); - vm.deal(address(fuzzDeviceWallet), MAX_FUZZED_ETH); - uint256 vaultBefore = vault.balance; + fundSettlementToken(address(fuzzDeviceWallet), settlementAmount(price)); vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, cap)); - fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); + fuzzESIMWallet.buyDataBundleWithToken( + bundle("DB_FUZZ", price), + ASSET_USDC, + settlementAmount(price), + nextRef() + ); - assertEq(vault.balance, vaultBefore, "A refused purchase must move no ETH"); + assertEq(settlementERC20.balanceOf(vault), 0, "A refused purchase must pay nothing"); } /// @notice With no cap on the wallet, the registry default is what refuses @@ -98,7 +64,7 @@ contract ETHAmountsTest is FuzzBase { /// levels are read in the right order rather than the default being ignored once a wallet /// exists. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_fallsBackToTheRegistryCap(uint64 _cap, uint64 _price) public { + function testFuzz_buyDataBundleWithToken_fallsBackToTheRegistryCap(uint64 _cap, uint64 _price) public { uint64 cap = uint64(bound(_cap, 1, MAX_FUZZED_CENTS - 1)); uint64 price = uint64(bound(_price, uint256(cap) + 1, MAX_FUZZED_CENTS)); @@ -107,11 +73,16 @@ contract ETHAmountsTest is FuzzBase { assertEq(fuzzESIMWallet.priceCapUSDCents(), 0, "The wallet must have no cap of its own"); - vm.deal(address(fuzzDeviceWallet), MAX_FUZZED_ETH); + fundSettlementToken(address(fuzzDeviceWallet), settlementAmount(price)); vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, cap)); - fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); + fuzzESIMWallet.buyDataBundleWithToken( + bundle("DB_FUZZ", price), + ASSET_USDC, + settlementAmount(price), + nextRef() + ); } /// @notice The wallet's own cap wins over the registry default, in both directions @@ -119,7 +90,10 @@ contract ETHAmountsTest is FuzzBase { /// that took the minimum of the two would pass a test that only ever set the wallet /// tighter, and would silently cap wallets the device owner meant to raise. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_theWalletCapOverridesTheDefault(uint64 _walletCap, uint64 _price) public { + function testFuzz_buyDataBundleWithToken_theWalletCapOverridesTheDefault( + uint64 _walletCap, + uint64 _price + ) public { uint64 walletCap = uint64(bound(_walletCap, 2, MAX_FUZZED_CENTS)); uint64 price = uint64(bound(_price, 1, walletCap)); @@ -131,13 +105,17 @@ contract ETHAmountsTest is FuzzBase { vm.prank(address(fuzzDeviceWallet)); fuzzESIMWallet.setPriceCapUSDCents(walletCap); - vm.deal(address(fuzzDeviceWallet), MAX_FUZZED_ETH); - uint256 vaultBefore = vault.balance; + uint256 needed = settlementAmount(price); + fundSettlementToken(address(fuzzDeviceWallet), needed); vm.prank(eSIMWalletAdmin); - fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); + fuzzESIMWallet.buyDataBundleWithToken(bundle("DB_FUZZ", price), ASSET_USDC, needed, nextRef()); - assertEq(vault.balance - vaultBefore, 1 ether, "The wallet's own cap must be the one that applies"); + assertEq( + settlementERC20.balanceOf(vault), + needed, + "The wallet's own cap must be the one that applies" + ); } /// @notice A price within the registry default set at deployment succeeds without either @@ -145,7 +123,7 @@ contract ETHAmountsTest is FuzzBase { /// @dev The wallet-level zero still means "follow the registry", but the registry itself can /// never hold zero, so this is the uncapped-looking path that is actually always bounded. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_buyDataBundle_withinTheDeployTimeDefaultSucceeds(uint64 _price) public { + function testFuzz_buyDataBundleWithToken_withinTheDeployTimeDefaultSucceeds(uint64 _price) public { uint64 price = uint64(bound(_price, 1, defaultPriceCapUSDCents)); assertEq(fuzzESIMWallet.priceCapUSDCents(), 0, "The wallet must have no cap of its own"); @@ -155,70 +133,59 @@ contract ETHAmountsTest is FuzzBase { "The registry must hold the cap it was deployed with" ); - vm.deal(address(fuzzDeviceWallet), MAX_FUZZED_ETH); - uint256 vaultBefore = vault.balance; + uint256 needed = settlementAmount(price); + fundSettlementToken(address(fuzzDeviceWallet), needed); vm.prank(eSIMWalletAdmin); - fuzzESIMWallet.buyDataBundle(bundle("DB_FUZZ", price), 1 ether, nextRef()); + fuzzESIMWallet.buyDataBundleWithToken(bundle("DB_FUZZ", price), ASSET_USDC, needed, nextRef()); - assertEq(vault.balance - vaultBefore, 1 ether, "A price within the default must not be refused"); + assertEq( + settlementERC20.balanceOf(vault), + needed, + "A price within the default must not be refused" + ); } - /// @notice Pulling more than the device wallet holds fails and moves nothing - /// @dev The failure has to be total. A partial transfer would leave the eSIM wallet holding ETH - /// the device wallet still believes it has. - /// forge-config: default.fuzz.runs = 5000 - function testFuzz_pullETH_cannotOverdrawTheDeviceWallet(uint256 _balance, uint256 _amount) public { - uint256 balance = bound(_balance, 0, MAX_FUZZED_ETH); - uint256 amount = bound(_amount, balance + 1, MAX_FUZZED_ETH + 1); - - vm.deal(address(fuzzDeviceWallet), balance); - uint256 walletBefore = address(fuzzESIMWallet).balance; - - vm.prank(address(fuzzESIMWallet)); - vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientBalance.selector, balance, amount)); - fuzzDeviceWallet.pullETH(amount); - - assertEq(address(fuzzDeviceWallet).balance, balance, "A failed pull must leave the balance alone"); - assertEq(address(fuzzESIMWallet).balance, walletBefore, "A failed pull must deliver nothing"); - } + // --------------------------------------------------------------------------------------------- + // What a pull moves + // --------------------------------------------------------------------------------------------- /// @notice A pull the device wallet can cover moves exactly that amount and no more + /// @dev Swept over arbitrary balances because the unit tests pin single amounts. A partial + /// transfer would leave the eSIM wallet holding tokens the device wallet still counts. /// forge-config: default.fuzz.runs = 5000 - function testFuzz_pullETH_movesExactlyTheAmountRequested(uint256 _balance, uint256 _amount) public { - uint256 balance = bound(_balance, 1, MAX_FUZZED_ETH); + function testFuzz_pullToken_movesExactlyTheAmountRequested(uint256 _balance, uint256 _amount) public { + uint256 balance = bound(_balance, 1, type(uint128).max); uint256 amount = bound(_amount, 1, balance); - vm.deal(address(fuzzDeviceWallet), balance); - uint256 walletBefore = address(fuzzESIMWallet).balance; + fundSettlementToken(address(fuzzDeviceWallet), balance); + uint256 walletBefore = settlementERC20.balanceOf(address(fuzzESIMWallet)); vm.prank(address(fuzzESIMWallet)); - fuzzDeviceWallet.pullETH(amount); + fuzzDeviceWallet.pullToken(settlementToken, amount); - assertEq(address(fuzzDeviceWallet).balance, balance - amount, "The device wallet must lose exactly the amount"); - assertEq(address(fuzzESIMWallet).balance - walletBefore, amount, "The eSIM wallet must gain exactly the amount"); + assertEq( + settlementERC20.balanceOf(address(fuzzDeviceWallet)), + balance - amount, + "The device wallet must lose exactly the amount" + ); + assertEq( + settlementERC20.balanceOf(address(fuzzESIMWallet)) - walletBefore, + amount, + "The eSIM wallet must gain exactly the amount" + ); } - /// @notice A zero amount is refused rather than succeeding as a no-op - /// @dev Why the fuzz bound above starts at one. A zero-amount call that succeeded would emit a - /// movement event carrying no movement, which is what an offchain indexer reading those - /// events would have to filter for. - function test_zeroAmountIsRefused() public { - vm.deal(address(fuzzDeviceWallet), 1 ether); - - vm.prank(address(fuzzESIMWallet)); - vm.expectRevert(Errors.ZeroAmount.selector); - fuzzDeviceWallet.pullETH(0); - - assertEq(address(fuzzDeviceWallet).balance, 1 ether, "No ETH may move on a refused call"); - } + // --------------------------------------------------------------------------------------------- + // Who gets access to the money + // --------------------------------------------------------------------------------------------- - /// @notice No caller binds a wallet with the right to pull ETH, whoever they are + /// @notice No caller binds a wallet with the right to spend, whoever they are /// @dev Access control runs first, so an unauthorised caller is turned away before the flag is /// read, and an authorised one is refused on the flag itself. Either way the wallet ends /// up without the access. /// forge-config: default.fuzz.runs = 2000 - function testFuzz_noCallerEverBindsWithETHAccess(uint256 _caller) public { + function testFuzz_noCallerEverBindsWithFundsAccess(uint256 _caller) public { address[5] memory callers = [ eSIMWalletAdmin, address(registry), @@ -236,12 +203,12 @@ contract ETHAmountsTest is FuzzBase { vm.prank(caller); try fuzzDeviceWallet.addESIMWallet(wallet, true) { - fail("A bind carrying ETH access must never succeed"); + fail("A bind carrying funds access must never succeed"); } catch {} assertFalse( fuzzDeviceWallet.canPullFunds(wallet), - "A refused bind must leave the wallet without the right to pull ETH" + "A refused bind must leave the wallet without the right to spend" ); } } diff --git a/test/foundry/gas/DeviceWallet.Operations.gas.t.sol b/test/foundry/gas/DeviceWallet.Operations.gas.t.sol index 4e82b592..083c1aa1 100644 --- a/test/foundry/gas/DeviceWallet.Operations.gas.t.sol +++ b/test/foundry/gas/DeviceWallet.Operations.gas.t.sol @@ -71,19 +71,6 @@ contract DeviceWalletOperationsGasTest is GasBase { vm.snapshotGasLastCall(NAMESPACE, "toggleAccessToFunds: revoke"); } - /// @notice An eSIM wallet pulling ETH from the device wallet that owns it - function test_pullETH() public { - _deploy(); - vm.deal(address(wallet), 10 ether); - - vm.prank(address(wallet)); - wallet.toggleAccessToFunds(address(firstESIMWallet), true); - - vm.prank(address(firstESIMWallet)); - wallet.pullETH(1 ether); - vm.snapshotGasLastCall(NAMESPACE, "pullETH: 1 ether to the eSIM wallet"); - } - /// @notice An eSIM wallet pulling an ERC-20 from the device wallet that owns it /// @dev Two cases because the eSIM wallet's balance slot is cold on its first purchase and warm /// after, and the difference is what a wallet buying more than once actually pays. diff --git a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol index fa0346b5..5b5f9769 100644 --- a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol +++ b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol @@ -38,36 +38,10 @@ contract ESIMWalletOperationsGasTest is GasBase { vm.snapshotGasLastCall(NAMESPACE, "deployESIMWallet: through the factory"); } - /// @notice Buying a data bundle, funded and unfunded - /// @dev The unfunded case is the common one. A wallet holds no float, so every purchase pulls - /// from the device wallet and pays the vault in the same call. - /// - /// The first purchase gives the vault a balance it did not have, which costs more than - /// every one after it, so one runs unmeasured before either figure is taken. - function test_buyDataBundle() public { - _deploy(); - - vm.deal(address(eSIMWallet), 10 ether); - vm.prank(address(wallet)); - eSIMWallet.buyDataBundle(bundle("DB_GAS_0", TEST_PRICE_CENTS), 1 ether, paymentRef("gas-0")); - - vm.prank(address(wallet)); - eSIMWallet.buyDataBundle(bundle("DB_GAS_1", TEST_PRICE_CENTS), 1 ether, paymentRef("gas-1")); - vm.snapshotGasLastCall(NAMESPACE, "buyDataBundle: wallet already holds the price"); - - vm.deal(address(eSIMWallet), 0); - vm.prank(address(wallet)); - eSIMWallet.buyDataBundle(bundle("DB_GAS_2", TEST_PRICE_CENTS), 1 ether, paymentRef("gas-2")); - vm.snapshotGasLastCall(NAMESPACE, "buyDataBundle: pulls from the device wallet"); - } - /// @notice Buying a data bundle with an ERC-20, funded and unfunded /// @dev The unfunded case is the common one and costs a pull on top. A first purchase writes /// the vault a balance it did not have, so one runs unmeasured before either figure is /// taken and the two are then comparable. - /// - /// Both are dearer than the ETH path, which moves money once where this moves it into the - /// wallet, on to the adapter and then to the vault. function test_buyDataBundleWithToken() public { _deploy(); uint256 needed = settlementAmount(TEST_PRICE_CENTS); diff --git a/test/foundry/invariant-testing/HandlerDistribution.t.sol b/test/foundry/invariant-testing/HandlerDistribution.t.sol index e8477d16..86a0683c 100644 --- a/test/foundry/invariant-testing/HandlerDistribution.t.sol +++ b/test/foundry/invariant-testing/HandlerDistribution.t.sol @@ -79,11 +79,9 @@ contract HandlerDistributionTest is CampaignBase { adminHandler.setESIMIdentifier(round, seed + 3000, false); walletHandler.toggleAccessToFunds(round, true); walletHandler.setESIMWalletPriceCap(round, round); - adminHandler.buyDataBundle(round, 100, 1 gwei); // Each payment path is given its own block of reference seeds. Two calls presenting // the same reference is a case the campaign covers, and the second one is refused, // which is a revert rather than the count this drive is checking - paymentHandler.buyDataBundle(round, 100, 1 gwei, round); paymentHandler.recordSettledPurchase(round, 100, round + PAYMENT_REFERENCE_SEEDS, 0, false); paymentHandler.buyDataBundleWithToken(round, 100, 10e6, round + 2 * PAYMENT_REFERENCE_SEEDS); paymentHandler.quote(0, 100); @@ -91,7 +89,7 @@ contract HandlerDistributionTest is CampaignBase { // currency table this one did paymentHandler.updateAsset(1, false, true); paymentHandler.updateAsset(1, true, true); - walletHandler.pullETH(round, 1 gwei); + walletHandler.pullToken(round, 1_000e6, 5_000e6); // Removal comes before the transfer pair on purpose. Requesting a transfer detaches // the wallet on its way through, so a removal after it has nothing left to remove. // @@ -148,12 +146,11 @@ contract HandlerDistributionTest is CampaignBase { _assertExercised("deployESIMWalletForDevice"); _assertExercised("setESIMIdentifier"); _assertExercised("toggleAccessToFunds"); - _assertExercised("buyDataBundle"); _assertExercised("buyDataBundleWithToken"); _assertExercised("recordSettledPurchase"); _assertExercised("quote"); _assertExercised("updateAsset"); - _assertExercised("pullETH"); + _assertExercised("pullToken"); _assertExercised("removeESIMWallet"); _assertExercised("addESIMWallet"); _assertExercised("requestTransferOwnership"); diff --git a/test/foundry/invariant-testing/base/CampaignBase.sol b/test/foundry/invariant-testing/base/CampaignBase.sol index cc734b9b..7c145221 100644 --- a/test/foundry/invariant-testing/base/CampaignBase.sol +++ b/test/foundry/invariant-testing/base/CampaignBase.sol @@ -56,7 +56,8 @@ abstract contract CampaignBase is InvariantBase { adminSuccessor: ADMIN_SUCCESSOR, upgradeManager: UPGRADE_MANAGER, vault: VAULT, - attacker: ATTACKER + attacker: ATTACKER, + settlementToken: settlementToken }); adminHandler = new AdminHandler(config); diff --git a/test/foundry/invariant-testing/handler/AdminHandler.sol b/test/foundry/invariant-testing/handler/AdminHandler.sol index 3246b068..c956977d 100644 --- a/test/foundry/invariant-testing/handler/AdminHandler.sol +++ b/test/foundry/invariant-testing/handler/AdminHandler.sol @@ -147,70 +147,6 @@ contract AdminHandler is HandlerBase { } } - /// @notice The admin charges an eSIM wallet for a data bundle - /// @dev Both figures reach past the ceiling on purpose, since the ceiling is the only thing - /// between the admin and a wallet's whole balance. Fuzzed separately because the ceiling - /// is in cents and the ETH is in wei, and nothing converts between them. - /// @param eSIMIndex Which eSIM wallet pays - /// @param priceUSDCents What the purchase is recorded at - /// @param priceWei What the wallet actually sends the vault - function buyDataBundle(uint256 eSIMIndex, uint64 priceUSDCents, uint256 priceWei) external counted { - address wallet = _pickESIMWallet(eSIMIndex); - if (wallet == address(0)) { - state.recordRevert("buyDataBundle"); - return; - } - priceUSDCents = uint64(bound(priceUSDCents, 1, 1_000_000)); - priceWei = bound(priceWei, 1, 100 ether); - - address device = registry.isESIMWalletValid(wallet); - uint256 vaultBefore = vault.balance; - uint256 walletsBefore = wallet.balance + device.balance; - uint256 historyBefore = MockESIMWallet(payable(wallet)).getTransactionHistory().length; - - vm.prank(_currentAdmin()); - try ESIMWallet(payable(wallet)).buyDataBundle( - DataBundleDetails({ - id: "bundle", - priceUSDCents: priceUSDCents, - settlement: Settlement.Fiat - }), - priceWei, - keccak256(abi.encode("admin-handler", ++_refNonce)) - ) { - // Asserted here rather than as an invariant because the ceiling is a property of the - // charge and not of any state left behind. Both ceilings move during a run, so a - // purchase that was inside the ceiling when it went through can sit above the one the - // next invariant call would read - uint64 cap = ESIMWallet(payable(wallet)).priceCapUSDCents(); - if (cap == 0) cap = registry.defaultPriceCapUSDCents(); - if (cap != 0) { - assertLe(priceUSDCents, cap, "A purchase went through above the ceiling that applied to it"); - } - - // The wallet pays out of its own balance and pulls the shortfall from the device - // wallet, so neither balance alone says what a purchase cost. Their sum does, and it - // has to fall by exactly what the vault gained - assertEq( - vault.balance - vaultBefore, priceWei, "The vault received something other than the price" - ); - assertEq( - walletsBefore - (wallet.balance + device.balance), - priceWei, - "A purchase moved a different amount out of the wallets than it sent to the vault" - ); - assertEq( - MockESIMWallet(payable(wallet)).getTransactionHistory().length, - historyBefore + 1, - "A purchase did not leave exactly one entry behind" - ); - - state.recordCall("buyDataBundle"); - } catch { - state.recordRevert("buyDataBundle"); - } - } - /// @notice The admin records purchase history against a device identifier with no wallet yet /// @dev The reuse branch presents an identifier that already carries history. That is two /// guards in one: appending to a device still waiting to be deployed has to work, and diff --git a/test/foundry/invariant-testing/handler/HandlerBase.sol b/test/foundry/invariant-testing/handler/HandlerBase.sol index bf41c358..fafe1782 100644 --- a/test/foundry/invariant-testing/handler/HandlerBase.sol +++ b/test/foundry/invariant-testing/handler/HandlerBase.sol @@ -30,6 +30,7 @@ struct HandlerConfig { address upgradeManager; address vault; address attacker; + address settlementToken; } /// @notice What every handler in the campaign shares. @@ -50,6 +51,7 @@ abstract contract HandlerBase is Test { address internal immutable upgradeManager; address internal immutable vault; address internal immutable attacker; + address internal immutable settlementToken; DeviceWalletFactory internal immutable deviceWalletFactory; ESIMWalletFactory internal immutable eSIMWalletFactory; @@ -69,6 +71,7 @@ abstract contract HandlerBase is Test { upgradeManager = config.upgradeManager; vault = config.vault; attacker = config.attacker; + settlementToken = config.settlementToken; } /// @notice Counts an invocation before the body decides whether it can go through, and reads diff --git a/test/foundry/invariant-testing/handler/PaymentHandler.sol b/test/foundry/invariant-testing/handler/PaymentHandler.sol index 5d481ed4..eea9a159 100644 --- a/test/foundry/invariant-testing/handler/PaymentHandler.sol +++ b/test/foundry/invariant-testing/handler/PaymentHandler.sol @@ -97,47 +97,9 @@ contract PaymentHandler is HandlerBase { } // --------------------------------------------------------------------------------------------- - // The two payment paths + // The payment path // --------------------------------------------------------------------------------------------- - /// @notice A wallet buys a data bundle with ETH - /// @param eSIMIndex Picks the wallet - /// @param priceUSDCents Price to record, bound under whichever ceiling applies - /// @param priceWei ETH to send to the vault - /// @param referenceSeed Picks the payment reference from the pool - function buyDataBundle( - uint256 eSIMIndex, - uint64 priceUSDCents, - uint256 priceWei, - uint256 referenceSeed - ) external counted { - address wallet = _pickESIMWallet(eSIMIndex); - if (wallet == address(0)) { - state.recordRevert("buyDataBundle"); - return; - } - - bytes32 paymentReference = _reference(referenceSeed); - priceUSDCents = uint64(bound(priceUSDCents, 1, _effectiveCap(wallet))); - priceWei = bound(priceWei, 1, 1 ether); - - vm.prank(_currentAdmin()); - try ESIMWallet(payable(wallet)).buyDataBundle( - DataBundleDetails({ - id: "bundle", - priceUSDCents: priceUSDCents, - settlement: Settlement.Fiat - }), - priceWei, - paymentReference - ) { - _recordSpend(paymentReference); - state.recordCall("buyDataBundle"); - } catch { - state.recordRevert("buyDataBundle"); - } - } - /// @notice A wallet buys a data bundle with the settlement token /// @dev The funding is minted to the eSIM wallet rather than to its device wallet, so whether /// the call goes through turns on the reference rather than on an access flag another diff --git a/test/foundry/invariant-testing/handler/WalletHandler.sol b/test/foundry/invariant-testing/handler/WalletHandler.sol index 2fe88e31..e23e1905 100644 --- a/test/foundry/invariant-testing/handler/WalletHandler.sol +++ b/test/foundry/invariant-testing/handler/WalletHandler.sol @@ -6,6 +6,7 @@ import {DeviceWallet} from "contracts/device-wallet/DeviceWallet.sol"; import {ESIMWallet} from "contracts/esim-wallet/ESIMWallet.sol"; import {HandlerBase, HandlerConfig} from "test/foundry/invariant-testing/handler/HandlerBase.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; /// @notice Everything a wallet does on its owner's behalf. /// @dev Every entry point here impersonates a wallet rather than a person. That is the whole @@ -226,29 +227,31 @@ contract WalletHandler is HandlerBase { } } - /// @notice An eSIM wallet pulls ETH from the device wallet that owns it + /// @notice An eSIM wallet pulls an ERC-20 from the device wallet that owns it /// @dev The amount is allowed to exceed the device wallet's balance, which is the case the - /// transfer guard has to refuse rather than partially serve. + /// transfer has to refuse rather than partially serve. /// @param eSIMIndex Which eSIM wallet pulls /// @param amount How much it asks for - function pullETH(uint256 eSIMIndex, uint256 amount) external counted { + /// @param fundingSeed How much the device wallet is holding when it asks + function pullToken(uint256 eSIMIndex, uint256 amount, uint256 fundingSeed) external counted { address wallet = _pickESIMWallet(eSIMIndex); if (wallet == address(0)) { - state.recordRevert("pullETH"); + state.recordRevert("pullToken"); return; } address device = registry.isESIMWalletValid(wallet); if (device == address(0)) { - state.recordRevert("pullETH"); + state.recordRevert("pullToken"); return; } - amount = bound(amount, 0, 20 ether); + amount = bound(amount, 0, 20_000e6); + MockERC20(settlementToken).mint(device, bound(fundingSeed, 0, 10_000e6)); vm.prank(wallet); - try DeviceWallet(payable(device)).pullETH(amount) { - state.recordCall("pullETH"); + try DeviceWallet(payable(device)).pullToken(settlementToken, amount) { + state.recordCall("pullToken"); } catch { - state.recordRevert("pullETH"); + state.recordRevert("pullToken"); } } diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol index 04ebecdd..0513a302 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletESIMWallets.t.sol @@ -10,10 +10,67 @@ import {DeviceWallet} from "contracts/device-wallet/DeviceWallet.sol"; import {DeviceWalletFixture} from "test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol"; import {ReentrantESIMWallet} from "test/utils/mocks/ReentrantESIMWallet.sol"; +import {MockDeviceWallet} from "test/utils/mocks/MockDeviceWallet.sol"; +import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; /// @notice Which eSIM wallets a device wallet holds, and how one moves between devices. contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { + /// @notice The second device wallet takes the eSIM wallet, grants it access and buys through it + /// @dev Shared by the two handover tests, which are otherwise long enough that their locals push + /// the via-IR build over its stack limit. + function _handOverToSecondDeviceAndBuy() private { + vm.deal(address(deviceWallet2), 5 ether); + vm.prank(address(deviceWallet2)); + eSIMWallet1.acceptOwnershipTransfer(); + + assertEq(eSIMWallet1.owner(), address(deviceWallet2), "newOwner should have accepted the ownership"); + assertEq(eSIMWallet1.newRequestedOwner(), address(0), "newRequestedOwner should have reset to address(0)"); + + vm.prank(address(deviceWallet2)); + deviceWallet2.addESIMWallet(address(eSIMWallet1), false); + + _assertESIMWalletBinding(deviceWallet2, eSIMWallet1, false, address(deviceWallet2), false, true); + _grantAccessAndBuy(); + } + + /// @notice The second half of the handover, split so neither function overflows the IR stack + function _grantAccessAndBuy() private { + assertEq(address(deviceWallet2).balance, 5 ether, "Device wallet balance should have been the same"); + assertEq(address(eSIMWallet1).balance, 0, "eSIM wallet balance should have decreased to 0 ETH"); + + vm.prank(address(deviceWallet2)); + deviceWallet2.toggleAccessToFunds(address(eSIMWallet1), true); + + assertTrue(deviceWallet2.canPullFunds(address(eSIMWallet1)), "eSIMWallet1 should be able to spend"); + + _buyThroughDeviceWallet(deviceWallet2, eSIMWallet1); + } + + /// @notice Funds a device wallet and buys one bundle through the eSIM wallet it holds + function _buyThroughDeviceWallet(MockDeviceWallet _device, MockESIMWallet _wallet) private { + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(_device), needed); + + vm.prank(eSIMWalletAdmin); + _wallet.buyDataBundleWithToken(bundle("DB_ID_0", TEST_PRICE_CENTS), ASSET_USDC, needed, nextRef()); + + _assertBundlePaidFor(_device, _wallet, needed); + } + + /// @notice Split out of the caller so the via-IR build stays inside its stack limit + function _assertBundlePaidFor(MockDeviceWallet _device, MockESIMWallet _wallet, uint256 _needed) + private + view + { + assertEq(settlementERC20.balanceOf(address(_device)), 0, "The whole amount came from the device wallet"); + assertEq(settlementERC20.balanceOf(vault), _needed, "The vault must hold the price"); + + (bytes32 id, uint64 price,) = _wallet.transactionHistory(0); + assertEq(id, bytes32("DB_ID_0"), "The recorded data bundle ID should have been correct"); + assertEq(price, TEST_PRICE_CENTS, "The recorded price should have been correct"); + } + function test_deployESIMWallet() public { deployWallets(); } @@ -100,25 +157,40 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { } /// @notice A wallet whose logic re-enters the device wallet during its own removal must find - /// that it has already lost both its association and its right to pull ETH - function test_removeESIMWallet_reentrantCallbackCannotPullETH() public { + /// that it has already lost both its association and its right to spend + function test_removeESIMWallet_reentrantCallbackCannotPull() public { deployWallets(); - vm.deal(address(deviceWallet), 10 ether); + fundSettlementToken(address(deviceWallet), 1_000e6); - ReentrantESIMWallet handlerImpl = new ReentrantESIMWallet(DeviceWallet(payable(address(deviceWallet)))); - vm.etch(address(eSIMWallet1), address(handlerImpl).code); - ReentrantESIMWallet handler = ReentrantESIMWallet(payable(address(eSIMWallet1))); + ReentrantESIMWallet handler = _etchReentrantLogicOverESIMWallet1(); vm.prank(address(deviceWallet)); deviceWallet.removeESIMWallet(address(eSIMWallet1), true); - assertEq(address(deviceWallet).balance, 10 ether, "Device wallet must not have lost any ETH to the callback"); - assertEq(handler.pullETHSucceededDuringRemoval(), false, "pullETH must not succeed from inside the removal"); - assertEq(handler.wasStillValidDuringRemoval(), false, "The wallet must already be unbound when the callback runs"); - assertEq(handler.couldStillPullETHDuringRemoval(), false, "The wallet must already have lost ETH access when the callback runs"); + _assertCallbackReachedNothing(handler); + } - _assertESIMWalletBinding(deviceWallet, eSIMWallet1, true, address(deviceWallet), false, false); + /// @notice Puts re-entering logic behind eSIMWallet1's address, keeping its bindings + /// @dev Deployed through `deployCode` rather than `new`, which keeps the mock's creation code + /// out of this contract. Inlined, it puts the via-IR build over its stack limit. + function _etchReentrantLogicOverESIMWallet1() private returns (ReentrantESIMWallet) { + address impl = deployCode( + "ReentrantESIMWallet.sol:ReentrantESIMWallet", + abi.encode(address(deviceWallet), settlementToken) + ); + vm.etch(address(eSIMWallet1), impl.code); + + return ReentrantESIMWallet(payable(address(eSIMWallet1))); + } + + /// @notice What the re-entering wallet found, split out so the caller stays inside the IR stack + function _assertCallbackReachedNothing(ReentrantESIMWallet _handler) private view { + assertEq(settlementERC20.balanceOf(address(deviceWallet)), 1_000e6, "nothing lost to the callback"); + assertFalse(_handler.pullSucceededDuringRemoval(), "a pull must not succeed mid-removal"); + assertFalse(_handler.wasStillValidDuringRemoval(), "the wallet must already be unbound"); + assertFalse(_handler.couldStillPullDuringRemoval(), "the wallet must already have lost access"); + assertFalse(deviceWallet.isValidESIMWallet(address(eSIMWallet1)), "the wallet must be unbound after"); } function test_addESIMWallet_unauthorised() public { @@ -202,51 +274,8 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { deviceWallet.removeESIMWallet(address(eSIMWallet1), true); vm.stopPrank(); - // 3. deviceWallet2 accepts ownership of eSIMWallet1 - vm.deal(address(deviceWallet2), 5 ether); - vm.startPrank(address(deviceWallet2)); - eSIMWallet1.acceptOwnershipTransfer(); - vm.stopPrank(); - - address newOwner = eSIMWallet1.owner(); - assertEq(newOwner, address(deviceWallet2), "newOwner should have accepted the ownership"); - - address requestedOwner = eSIMWallet1.newRequestedOwner(); - assertEq(requestedOwner, address(0), "newRequestedOwner should have reset to address(0)"); - - // 4. deviceWallet2 adds/binds eSIMWallet1 - vm.startPrank(address(deviceWallet2)); - deviceWallet2.addESIMWallet(address(eSIMWallet1), false); - vm.stopPrank(); - - assertEq(address(deviceWallet2).balance, 5 ether, "Device wallet balance should have been the same"); - assertEq(address(eSIMWallet1).balance, 0, "eSIM wallet balance should have decreased to 0 ETH"); - - _assertESIMWalletBinding(deviceWallet2, eSIMWallet1, false, address(deviceWallet2), false, true); - - // 5. deviceWallet2 grants access to eSIMWallet1 to pull ETH (This could also be done in a single step during addESIMWallet function call) - vm.startPrank(address(deviceWallet2)); - deviceWallet2.toggleAccessToFunds(address(eSIMWallet1), true); - vm.stopPrank(); - - assertEq(deviceWallet2.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should have access to ETH for deviceWallet2"); - assertEq(address(eSIMWallet1).balance, 0, "eSIMWallet1 balance should have been 0 ETH"); - - // 6. Add ETH to deviceWallet2, and buy data bundle for eSIMWallet1 - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 1 ether; - - vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - assertEq(address(deviceWallet2).balance, 4 ether, "Device wallet balance should have been 4 ETH"); - assertEq((deviceWallet2.getVaultAddress()).balance, 1 ether, "Vault balance should have increased by 1 ETH"); - - DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); + // 3. deviceWallet2 takes the wallet, grants access and buys through it + _handOverToSecondDeviceAndBuy(); } /// @notice A registered device wallet cannot release an eSIM wallet another one holds @@ -425,50 +454,7 @@ contract DeviceWalletESIMWalletsTest is DeviceWalletFixture { currentOwner = eSIMWallet1.owner(); assertNotEq(address(deviceWallet3), eSIMWallet1.owner(), "Critical Error: ESIMWallet stolen"); - // 4. deviceWallet2 accepts ownership of eSIMWallet1 - vm.deal(address(deviceWallet2), 5 ether); - vm.startPrank(address(deviceWallet2)); - eSIMWallet1.acceptOwnershipTransfer(); - vm.stopPrank(); - - address newOwner = eSIMWallet1.owner(); - assertEq(newOwner, address(deviceWallet2), "newOwner should have accepted the ownership"); - - address requestedOwner = eSIMWallet1.newRequestedOwner(); - assertEq(requestedOwner, address(0), "newRequestedOwner should have reset to address(0)"); - - // 5. deviceWallet2 adds/binds eSIMWallet1 - vm.startPrank(address(deviceWallet2)); - deviceWallet2.addESIMWallet(address(eSIMWallet1), false); - vm.stopPrank(); - - assertEq(address(deviceWallet2).balance, 5 ether, "Device wallet balance should have been the same"); - assertEq(address(eSIMWallet1).balance, 0, "eSIM wallet balance should have decreased to 0 ETH"); - - _assertESIMWalletBinding(deviceWallet2, eSIMWallet1, false, address(deviceWallet2), false, true); - - // 6. deviceWallet2 grants access to eSIMWallet1 to pull ETH (This could also be done in a single step during addESIMWallet function call) - vm.startPrank(address(deviceWallet2)); - deviceWallet2.toggleAccessToFunds(address(eSIMWallet1), true); - vm.stopPrank(); - - assertEq(deviceWallet2.canPullFunds(address(eSIMWallet1)), true, "ESIMWallet1 should have access to ETH for deviceWallet2"); - assertEq(address(eSIMWallet1).balance, 0, "eSIMWallet1 balance should have been 0 ETH"); - - // 7. Add ETH to deviceWallet2, and buy data bundle for eSIMWallet1 - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 1 ether; - - vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - assertEq(address(deviceWallet2).balance, 4 ether, "Device wallet balance should have been 4 ETH"); - assertEq((deviceWallet2.getVaultAddress()).balance, 1 ether, "Vault balance should have increased by 1 ETH"); - - DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); + // 4. deviceWallet2 takes the wallet, grants access and buys through it + _handOverToSecondDeviceAndBuy(); } } diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol index 9042b903..2e5be3dd 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol @@ -11,41 +11,11 @@ import {DeviceWalletFixture} from "test/foundry/unit-testing/device-wallet/base/ import {MockDeviceWallet} from "test/utils/mocks/MockDeviceWallet.sol"; import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; -/// @notice Every path ETH takes into and out of a device wallet, and who may open each one. +/// @notice Who may reach a device wallet's money, and where the vault address comes from. +/// @dev The pull itself is covered in DeviceWalletTokens. What is here is the access flag seen +/// through a purchase, which is the only thing that spends on a user's behalf. contract DeviceWalletETHTest is DeviceWalletFixture { - function test_pullETH_unauthorise() public { - deployWallets(); - - vm.deal(address(deviceWallet), 2 ether); - vm.startPrank(user1); - vm.expectRevert(bytes4(keccak256("OnlyAssociatedESIMWallets()"))); - deviceWallet.pullETH(1000000000000000000); // 1 ETH - vm.stopPrank(); - } - - function test_pullETH_revokedESIMWallet() public { - deployWallets(); - - vm.deal(address(deviceWallet), 2 ether); - vm.startPrank(address(eSIMWallet2)); - vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, address(eSIMWallet2))); - deviceWallet.pullETH(1000000000000000000); // 1 ETH - vm.stopPrank(); - } - - function test_pullETH() public { - deployWallets(); - - vm.deal(address(deviceWallet), 2 ether); - vm.startPrank(address(eSIMWallet1)); - deviceWallet.pullETH(1000000000000000000); // 1 ETH - vm.stopPrank(); - - assertEq(address(deviceWallet).balance, 1 ether, "Device wallet balance should have been 1 ETH"); - assertEq(address(eSIMWallet1).balance, 1 ether, "ESIM wallet balance should have been 1 ETH"); - } - function test_getVaultAddress() public { deployWallets(); @@ -74,10 +44,14 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(deviceWallet2.getVaultAddress(), user5, "Every wallet must follow, not just one"); } + // --------------------------------------------------------------------------------------------- + // The access flag, seen through a purchase + // --------------------------------------------------------------------------------------------- + function test_toggleAccessToFunds_unauthorised() public { deployWallets(); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to spend"); vm.startPrank(user1); vm.expectRevert(Errors.OnlySelf.selector); @@ -88,86 +62,46 @@ contract DeviceWalletETHTest is DeviceWalletFixture { vm.stopPrank(); } - function test_toggleAccessToFunds_revoke_deviceWalletHasETH() public { + /// @notice A revoked wallet cannot reach the device wallet's balance + function test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() public { deployWallets(); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); - vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToFunds( - address(eSIMWallet1), - false - ); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), false); vm.stopPrank(); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to spend"); - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 0.1 ether; + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(deviceWallet), needed); - vm.deal(address(deviceWallet), 1 ether); vm.startPrank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, address(eSIMWallet1))); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - } - - function test_toggleAccessToFunds_revoke_eSIMWalletHasETH() public { - deployWallets(); - - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); - - vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToFunds( - address(eSIMWallet1), - false - ); - vm.stopPrank(); - - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); - - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 0.1 ether; - - vm.deal(address(eSIMWallet1), 1 ether); - vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_ID_0", TEST_PRICE_CENTS), ASSET_USDC, needed, nextRef()); vm.stopPrank(); - - assertEq(address(eSIMWallet1).balance, 0.9 ether, "ESIMWalletAdmin balance should have been decreased to 0.9 ETH"); - assertEq(vault.balance, 0.1 ether, "Vault balance should have updated to 0.1 ETH"); - - DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } - function test_toggleAccessToFunds_revoke_userHasETH() public { + /// @notice A revoked wallet still spends what it already holds itself + /// @dev The flag governs the pull, not the wallet's own balance. Revoking it must not strand a + /// wallet that was funded directly. + function test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() public { deployWallets(); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), true, "eSIMWallet1 should be able to pull ETH"); - vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToFunds( - address(eSIMWallet1), - false - ); + deviceWallet.toggleAccessToFunds(address(eSIMWallet1), false); vm.stopPrank(); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet1)), false, "eSIMWallet1 should not be able to spend"); - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 0.1 ether; + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(eSIMWallet1), needed); - vm.deal(eSIMWalletAdmin, 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle{value: 0.2 ether}(_dataBundleDetail, _priceWei, nextRef()); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_ID_0", TEST_PRICE_CENTS), ASSET_USDC, needed, nextRef()); vm.stopPrank(); - assertEq(address(eSIMWallet1).balance, 0.1 ether, "ESIMWallet balance should have been increased to 0.1 ETH"); - assertEq(vault.balance, 0.1 ether, "Vault balance should have updated to 0.1 ETH"); - assertEq(eSIMWalletAdmin.balance, 0.8 ether, "User balance should have been decreased to 0.8 ETH"); + assertEq(settlementERC20.balanceOf(address(eSIMWallet1)), 0, "The wallet must have spent what it held"); + assertEq(settlementERC20.balanceOf(vault), needed, "The vault must hold the price"); DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); @@ -175,62 +109,27 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); } - function test_toggleAccessToFunds_grant_deviceWalletHasETH() public { + /// @notice A granted wallet reaches the device wallet's balance + function test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() public { deployWallets(); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "eSIMWallet2 should not be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "eSIMWallet2 should not be able to spend"); vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToFunds( - address(eSIMWallet2), - true - ); + deviceWallet.toggleAccessToFunds(address(eSIMWallet2), true); vm.stopPrank(); - assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); + assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), true, "eSIMWallet2 should be able to spend"); - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 0.1 ether; + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(deviceWallet), needed); - vm.deal(address(deviceWallet), 1 ether); vm.startPrank(eSIMWalletAdmin); - eSIMWallet2.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); + eSIMWallet2.buyDataBundleWithToken(bundle("DB_ID_0", TEST_PRICE_CENTS), ASSET_USDC, needed, nextRef()); vm.stopPrank(); - assertEq(vault.balance, 0.1 ether, "Vault balance should have updated to 0.1 ETH"); - assertEq(address(deviceWallet).balance, 0.9 ether, "Device wallet balance should have been decreased to 0.9 ETH"); - - DataBundleDetails[] memory history = eSIMWallet2.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Data bundle price should have been correct"); - } - - function test_toggleAccessToFunds_grant_userHasETH() public { - deployWallets(); - - assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), false, "eSIMWallet2 should not be able to pull ETH"); - - vm.startPrank(address(deviceWallet)); - deviceWallet.toggleAccessToFunds( - address(eSIMWallet2), - true - ); - vm.stopPrank(); - - assertEq(deviceWallet.canPullFunds(address(eSIMWallet2)), true, "eSIMWallet2 should be able to pull ETH"); - - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 0.1 ether; - - vm.deal(eSIMWalletAdmin, 1 ether); - vm.startPrank(eSIMWalletAdmin); - eSIMWallet2.buyDataBundle{value: 0.2 ether}(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - assertEq(address(eSIMWallet2).balance, 0.1 ether, "ESIMWallet balance should have been increased to 0.1 ETH"); - assertEq(vault.balance, 0.1 ether, "Vault balance should have updated to 0.1 ETH"); - assertEq(eSIMWalletAdmin.balance, 0.8 ether, "User balance should have been decreased to 0.8 ETH"); + assertEq(settlementERC20.balanceOf(vault), needed, "The vault must hold the price"); + assertEq(settlementERC20.balanceOf(address(deviceWallet)), 0, "The price must have come out of the device wallet"); DataBundleDetails[] memory history = eSIMWallet2.getTransactionHistory(); assertEq(history.length, 1, "Transaction history should have been updated"); @@ -239,13 +138,13 @@ contract DeviceWalletETHTest is DeviceWalletFixture { } // --------------------------------------------------------------------------------------------- - // A bind never carries ETH access + // A bind never carries funds access // --------------------------------------------------------------------------------------------- - /// @notice The admin cannot hand a wallet ETH access at the moment it deploys it + /// @notice The admin cannot hand a wallet access at the moment it deploys it /// @dev The flag used to be the admin's to set, which made a signed revocation pointless. It /// reverts now rather than being downgraded in silence. - function test_deployESIMWallet_cannotGrantETHAccess() public { + function test_deployESIMWallet_cannotGrantFundsAccess() public { deployWallets(); vm.prank(eSIMWalletAdmin); @@ -255,7 +154,7 @@ contract DeviceWalletETHTest is DeviceWalletFixture { /// @notice Not even the device wallet itself may grant access at bind time /// @dev Pins `toggleAccessToFunds` as the single grant path, not merely the non-admin one. - function test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() public { + function test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() public { deployWallets(); vm.prank(address(deviceWallet)); @@ -270,7 +169,9 @@ contract DeviceWalletETHTest is DeviceWalletFixture { /// reaches no further: the purchase is refused and the balance is untouched. function test_theAdminCannotUndoARevocationByDeployingAnotherWallet() public { deployWallets(); - vm.deal(address(deviceWallet), 10 ether); + + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(deviceWallet), needed); vm.prank(address(deviceWallet)); deviceWallet.toggleAccessToFunds(address(eSIMWallet1), false); @@ -282,20 +183,29 @@ contract DeviceWalletETHTest is DeviceWalletFixture { vm.prank(eSIMWalletAdmin); address fresh = deviceWallet.deployESIMWallet(false, 4243); - assertFalse(deviceWallet.canPullFunds(fresh), "The fresh wallet must arrive with no ETH access"); + assertFalse(deviceWallet.canPullFunds(fresh), "The fresh wallet must arrive with no access"); vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.FundsAccessRevoked.selector, fresh)); - MockESIMWallet(payable(fresh)).buyDataBundle(bundle("DB_ID_0", TEST_PRICE_CENTS), 1 ether, nextRef()); + MockESIMWallet(payable(fresh)).buyDataBundleWithToken( + bundle("DB_ID_0", TEST_PRICE_CENTS), + ASSET_USDC, + needed, + nextRef() + ); - assertEq(address(deviceWallet).balance, 10 ether, "No ETH may leave through a wallet the user never granted"); - assertEq(vault.balance, 0, "The vault must have been paid nothing"); + assertEq( + settlementERC20.balanceOf(address(deviceWallet)), + needed, + "Nothing may leave through a wallet the user never granted" + ); + assertEq(settlementERC20.balanceOf(vault), 0, "The vault must have been paid nothing"); } - /// @notice Both deployment routes leave a wallet without ETH access + /// @notice Both deployment routes leave a wallet without funds access /// @dev Both routes used to pass the flag set. The batch is run here rather than through the /// fixture, which grants access on its way past. - function test_aFreshESIMWalletStartsWithNoETHAccess() public { + function test_aFreshESIMWalletStartsWithNoFundsAccess() public { string[] memory identifiers = new string[](1); bytes32[2][] memory keys = new bytes32[2][](1); uint256[] memory salts = new uint256[](1); @@ -317,12 +227,12 @@ contract DeviceWalletETHTest is DeviceWalletFixture { MockDeviceWallet fresh = MockDeviceWallet(payable(batch.deviceWallet)); assertFalse( fresh.canPullFunds(batch.eSIMWallet), - "The batch deployed wallet must arrive with no ETH access" + "The batch deployed wallet must arrive with no access" ); vm.prank(eSIMWalletAdmin); address second = fresh.deployESIMWallet(false, 4245); - assertFalse(fresh.canPullFunds(second), "The admin deployed wallet must arrive with no ETH access"); + assertFalse(fresh.canPullFunds(second), "The admin deployed wallet must arrive with no access"); } /// @notice The owner grants after the bind, and the purchase then goes through @@ -330,7 +240,9 @@ contract DeviceWalletETHTest is DeviceWalletFixture { /// is only correct if the owner still has a way to hand it over. function test_theOwnerGrantsAfterBinding() public { deployWallets(); - vm.deal(address(deviceWallet), 10 ether); + + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(deviceWallet), needed); vm.prank(eSIMWalletAdmin); address fresh = deviceWallet.deployESIMWallet(false, 4245); @@ -340,12 +252,25 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertTrue(deviceWallet.canPullFunds(fresh), "The grant must land"); vm.prank(eSIMWalletAdmin); - MockESIMWallet(payable(fresh)).buyDataBundle(bundle("DB_ID_0", TEST_PRICE_CENTS), 1 ether, nextRef()); + MockESIMWallet(payable(fresh)).buyDataBundleWithToken( + bundle("DB_ID_0", TEST_PRICE_CENTS), + ASSET_USDC, + needed, + nextRef() + ); - assertEq(vault.balance, 1 ether, "The vault must have been paid"); - assertEq(address(deviceWallet).balance, 9 ether, "The price must have come out of the device wallet"); + assertEq(settlementERC20.balanceOf(vault), needed, "The vault must have been paid"); + assertEq( + settlementERC20.balanceOf(address(deviceWallet)), + 0, + "The price must have come out of the device wallet" + ); } + // --------------------------------------------------------------------------------------------- + // ETH the owner still holds + // --------------------------------------------------------------------------------------------- + /// @notice Naming a function the wallet does not have must revert, while plain ETH still lands /// @dev A payable fallback answered every unknown selector with success, so a mistyped call, /// or one naming a function a later implementation no longer has, looked like it worked. @@ -363,29 +288,6 @@ contract DeviceWalletETHTest is DeviceWalletFixture { assertFalse(acceptedCall, "A call naming a function the wallet does not have must revert"); } - /// @notice An associated eSIM wallet can drain the device wallet through pullETH, so a live - /// incident needs a lever that does not wait on a beacon upgrade. - function test_pullETH_revertsWhilePaused() public { - deployWallets(); - vm.deal(address(deviceWallet), 2 ether); - - vm.prank(registry.eSIMWalletAdmin()); - registry.pause(); - - vm.prank(address(eSIMWallet1)); - vm.expectRevert(Errors.ProtocolPaused.selector); - deviceWallet.pullETH(1 ether); - - assertEq(address(deviceWallet).balance, 2 ether, "No ETH may leave while paused"); - - vm.prank(registry.owner()); - registry.unpause(); - - vm.prank(address(eSIMWallet1)); - deviceWallet.pullETH(1 ether); - assertEq(address(deviceWallet).balance, 1 ether, "The release must restore the path"); - } - /// @notice A pause stops the admin-driven and eSIM-driven flows, never an owner spending their /// own ETH. Blocking execute would hand the admin key a freeze on user funds, which is worse /// than what the pause defends against. diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol index 3bc79778..674bccce 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletGuards.t.sol @@ -117,20 +117,6 @@ contract DeviceWalletGuardsTest is DeployerBase { registry.assignESIMIdentifier(user1, "eSIM_unknown"); } - // --------------------------------------------------------------------------------------------- - // Amounts - // --------------------------------------------------------------------------------------------- - - /// @notice A zero pull is refused rather than emitting a transfer of nothing - function test_pullETH_rejectsAZeroAmount() public { - _deployWallet(customDeviceUniqueIdentifiers[0], pubKey1, 8004); - vm.deal(address(wallet), 1 ether); - - vm.prank(eSIMWallet); - vm.expectRevert(Errors.ZeroAmount.selector); - wallet.pullETH(0); - } - // --------------------------------------------------------------------------------------------- // Addresses the wallet has never bound // --------------------------------------------------------------------------------------------- diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol index b3ae42a9..24a92fd7 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol @@ -7,21 +7,23 @@ import "forge-std/console.sol"; import "contracts/CustomStructs.sol"; import {Errors} from "contracts/Errors.sol"; +import {Asset} from "contracts/payments/PaymentAdapter.sol"; import "test/utils/DeployerBase.sol"; import "test/utils/mocks/MockESIMWallet.sol"; import "test/utils/mocks/MockDeviceWallet.sol"; +import {MockReenteringERC20} from "test/utils/mocks/tokens/MockReenteringERC20.sol"; contract ESIMWalletTest is DeployerBase { // Redeclared so expectEmit can name it. This test contract does not inherit ESIMWallet. event PriceCapUSDCentsUpdated(uint64 _cap); - /// @notice A rotated admin has to reach buyDataBundle, which the factory alone does not - /// @dev This path pulls whatever price it is given out of the device wallet and sends it to - /// the vault, so an admin address that changes only on the factory leaves the retired key - /// able to drain wallets and leaves the new key unable to do its job. - function test_buyDataBundle_followsTheRotatedAdmin() public { + /// @notice A rotated admin has to reach the purchase path, which the factory alone does not + /// @dev This path spends out of the device wallet, so an admin address that changes only on the + /// factory leaves the retired key able to charge wallets and leaves the new key unable to + /// do its job. + function test_buyDataBundleWithToken_followsTheRotatedAdmin() public { deployWallets(); address retiredAdmin = registry.eSIMWalletAdmin(); @@ -30,14 +32,15 @@ contract ESIMWalletTest is DeployerBase { vm.prank(user3); registry.acceptAdminUpdate(); - vm.deal(address(deviceWallet), 1 ether); + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(deviceWallet), needed); vm.prank(retiredAdmin); vm.expectRevert(Errors.OnlyDeviceWalletOrESIMWalletAdmin.selector); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 1, nextRef()); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_ID_1", TEST_PRICE_CENTS), ASSET_USDC, needed, nextRef()); vm.prank(user3); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 1, nextRef()); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_ID_1", TEST_PRICE_CENTS), ASSET_USDC, needed, nextRef()); (, uint64 price,) = eSIMWallet1.transactionHistory(0); assertEq(price, TEST_PRICE_CENTS, "The purchase made by the rotated admin must be recorded"); @@ -548,113 +551,6 @@ contract ESIMWalletTest is DeployerBase { assertEq(eSIMWallet1.owner(), requestedOwner, "Ownership should have moved to the requested owner"); } - function test_buyDataBundle_noFundsFromESIMWallet() public { - deployWallets(); - - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 100000000000000000; // 0.1 ETH - - vm.startPrank(eSIMWalletAdmin); - // The shortfall is pulled from the device wallet, which holds nothing - vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientBalance.selector, 0, 0.1 ether)); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - vm.deal(address(deviceWallet), 1 ether); - vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - assertEq(address(deviceWallet).balance, 0.9 ether, "Device wallet balance should have been 0.9 ETH"); - assertEq((deviceWallet.getVaultAddress()).balance, 0.1 ether, "Vault balance should have increased by 0.1 ETH"); - - DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); - } - - function test_buyDataBundle_partialFundsFromESIMWallet() public { - deployWallets(); - - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 100000000000000000; // 0.1 ETH - - vm.deal(address(eSIMWallet1), 0.03 ether); - vm.startPrank(eSIMWalletAdmin); - // The wallet covers 0.03 of the 0.1 and pulls the rest from an empty device wallet - vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientBalance.selector, 0, 0.07 ether)); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - vm.deal(address(deviceWallet), 1 ether); - vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - assertEq(address(deviceWallet).balance, 0.93 ether, "Device wallet balance should have been 0.93 ETH"); - assertEq(address(eSIMWallet1).balance, 0 ether, "ESIM wallet balance should have been 0 ETH"); - assertEq((deviceWallet.getVaultAddress()).balance, 0.1 ether, "Vault balance should have increased by 0.1 ETH"); - - DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); - } - - function test_buyDataBundle_partialFundsFromUserAndESIMWallet() public { - deployWallets(); - - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 100000000000000000; // 0.1 ETH - - vm.deal(eSIMWalletAdmin, 0.04 ether); // 0.04 ETH to be supplied as msg.value - vm.deal(address(eSIMWallet1), 0.03 ether); // 0.03 ETH to be used from eSIM wallet - - vm.startPrank(eSIMWalletAdmin); - // Revert from the device wallet, needed 0.03 ETH, found 0 ETH - vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientBalance.selector, 0, 0.03 ether)); - eSIMWallet1.buyDataBundle{value: 0.04 ether}(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - vm.deal(address(deviceWallet), 1 ether); // needed 0.03 ETH from Device walelt, found 1 ETH - vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle{value: 0.04 ether}(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - assertEq(address(deviceWallet).balance, 0.97 ether, "Device wallet balance should have been 0.97 ETH"); - assertEq(address(eSIMWallet1).balance, 0 ether, "ESIM wallet balance should have been 0 ETH"); - assertEq((deviceWallet.getVaultAddress()).balance, 0.1 ether, "Vault balance should have increased by 0.1 ETH"); - - DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); - } - - function test_buyDataBundle_allFundsFromUser() public { - deployWallets(); - - DataBundleDetails memory _dataBundleDetail = bundle("DB_ID_0", TEST_PRICE_CENTS); - uint256 _priceWei = 0.1 ether; - - vm.deal(address(deviceWallet), 2 ether); - vm.deal(address(eSIMWallet1), 1 ether); - vm.deal(eSIMWalletAdmin, 0.2 ether); // remaining ETH should go to eSIM wallet - - vm.startPrank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle{value: 0.2 ether}(_dataBundleDetail, _priceWei, nextRef()); - vm.stopPrank(); - - assertEq(address(deviceWallet).balance, 2 ether, "Device wallet balance should have been 2 ETH"); - assertEq(address(eSIMWallet1).balance, 1.1 ether, "ESIM wallet balance should have been 1 ETH"); - assertEq((deviceWallet.getVaultAddress()).balance, 0.1 ether, "Vault balance should have increased by 0.1 ETH"); - - DataBundleDetails[] memory history = eSIMWallet1.getTransactionHistory(); - assertEq(history.length, 1, "Transaction history should have been updated"); - assertEq(history[0].id, "DB_ID_0", "Transaction history's data bundle ID should have been correct"); - assertEq(history[0].priceUSDCents, TEST_PRICE_CENTS, "Transaction history's data bundle price should have been correct"); - } function test_sendETHToDeviceWallet_unauthorised() public { deployWallets(); @@ -695,69 +591,9 @@ contract ESIMWalletTest is DeployerBase { assertEq(address(newDeviceWallet).balance, 1 ether, "New device wallet balance should have increased to 1 ETH"); } - /// @notice buyDataBundle pulls whatever price it is given out of the device wallet and sends it - /// to the vault, on the admin's say-so alone, which is the largest ETH exit in the protocol. - function test_buyDataBundle_revertsWhilePaused() public { - deployWallets(); - vm.deal(address(deviceWallet), 1 ether); - - vm.prank(registry.eSIMWalletAdmin()); - registry.pause(); - - vm.prank(eSIMWalletAdmin); - vm.expectRevert(Errors.ProtocolPaused.selector); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 0.1 ether, nextRef()); - - assertEq(vault.balance, 0, "The vault must receive nothing while paused"); - assertEq(address(deviceWallet).balance, 1 ether, "The device wallet must keep its ETH"); - - vm.prank(registry.owner()); - registry.unpause(); - - vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 0.1 ether, nextRef()); - assertEq(vault.balance, 0.1 ether, "The release must restore the path"); - } - - /// @notice A wallet that sets its own ceiling is held to it, whatever the admin asks for. - function test_buyDataBundle_revertsAboveTheWalletCap() public { - deployWallets(); - vm.deal(address(deviceWallet), 5 ether); - - vm.prank(address(deviceWallet)); - eSIMWallet1.setPriceCapUSDCents(20_000); // $200 - - vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 30_000, 20_000)); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", 30_000), 0.3 ether, nextRef()); - - assertEq(vault.balance, 0, "The vault must receive nothing above the cap"); - assertEq(address(deviceWallet).balance, 5 ether, "The device wallet must keep its ETH"); - } - - /// @notice A wallet holding no ceiling of its own falls back to the registry's. - /// @dev This is the case that matters for wallets already deployed. They read zero, so the - /// registry is the only place a limit can reach them from. - function test_buyDataBundle_revertsAboveTheRegistryDefault() public { - deployWallets(); - vm.deal(address(deviceWallet), 5 ether); - - vm.prank(registry.owner()); - registry.setDefaultPriceCapUSDCents(20_000); // $200 - - assertEq(eSIMWallet1.priceCapUSDCents(), 0, "The wallet must hold no ceiling of its own"); - - vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 30_000, 20_000)); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", 30_000), 0.3 ether, nextRef()); - - assertEq(vault.balance, 0, "The vault must receive nothing above the default"); - } - /// @notice A wallet's own ceiling wins over the registry default, in both directions. - function test_buyDataBundle_theWalletCapOverridesTheRegistryDefault() public { + function test_buyDataBundleWithToken_theWalletCapOverridesTheRegistryDefault() public { deployWallets(); - vm.deal(address(deviceWallet), 5 ether); vm.prank(registry.owner()); registry.setDefaultPriceCapUSDCents(10_000); // $100 @@ -765,35 +601,25 @@ contract ESIMWalletTest is DeployerBase { vm.prank(address(deviceWallet)); eSIMWallet1.setPriceCapUSDCents(100_000); // $1000 - vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", 50_000), 0.5 ether, nextRef()); - assertEq(vault.balance, 0.5 ether, "A wallet raising its own ceiling must be able to spend to it"); + uint256 needed = settlementAmount(50_000); + fundSettlementToken(address(deviceWallet), needed); vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 200_000, 100_000)); - eSIMWallet1.buyDataBundle(bundle("DB_ID_2", 200_000), 2 ether, nextRef()); - } - - /// @notice A wallet with no ceiling of its own is still bound by the registry default set at - /// deployment. - /// @dev `Registry.initialize` and `setDefaultPriceCapUSDCents` both refuse zero, so a wallet - /// that never sets its own cap is never actually uncapped, only deferring to whatever the - /// registry was given at deploy time. - function test_buyDataBundle_rejectsAPriceAboveTheRegistryDefault() public { - deployWallets(); - vm.deal(address(deviceWallet), 5 ether); - - assertEq(eSIMWallet1.priceCapUSDCents(), 0, "The wallet must hold no ceiling of its own"); - - uint64 price = defaultPriceCapUSDCents + 1; + eSIMWallet1.buyDataBundleWithToken(bundle("DB_ID_1", 50_000), ASSET_USDC, needed, nextRef()); + assertEq( + settlementERC20.balanceOf(vault), + needed, + "A wallet raising its own ceiling must be able to spend to it" + ); vm.prank(eSIMWalletAdmin); - vm.expectRevert( - abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, defaultPriceCapUSDCents) + vm.expectRevert(abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, 200_000, 100_000)); + eSIMWallet1.buyDataBundleWithToken( + bundle("DB_ID_2", 200_000), + ASSET_USDC, + settlementAmount(200_000), + nextRef() ); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", price), 0.1 ether, nextRef()); - - assertEq(vault.balance, 0, "The vault must receive nothing above the registry default"); } /// @notice The admin names the price, so it must not also be able to raise the ceiling. @@ -860,37 +686,55 @@ contract ESIMWalletTest is DeployerBase { vm.prank(address(deviceWallet2)); deviceWallet2.addESIMWallet(address(eSIMWallet1), false); - vm.deal(address(deviceWallet2), 5 ether); uint64 price = defaultPriceCapUSDCents + 1; + fundSettlementToken(address(deviceWallet2), settlementAmount(price)); vm.prank(eSIMWalletAdmin); vm.expectRevert( abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, price, defaultPriceCapUSDCents) ); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", price), 0.1 ether, nextRef()); + eSIMWallet1.buyDataBundleWithToken( + bundle("DB_ID_1", price), + ASSET_USDC, + settlementAmount(price), + nextRef() + ); - assertEq(vault.balance, 0, "The registry default must bind the new owner"); + assertEq(settlementERC20.balanceOf(vault), 0, "The registry default must bind the new owner"); } - /// @notice The purchase is already in history by the time the vault receives its ETH - /// @dev A vault that is a contract runs on receipt, so if the record landed after the transfer - /// it would see a history one entry short of the purchase that just paid it. - function test_buyDataBundle_recordsTheHistoryBeforeTheVaultIsPaid() public { + /// @notice The purchase is already in history before any token moves + /// @dev Read through a token that calls out mid-transfer. The first transfer is the pull from + /// the device wallet, so a record written after it would show a history one entry short. + function test_buyDataBundleWithToken_recordsTheHistoryBeforeAnythingMoves() public { deployWallets(); - vm.deal(address(deviceWallet), 1 ether); - HistoryReadingVault historyReadingVault = new HistoryReadingVault(address(eSIMWallet1)); - vm.prank(registry.owner()); - registry.updateVaultAddress(address(historyReadingVault)); + HistoryReadingVault historyReader = new HistoryReadingVault(address(eSIMWallet1)); + + MockReenteringERC20 callingToken = new MockReenteringERC20("Calling", "CALL", 6); + + vm.prank(upgradeManager); + paymentAdapter.registerAsset(bytes32("CALL"), Asset({ + allowed: true, + isDollarUnit: true, + decimals: 6, + token: address(callingToken) + })); + + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + callingToken.mint(address(deviceWallet), needed); + + // Armed after the mint, which is itself a transfer and would otherwise spend the one callback + callingToken.setReentry(address(historyReader), abi.encodeWithSignature("record()")); vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundle(bundle("DB_ID_1", TEST_PRICE_CENTS), 0.1 ether, nextRef()); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_ID_1", TEST_PRICE_CENTS), bytes32("CALL"), needed, nextRef()); - assertEq(historyReadingVault.historyLengthSeen(), 1, "The vault must see the purchase already recorded"); + assertEq(historyReader.historyLengthSeen(), 1, "The purchase must already be recorded before any transfer"); } } -/// @notice Reads back the paying wallet's transaction history length as it receives ETH +/// @notice Reads back the paying wallet's transaction history length while a transfer is in flight contract HistoryReadingVault { MockESIMWallet private immutable eSIMWallet; uint256 public historyLengthSeen; @@ -899,7 +743,7 @@ contract HistoryReadingVault { eSIMWallet = MockESIMWallet(payable(_eSIMWallet)); } - receive() external payable { + function record() external { historyLengthSeen = eSIMWallet.getTransactionHistory().length; } } diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol index 17be8646..4226fab6 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletGuards.t.sol @@ -64,12 +64,13 @@ contract ESIMWalletGuardsTest is DeployerBase { } /// @notice Buys a bundle at the given recorded price as the admin - /// @dev The ETH figure is fixed, since the ceiling limits the recorded price and not the - /// amount sent. /// @param _priceUSDCents The price to record function _buyAt(uint64 _priceUSDCents) internal { + uint256 needed = settlementAmount(_priceUSDCents); + fundSettlementToken(address(eSIMWallet), needed); + vm.prank(eSIMWalletAdmin); - eSIMWallet.buyDataBundle(bundle("DB_ID_CAP", _priceUSDCents), 0.1 ether, nextRef()); + eSIMWallet.buyDataBundleWithToken(bundle("DB_ID_CAP", _priceUSDCents), ASSET_USDC, needed, nextRef()); } // --------------------------------------------------------------------------------------------- @@ -98,28 +99,6 @@ contract ESIMWalletGuardsTest is DeployerBase { // Purchase inputs // --------------------------------------------------------------------------------------------- - /// @notice A purchase with no bundle named is refused - /// @dev The identifier is what the offchain side reconciles the payment against, so an empty - /// one records a payment nothing can be matched to. - function test_buyDataBundle_rejectsAnEmptyDataBundleID() public { - _deployWallets(9001); - vm.deal(address(deviceWallet), 1 ether); - - vm.prank(eSIMWalletAdmin); - vm.expectRevert(Errors.EmptyDataBundleID.selector); - eSIMWallet.buyDataBundle(bundle("", TEST_PRICE_CENTS), 1, nextRef()); - } - - /// @notice A purchase for nothing is refused - function test_buyDataBundle_rejectsAZeroPrice() public { - _deployWallets(9002); - vm.deal(address(deviceWallet), 1 ether); - - vm.prank(eSIMWalletAdmin); - vm.expectRevert(Errors.ZeroDataBundlePrice.selector); - eSIMWallet.buyDataBundle(bundle("DB_ID_1", 0), 0.1 ether, nextRef()); - } - // --------------------------------------------------------------------------------------------- // Pre-deployment history // --------------------------------------------------------------------------------------------- @@ -162,7 +141,12 @@ contract ESIMWalletGuardsTest is DeployerBase { vm.expectRevert( abi.encodeWithSelector(Errors.DataBundlePriceAboveCap.selector, PRICE_BETWEEN_THE_TWO, REGISTRY_CAP) ); - eSIMWallet.buyDataBundle(bundle("DB_ID_CAP", PRICE_BETWEEN_THE_TWO), 0.1 ether, nextRef()); + eSIMWallet.buyDataBundleWithToken( + bundle("DB_ID_CAP", PRICE_BETWEEN_THE_TWO), + ASSET_USDC, + settlementAmount(PRICE_BETWEEN_THE_TWO), + nextRef() + ); } // --------------------------------------------------------------------------------------------- diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol index 382fe4ce..c374858c 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol @@ -257,19 +257,6 @@ contract ESIMWalletTokenPurchaseTest is DeviceWalletFixture { eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, spentRef); } - /// @notice References are spent protocol-wide, so the ETH path cannot re-use one from here - function test_buyDataBundleWithToken_referenceCannotBeReusedOnTheETHPath() public { - bytes32 spentRef = nextRef(); - - vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), spentRef); - - vm.deal(address(eSIMWallet1), 1 ether); - vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, spentRef)); - eSIMWallet1.buyDataBundle(bundle("DB_ETH", PRICE_CENTS), 0.1 ether, spentRef); - } - /// @notice A second wallet cannot spend a reference the first one used function test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() public { bytes32 spentRef = nextRef(); diff --git a/test/foundry/unit-testing/registry/AdminRotation.t.sol b/test/foundry/unit-testing/registry/AdminRotation.t.sol index 0def6dd9..4f1f94f3 100644 --- a/test/foundry/unit-testing/registry/AdminRotation.t.sol +++ b/test/foundry/unit-testing/registry/AdminRotation.t.sol @@ -126,7 +126,12 @@ contract AdminRotationTest is DeployerBase { DataBundleDetails memory purchase = bundle("bundle", TEST_PRICE_CENTS); vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.OnlyDeviceWalletOrESIMWalletAdmin.selector); - ESIMWallet(payable(eSIMWallet)).buyDataBundle(purchase, 1 ether, nextRef()); + ESIMWallet(payable(eSIMWallet)).buyDataBundleWithToken( + purchase, + ASSET_USDC, + settlementAmount(TEST_PRICE_CENTS), + nextRef() + ); // The nominee has nothing yet either. The role is dormant, not transferred. _assertAdminGatesClosed(user2); @@ -296,7 +301,12 @@ contract AdminRotationTest is DeployerBase { DataBundleDetails memory purchase = bundle("bundle", TEST_PRICE_CENTS); vm.prank(eSIMWalletAdmin); vm.expectRevert(Errors.OnlyDeviceWalletOrESIMWalletAdmin.selector); - ESIMWallet(payable(eSIMWallet)).buyDataBundle(purchase, 1 ether, nextRef()); + ESIMWallet(payable(eSIMWallet)).buyDataBundleWithToken( + purchase, + ASSET_USDC, + settlementAmount(TEST_PRICE_CENTS), + nextRef() + ); } /// @notice Suspension is the owner's, which is what lets a guardian reach it through the diff --git a/test/foundry/unit-testing/registry/SettledPurchase.t.sol b/test/foundry/unit-testing/registry/SettledPurchase.t.sol index 9ff148a6..fa006e05 100644 --- a/test/foundry/unit-testing/registry/SettledPurchase.t.sol +++ b/test/foundry/unit-testing/registry/SettledPurchase.t.sol @@ -307,13 +307,16 @@ contract SettledPurchaseTest is DeployerBase { assertEq(eSIMWallet.getTransactionHistory().length, 1, "The purchase must be recorded once"); } - /// @notice A reference spent buying with ETH cannot be spent again on the settled path + /// @notice A reference spent by a purchase cannot be spent again on the settled path function test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() public { _deployWallets(); bytes32 orderRef = paymentRef("shared-order"); + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(eSIMWallet), needed); + vm.prank(eSIMWalletAdmin); - eSIMWallet.buyDataBundle(bundle("DB_BOUGHT", TEST_PRICE_CENTS), 0.1 ether, orderRef); + eSIMWallet.buyDataBundleWithToken(bundle("DB_BOUGHT", TEST_PRICE_CENTS), ASSET_USDC, needed, orderRef); vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, orderRef)); @@ -324,14 +327,17 @@ contract SettledPurchaseTest is DeployerBase { /// @notice And the same reference cannot go the other way round either /// @dev Both paths spend through the one adapter, which is what makes this hold. - function test_buyDataBundle_rejectsAReferenceSpentBySettlement() public { + function test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() public { _deployWallets(); bytes32 orderRef = paymentRef("shared-order-reversed"); _settle("DB_SETTLED", TEST_PRICE_CENTS, orderRef); + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(eSIMWallet), needed); + vm.prank(eSIMWalletAdmin); vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, orderRef)); - eSIMWallet.buyDataBundle(bundle("DB_BOUGHT", TEST_PRICE_CENTS), 0.1 ether, orderRef); + eSIMWallet.buyDataBundleWithToken(bundle("DB_BOUGHT", TEST_PRICE_CENTS), ASSET_USDC, needed, orderRef); } /// @notice An empty reference ties the purchase to nothing diff --git a/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol b/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol index a0dd799a..c90e6f11 100644 --- a/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol +++ b/test/foundry/unit-testing/upgrade/PriceCapSlotMigration.t.sol @@ -92,8 +92,11 @@ contract PriceCapSlotMigrationTest is DeployerBase { _clearCeilingBytes(address(registry), REGISTRY_PACKED_SLOT, 176); _clearCeilingBytes(address(eSIMWallet), WALLET_PACKED_SLOT, 160); + uint256 needed = settlementAmount(type(uint64).max); + fundSettlementToken(address(eSIMWallet), needed); + vm.prank(eSIMWalletAdmin); - eSIMWallet.buyDataBundle(bundle("DB_UNCAPPED", type(uint64).max), 1 ether, nextRef()); + eSIMWallet.buyDataBundleWithToken(bundle("DB_UNCAPPED", type(uint64).max), ASSET_USDC, needed, nextRef()); (, uint64 recorded,) = eSIMWallet.transactionHistory(0); assertEq(recorded, type(uint64).max, "A price of $184 quadrillion was recorded with no ceiling set"); @@ -117,7 +120,12 @@ contract PriceCapSlotMigrationTest is DeployerBase { Errors.DataBundlePriceAboveCap.selector, type(uint64).max, defaultPriceCapUSDCents ) ); - eSIMWallet.buyDataBundle(bundle("DB_UNCAPPED", type(uint64).max), 1 ether, nextRef()); + eSIMWallet.buyDataBundleWithToken( + bundle("DB_UNCAPPED", type(uint64).max), + ASSET_USDC, + settlementAmount(type(uint64).max), + nextRef() + ); } // --------------------------------------------------------------------------------------------- diff --git a/test/utils/mocks/ReentrantESIMWallet.sol b/test/utils/mocks/ReentrantESIMWallet.sol index c9f57b18..8a6bf698 100644 --- a/test/utils/mocks/ReentrantESIMWallet.sol +++ b/test/utils/mocks/ReentrantESIMWallet.sol @@ -10,14 +10,16 @@ import {DeviceWallet} from "contracts/device-wallet/DeviceWallet.sol"; /// the removal callback is not fixed for the life of the protocol. contract ReentrantESIMWallet { DeviceWallet public immutable deviceWallet; + address public immutable settlementToken; /// @notice What the wallet could still see and do while its own removal was in progress bool public wasStillValidDuringRemoval; - bool public couldStillPullETHDuringRemoval; - bool public pullETHSucceededDuringRemoval; + bool public couldStillPullDuringRemoval; + bool public pullSucceededDuringRemoval; - constructor(DeviceWallet _deviceWallet) { + constructor(DeviceWallet _deviceWallet, address _settlementToken) { deviceWallet = _deviceWallet; + settlementToken = _settlementToken; } /// @dev The registry reads both of these while the association is being cleared @@ -34,11 +36,11 @@ contract ReentrantESIMWallet { // These are etched over an existing eSIM wallet, so the slots hold that wallet's leftover // storage until each one is written. All three have to be set, not just the observed ones. wasStillValidDuringRemoval = deviceWallet.isValidESIMWallet(address(this)); - couldStillPullETHDuringRemoval = deviceWallet.canPullFunds(address(this)); - pullETHSucceededDuringRemoval = false; + couldStillPullDuringRemoval = deviceWallet.canPullFunds(address(this)); + pullSucceededDuringRemoval = false; - try deviceWallet.pullETH(1 ether) returns (uint256) { - pullETHSucceededDuringRemoval = true; + try deviceWallet.pullToken(settlementToken, 1e6) returns (uint256) { + pullSucceededDuringRemoval = true; } catch {} return 0; From 00c65ab3665155c47221af3e4477599e94cd4141 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 14:30:42 +0530 Subject: [PATCH 46/75] Drop the ETH pull rule from the wallet spec --- certora/specs/DeviceWallet.spec | 31 ++++++++++--------------------- certora/specs/ESIMWallet.spec | 9 +++++---- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/certora/specs/DeviceWallet.spec b/certora/specs/DeviceWallet.spec index 2f95bb95..5441dcb7 100644 --- a/certora/specs/DeviceWallet.spec +++ b/certora/specs/DeviceWallet.spec @@ -1,11 +1,14 @@ /// DeviceWallet: the rights it hands to eSIM wallets, and the key that owns it. /// -/// A device wallet holds user ETH and decides which eSIM wallets may reach it. Two mappings carry -/// that decision, `isValidESIMWallet` for membership and `canPullFunds` for the spending right, and -/// the second is meaningless without the first: `pullETH` checks `canPullFunds` on its own, so a -/// wallet that kept the right after being let go would still be able to spend. The rules below fix -/// the relation between the two, fix the one ETH path against the balance, and fix the P256 key -/// that authorises everything this wallet does. +/// A device wallet holds user funds and decides which eSIM wallets may reach them. Two mappings +/// carry that decision, `isValidESIMWallet` for membership and `canPullFunds` for the spending +/// right, and the second is meaningless without the first: `pullToken` checks `canPullFunds` on its +/// own, so a wallet that kept the right after being let go would still be able to spend. The rules +/// below fix the relation between the two, and fix the P256 key that authorises everything this +/// wallet does. +/// +/// What a pull moves is not stated here. It is an ERC-20 transfer, and the token is outside the +/// scene, so the amount is carried by the unit tests in `DeviceWalletTokens.t.sol` instead. /// /// Scope. Calls out of this wallet, into the registry, the two factories and the eSIM wallets, are /// summarised as NONDET, so what is proved is this contract's own storage. That is the same scope @@ -87,21 +90,7 @@ rule theRightToPullETHNeverOutlivesMembership(method f, address eSIMWallet) filt f(callEnv, args); assert canPullFunds(eSIMWallet) => isValidESIMWallet(eSIMWallet), - "an eSIM wallet kept the right to pull ETH without being recognised"; -} - -/// R-14. The ETH path pays out no more than the wallet holds. -/// -/// The route ends in `_transferETH`, which compares against the live balance. Stated over the entry -/// point rather than over the internal function, so the guard is proved where a caller meets it. It -/// is not payable, so the balance the call reads is the balance read here. -rule pullingMoreETHThanTheWalletHoldsAlwaysReverts(uint256 amount) { - require amount > nativeBalances[currentContract]; - - env callEnv; - pullETH@withrevert(callEnv, amount); - - assert lastReverted, "an eSIM wallet pulled more ETH than the device wallet held"; + "an eSIM wallet kept the right to spend without being recognised"; } /// R-15. The registry and the eSIM wallet factory are written once. diff --git a/certora/specs/ESIMWallet.spec b/certora/specs/ESIMWallet.spec index 16ec1bcb..8d988232 100644 --- a/certora/specs/ESIMWallet.spec +++ b/certora/specs/ESIMWallet.spec @@ -232,8 +232,9 @@ rule theOwnerIsAlwaysTheDeviceWallet(method f) filtered { /// R-12. Purchase history is append-only. /// -/// Two writers, `buyDataBundle` and the registry's `populateHistory`, and neither is allowed to -/// drop an entry. History is what a dispute is settled from, so losing one is not recoverable. +/// Two writers, `buyDataBundleWithToken` and the registry's `populateHistory`, and neither is +/// allowed to drop an entry. History is what a dispute is settled from, so losing one is not +/// recoverable. /// /// Not stated here, because the prover cannot reach the array length on this contract. The storage /// analysis fails on `setESIMUniqueIdentifier`, and every phrasing needs it: @@ -251,8 +252,8 @@ rule theOwnerIsAlwaysTheDeviceWallet(method f) filtered { /// handover. /// /// Not in the original milestone list, since the ceiling postdates it. Worth stating because the -/// ceiling is what stops the admin naming its own price on `buyDataBundle`, and the guard on the -/// setter is the whole of that protection. +/// ceiling is what stops the admin naming its own price on `buyDataBundleWithToken`, and the guard +/// on the setter is the whole of that protection. /// /// The second assert is what makes the handover exception safe. A handover may only take the /// ceiling to zero, which hands the wallet to the registry default rather than to a figure the From 70475731ca47f799327821c7873c7d08c5a00e94 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 14:39:11 +0530 Subject: [PATCH 47/75] Refresh the baselines and docs after the removal --- .gas-snapshot | 542 +++++++++--------- docs/device-wallet/DeviceWallet.md | 57 +- docs/esim-wallet/ESIMWallet.md | 39 -- snapshots/DeviceWallet.Operations.json | 7 +- snapshots/DeviceWalletFactory.Operations.json | 6 +- snapshots/ESIMWallet.Operations.json | 18 +- snapshots/LazyWalletRegistry.Operations.json | 20 +- snapshots/Registry.Operations.json | 10 +- 8 files changed, 295 insertions(+), 404 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 1f8f4b6c..59e5375e 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,27 +1,27 @@ -Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 971257) -Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926625) -Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 932019) -Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1021174) -Account4337Test:test_executeBatch_runsEveryCall() (gas: 1004056) -Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977999) -Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 931310) -Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 929392) -Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924474) -Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924834) -Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 963363) -Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 929032) -Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 929126) -Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007861) -Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966798) -Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970482) -Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964586) +Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 971191) +Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926559) +Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 931953) +Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1021108) +Account4337Test:test_executeBatch_runsEveryCall() (gas: 1003990) +Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977933) +Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 931244) +Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 929326) +Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924408) +Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924768) +Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 963297) +Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 928966) +Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 929060) +Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007795) +Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966732) +Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970416) +Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964520) AdminRotationTest:test_acceptAdminUpdate() (gas: 47187) AdminRotationTest:test_acceptAdminUpdate_afterRevoke() (gas: 48918) AdminRotationTest:test_acceptAdminUpdate_clearsASuspension() (gas: 49065) AdminRotationTest:test_acceptAdminUpdate_currentAdmin() (gas: 42653) AdminRotationTest:test_acceptAdminUpdate_reachesEveryReader() (gas: 68118) AdminRotationTest:test_acceptAdminUpdate_withoutRequest() (gas: 19779) -AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175583) +AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175693) AdminRotationTest:test_disableAdmin_endsThePauseLoopAgainstACompromisedKey() (gas: 64565) AdminRotationTest:test_disableAdmin_rejectsARepeat() (gas: 29532) AdminRotationTest:test_disableAdmin_rejectsAnyoneButTheOwner() (gas: 38707) @@ -34,7 +34,7 @@ AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentLiftsASuspension() ( AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 285017) AdminRotationTest:test_requestAdminUpdate_rejectsAnyoneButTheOwner() (gas: 45957) AdminRotationTest:test_requestAdminUpdate_revoke() (gas: 43550) -AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1221945) +AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1222055) AdminRotationTest:test_requestAdminUpdate_zeroAddress() (gas: 19658) AdminStorageLayoutTest:test_layout_aDoneOperationNeverReadsReady() (gas: 8064) AdminStorageLayoutTest:test_layout_accessControlRolesReadSlotZero() (gas: 16215) @@ -44,14 +44,14 @@ AdminStorageLayoutTest:test_layout_readinessComesOnlyFromTheTimestamp() (gas: 90 AdminStorageLayoutTest:test_layout_theContractAddsNoStorageOfItsOwn() (gas: 7864) AdminStorageLayoutTest:test_layout_theDelayFloorIsNotInStorage() (gas: 8697) AdminStorageLayoutTest:test_layout_theGuardianRoleLivesInTheSameMappingAsTheRest() (gas: 17469) -AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2803252) +AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2573241) AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 4252888) -AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3615351) -AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2874870) +AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3496626) +AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2644859) AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12932702) AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3658751) AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 12981881) -AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5936539) +AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5587964) AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 4286072) AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 4225962) AdminUpgradesTest:test_upgrade_rejectsAProposerActingDirectly() (gas: 4225984) @@ -64,57 +64,51 @@ Deployer:test_lazyWalletRegistry_setUp() (gas: 21019) Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45819) Deployer:test_registry_ownable2step() (gas: 47346) Deployer:test_registry_setUp() (gas: 54915) -DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3713138) -DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3394182) -DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3450274) -DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395696) -DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399969) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3432110) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3401433) -DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388884) -DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3404543) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471676) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3394628) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPullETH() (gas: 3690072) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390612) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395677) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3458289) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3397201) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3419060) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403905) -DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3712546) -DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoETHAccess() (gas: 1270437) -DeviceWalletETHTest:test_addESIMWallet_cannotGrantETHAccessEvenFromTheWalletItself() (gas: 3394227) -DeviceWalletETHTest:test_deployESIMWallet_cannotGrantETHAccess() (gas: 3685534) -DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402965) -DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3438558) -DeviceWalletETHTest:test_getVaultAddress() (gas: 3396454) -DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3419155) -DeviceWalletETHTest:test_pullETH() (gas: 3405743) -DeviceWalletETHTest:test_pullETH_revertsWhilePaused() (gas: 3422374) -DeviceWalletETHTest:test_pullETH_revokedESIMWallet() (gas: 3396060) -DeviceWalletETHTest:test_pullETH_unauthorise() (gas: 3397749) -DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4114751) -DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 3972451) -DeviceWalletETHTest:test_toggleAccessToFunds_grant_deviceWalletHasETH() (gas: 3619350) -DeviceWalletETHTest:test_toggleAccessToFunds_grant_userHasETH() (gas: 3612753) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_deviceWalletHasETH() (gas: 3457629) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_eSIMWalletHasETH() (gas: 3564980) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_userHasETH() (gas: 3572469) -DeviceWalletETHTest:test_toggleAccessToFunds_unauthorised() (gas: 3397233) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4552254) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936931) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960919) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977565) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931937) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930455) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946474) +DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3745718) +DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3393661) +DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3449525) +DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395175) +DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399404) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3431405) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3400912) +DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388363) +DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3403929) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471083) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3393973) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPull() (gas: 3774443) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390057) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395156) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3457768) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3396691) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3418333) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403384) +DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3745124) +DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoFundsAccess() (gas: 1270323) +DeviceWalletETHTest:test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() (gas: 3393673) +DeviceWalletETHTest:test_deployESIMWallet_cannotGrantFundsAccess() (gas: 3685306) +DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402168) +DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3437784) +DeviceWalletETHTest:test_getVaultAddress() (gas: 3395658) +DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3418337) +DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4242702) +DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 4012726) +DeviceWalletETHTest:test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() (gas: 3659153) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() (gas: 3580830) +DeviceWalletETHTest:test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() (gas: 3605526) +DeviceWalletETHTest:test_toggleAccessToFunds_unauthorised() (gas: 3396388) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4551264) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936843) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960853) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977499) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931871) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930389) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946408) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_withoutAdminOrRegistry() (gas: 76765) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959752) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959686) DeviceWalletFactoryConfigTest:test_addRegistryAddress_onlyOnce() (gas: 22234) DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutAdmin() (gas: 37790) DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutOwner() (gas: 21191) -DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3994533) +DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3875740) DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation_admin() (gas: 36155) DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 566022) DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller() (gas: 382397) @@ -123,20 +117,20 @@ DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579639 DeviceWalletFactoryCreateAccountTest:test_createAccount_forwardsValueToAnAlreadyDeployedWallet() (gas: 391140) DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369842) DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351699) -DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928290) +DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928224) DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885367) DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886830) DeviceWalletFactoryGuardsTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 3556215) DeviceWalletFactoryGuardsTest:test_createAccount_rejectsAnEmptyDeviceIdentifier() (gas: 19711) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsADepositAboveTheETHSent() (gas: 53937) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938292) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936216) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938226) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936150) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortDepositArray() (gas: 58027) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortKeyArray() (gas: 52070) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortSaltArray() (gas: 57168) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyBatch() (gas: 35714) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyDeviceIdentifier() (gas: 57816) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975925) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975859) DeviceWalletFactoryGuardsTest:test_eSIMWalletAdmin_isEmptyUntilTheRegistryIsAdded() (gas: 3565235) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroESIMWalletFactory() (gas: 3141656) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 3141332) @@ -144,15 +138,15 @@ DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroVerifier() (gas: 3141855) DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539403) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412211) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 918102) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 918036) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressThatIsNotADeviceWallet() (gas: 88221) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressWithNoCode() (gas: 75397) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnEmptyDeviceIdentifier() (gas: 42131) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnIdentifierTheWalletDoesNotCarry() (gas: 408258) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnOffCurveOwnerKey() (gas: 368145) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsTheWrongSalt() (gas: 391838) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914974) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 918115) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914908) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 918049) DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheCurrentImplementation() (gas: 31777) DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheZeroAddress() (gas: 19621) DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnOffCurveOwnerKey() (gas: 41069) @@ -162,135 +156,123 @@ DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnOffCur DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOwnerKey() (gas: 83875) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnOffCurveOwnerKey() (gas: 19826) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnZeroOwnerKey() (gas: 19342) -DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961429) -DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954265) +DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961341) +DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954195) DeviceWalletGuardsTest:test_init_rejectsAZeroESIMWalletFactory() (gas: 117237) -DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117854) -DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115169) -DeviceWalletGuardsTest:test_pullETH_rejectsAZeroAmount() (gas: 954823) -DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972481) -DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963583) -DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957188) -DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960981) -DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3412551) -DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4423698) -DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3428416) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3402722) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3413587) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402765) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3402508) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402833) -DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1129021) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1459063) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149679) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132264) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120675) -DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3395356) +DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117832) +DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115147) +DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972371) +DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963517) +DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957100) +DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960915) +DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3411799) +DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4422748) +DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3427664) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3401970) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3412835) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402013) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3401756) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402081) +DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128823) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458865) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149481) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132066) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120477) +DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3394604) DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion() (gas: 30674) -DeviceWalletTokensTest:test_pullToken_emitsTokenSent() (gas: 77501) -DeviceWalletTokensTest:test_pullToken_movesATokenThatReturnsNothing() (gas: 327533) -DeviceWalletTokensTest:test_pullToken_movesTheAmountToTheCallingWallet() (gas: 79146) -DeviceWalletTokensTest:test_pullToken_revertsForACallerThatIsNotAnAssociatedWallet() (gas: 29202) -DeviceWalletTokensTest:test_pullToken_revertsForATokenTheWalletDoesNotHold() (gas: 653145) -DeviceWalletTokensTest:test_pullToken_revertsForAWalletWithoutAccess() (gas: 44181) -DeviceWalletTokensTest:test_pullToken_revertsForAnotherDevicesWallet() (gas: 28910) -DeviceWalletTokensTest:test_pullToken_revertsOnTheZeroTokenAddress() (gas: 39574) -DeviceWalletTokensTest:test_pullToken_revertsOnZeroAmount() (gas: 41332) -DeviceWalletTokensTest:test_pullToken_revertsWhenTheBalanceIsShort() (gas: 49128) -DeviceWalletTokensTest:test_pullToken_revertsWhileTheProtocolIsPaused() (gas: 53608) -DeviceWalletTokensTest:test_pullToken_stopsOnceAccessIsRevoked() (gas: 86396) +DeviceWalletTokensTest:test_pullToken_emitsTokenSent() (gas: 77477) +DeviceWalletTokensTest:test_pullToken_movesATokenThatReturnsNothing() (gas: 327509) +DeviceWalletTokensTest:test_pullToken_movesTheAmountToTheCallingWallet() (gas: 79122) +DeviceWalletTokensTest:test_pullToken_revertsForACallerThatIsNotAnAssociatedWallet() (gas: 29187) +DeviceWalletTokensTest:test_pullToken_revertsForATokenTheWalletDoesNotHold() (gas: 653121) +DeviceWalletTokensTest:test_pullToken_revertsForAWalletWithoutAccess() (gas: 44157) +DeviceWalletTokensTest:test_pullToken_revertsForAnotherDevicesWallet() (gas: 28895) +DeviceWalletTokensTest:test_pullToken_revertsOnTheZeroTokenAddress() (gas: 39550) +DeviceWalletTokensTest:test_pullToken_revertsOnZeroAmount() (gas: 41308) +DeviceWalletTokensTest:test_pullToken_revertsWhenTheBalanceIsShort() (gas: 49104) +DeviceWalletTokensTest:test_pullToken_revertsWhileTheProtocolIsPaused() (gas: 53584) +DeviceWalletTokensTest:test_pullToken_stopsOnceAccessIsRevoked() (gas: 86348) ESIMWalletFactoryTest:test_addRegistryAddress_onlyOnce() (gas: 25701) ESIMWalletFactoryTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 2412279) ESIMWalletFactoryTest:test_addRegistryAddress_withoutOwner() (gas: 21210) -ESIMWalletFactoryTest:test_deployESIMWallet() (gas: 343709) -ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837783) -ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324868) +ESIMWalletFactoryTest:test_deployESIMWallet() (gas: 343577) +ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837761) +ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324846) ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() (gas: 331736) -ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617341) +ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617297) ESIMWalletFactoryTest:test_deployESIMWallet_unauthorised() (gas: 35389) ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065366) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3815296) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3584883) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_rejectsTheZeroAddress() (gas: 18268) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_unauthorised() (gas: 21012) -ESIMWalletGuardsTest:test_buyDataBundle_rejectsAZeroPrice() (gas: 1007771) -ESIMWalletGuardsTest:test_buyDataBundle_rejectsAnEmptyDataBundleID() (gas: 1007885) -ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105710) +ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105666) ESIMWalletGuardsTest:test_initialize_rejectsAZeroFactory() (gas: 104832) -ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979670) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 978130) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039412) -ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1203959) -ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2397213) -ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2461818) -ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2430906) -ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2402025) -ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2412625) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2396749) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2398497) -ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510809) -ESIMWalletTest:test_buyDataBundle_allFundsFromUser() (gas: 1706580) -ESIMWalletTest:test_buyDataBundle_followsTheRotatedAdmin() (gas: 1729013) -ESIMWalletTest:test_buyDataBundle_noFundsFromESIMWallet() (gas: 1769032) -ESIMWalletTest:test_buyDataBundle_partialFundsFromESIMWallet() (gas: 1770122) -ESIMWalletTest:test_buyDataBundle_partialFundsFromUserAndESIMWallet() (gas: 1782956) -ESIMWalletTest:test_buyDataBundle_recordsTheHistoryBeforeTheVaultIsPaid() (gas: 1866687) -ESIMWalletTest:test_buyDataBundle_rejectsAPriceAboveTheRegistryDefault() (gas: 1549545) -ESIMWalletTest:test_buyDataBundle_revertsAboveTheRegistryDefault() (gas: 1559913) -ESIMWalletTest:test_buyDataBundle_revertsAboveTheWalletCap() (gas: 1569244) -ESIMWalletTest:test_buyDataBundle_revertsWhilePaused() (gas: 1730002) -ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2463552) -ESIMWalletTest:test_buyDataBundle_theWalletCapOverridesTheRegistryDefault() (gas: 1748450) -ESIMWalletTest:test_owner() (gas: 1506052) -ESIMWalletTest:test_populateHistory() (gas: 1795962) -ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1907281) -ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844809) -ESIMWalletTest:test_requestTransferOwnership() (gas: 2405816) -ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2426161) -ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3246152) -ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3264809) -ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2424402) -ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2435630) -ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517606) -ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510972) -ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517738) -ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2477323) -ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508920) -ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533862) -ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511701) -ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508646) -ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510960) -ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511552) -ESIMWalletTest:test_transferOwnership() (gas: 1508834) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 276475) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 263254) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 273007) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 269987) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 284831) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 276832) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 270236) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() (gas: 338228) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedOnTheETHPath() (gas: 288310) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1485428) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86955) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 105188) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 146612) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 75657) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 128976) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 287659) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79793) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79404) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 219241) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 125111) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 130698) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88597) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 265777) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 267536) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() (gas: 53620) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsATokenTheProtocolNeverPricedIn() (gas: 687143) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsTheBalance() (gas: 53854) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsForAnyoneElse() (gas: 58165) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnTheZeroTokenAddress() (gas: 22369) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnZeroAmount() (gas: 24016) +ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979538) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 977993) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039218) +ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1231946) +ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2396324) +ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2460907) +ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2429995) +ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2401114) +ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411824) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395867) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397608) +ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510277) +ESIMWalletTest:test_buyDataBundleWithToken_followsTheRotatedAdmin() (gas: 1768374) +ESIMWalletTest:test_buyDataBundleWithToken_recordsTheHistoryBeforeAnythingMoves() (gas: 3016767) +ESIMWalletTest:test_buyDataBundleWithToken_theWalletCapOverridesTheRegistryDefault() (gas: 1788275) +ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2513906) +ESIMWalletTest:test_owner() (gas: 1505476) +ESIMWalletTest:test_populateHistory() (gas: 1795415) +ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906640) +ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844193) +ESIMWalletTest:test_requestTransferOwnership() (gas: 2405037) +ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425360) +ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3245219) +ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263835) +ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423425) +ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434675) +ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517074) +ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510440) +ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517113) +ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2476297) +ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508407) +ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533352) +ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511235) +ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508158) +ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510406) +ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1510932) +ESIMWalletTest:test_transferOwnership() (gas: 1508324) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 276186) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 262954) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 272718) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 269735) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 284520) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 276543) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 269984) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() (gas: 337755) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1485183) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86755) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 104988) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 146417) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 75609) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 128803) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 287186) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79615) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79238) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 218951) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 124938) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 130525) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88409) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 265571) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 267308) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() (gas: 53602) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsATokenTheProtocolNeverPricedIn() (gas: 687121) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsTheBalance() (gas: 53836) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsForAnyoneElse() (gas: 58143) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnTheZeroTokenAddress() (gas: 22347) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnZeroAmount() (gas: 23994) GuardianPowersTest:test_disableAdminInstantly_cannotChooseAReplacement() (gas: 36672) GuardianPowersTest:test_disableAdminInstantly_cannotReinstate() (gas: 95421) GuardianPowersTest:test_disableAdminInstantly_emitsTheSuspension() (gas: 39312) @@ -323,25 +305,25 @@ GuardianPowersTest:test_unpauseInstantly_rejectsAnAccountWithoutTheGuardianRole( GuardianPowersTest:test_unpauseInstantly_rejectsTheProposer() (gas: 17244) GuardianPowersTest:test_unpauseInstantly_releasesAPauseWithoutWaiting() (gas: 42192) GuardianPowersTest:test_unpauseInstantly_revertsOnATargetWithNoCode() (gas: 20187) -IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1943988) -IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1105910) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2595331) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928779) -IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 988041) +IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1943721) +IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1105800) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2595133) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928713) +IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 987909) IdentifierCollisionTest:test_assignESIMIdentifier_rejectsAWalletTheProtocolDoesNotKnow() (gas: 33971) -IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1225281) -IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008652) +IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1225171) +IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008542) IdentifierCollisionTest:test_postCreateAccount_cannotClaimAReservedDeviceIdentifier() (gas: 562963) -IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1229130) -IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1830128) +IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1228998) +IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1829908) ImplementationLocksTest:test_DeviceWalletFactory_implementationCannotBeInitialized() (gas: 3065558) -ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3482510) +ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3357746) ImplementationLocksTest:test_ESIMWalletFactory_implementationCannotBeInitialized() (gas: 1991966) -ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2698011) +ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2457348) ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3591688) ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 4224587) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2178557) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168606) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2178245) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168316) LazySaltCollisionTest:test_getCounterFactualAddress_matchesTheDeployedAddress() (gas: 325495) LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortDataBundleArray() (gas: 35457) LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortESIMIdentifierArray() (gas: 34648) @@ -364,52 +346,52 @@ LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_reve LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3229497) LazyWalletRegistryTest:test_batchPopulateHistory_acceptsAnIdentifierAtTheLimit() (gas: 204715) LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4422448) -LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6269579) +LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6268369) LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2943885) LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3287905) LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3321324) -LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1607451) +LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1607341) LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongDeviceIdentifier() (gas: 46482) LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongESIMIdentifier() (gas: 60738) LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 235233) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6231630) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1674044) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6095458) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2203792) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27266895) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12142739) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6062999) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6230420) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1673912) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6094908) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2203528) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27260955) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12140539) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6062449) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutAdmin() (gas: 44658) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutESIMIdentifier() (gas: 51638) -LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6078277) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3531663) +LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6077727) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3531113) LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_refusesADeviceWithNoFirstBatch() (gas: 461277) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2373823) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6233229) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2373493) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6232019) LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3234439) LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4427566) LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_unregisteredIdentfier() (gas: 17775) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2033015) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2520087) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1346510) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1348221) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2032818) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2519811) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1346400) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1348111) LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnESIMItNeverDeployed() (gas: 329181) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1346333) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1551580) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6764809) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2772343) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2047277) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1346223) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1551439) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6764527) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2771899) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2047111) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3333481) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1602532) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6257162) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6254245) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1602422) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6255952) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6253035) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_unregistered() (gas: 37057) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_withoutAdmin() (gas: 32465) -NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157854) +NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157788) NemesisIdentifierSquatPoC:test_NM007_extraESIMWalletsGiveNoRouteIn() (gas: 361617) -NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143274) +NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143208) NemesisIdentifierSquatPoC:test_NM007_theOldClaimSelectorIsUnreachable() (gas: 13826) -NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55571) +NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55549) NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 49456) NemesisPausePoC:test_NM005_suspendingTheAdminRemovesThePauseLever() (gas: 49382) NemesisPausePoC:test_NM005_theProtocolStaysUnpausedWhileThePauseIsUnreachable() (gas: 31162) @@ -486,11 +468,11 @@ PaymentAdapterTest:test_updateAsset_withdrawsACurrency() (gas: 34405) PaymentAdapterTest:test_upgradeManager_matchesTheOwner() (gas: 13961) PaymentAdapterTest:test_upgrade_keepsTheSpentReferences() (gas: 1700526) PaymentAdapterTest:test_upgrade_rejectsAnyoneButTheOwner() (gas: 1664768) -PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011444) -PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1179361) -PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982850) -PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1135474) -PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814504) +PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011356) +PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1207654) +PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982762) +PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1135364) +PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814265) PriceCapSlotMigrationTest:test_migration_theOldCeilingSlotIsReadAsTheAdapter() (gas: 11352) ProtocolAdminTest:test_cancel_leavesThePayloadSchedulableAgain() (gas: 96360) ProtocolAdminTest:test_cancel_rejectsAnAccountHoldingNeitherRole() (gas: 56421) @@ -526,12 +508,12 @@ ProtocolAdminTest:test_updateDelay_cannotBringTheDelayBelowTheFloor() (gas: 6904 ProtocolAdminTest:test_updateDelay_rejectsACallerThatIsNotTheContractItself() (gas: 12158) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheAdmin() (gas: 25405) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheZeroAddress() (gas: 18743) -RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295502) -RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272387) +RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295436) +RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272251) RegistryGuardsTest:test_bindESIMWallet_rejectsACallerThatIsNotADeviceWallet() (gas: 20718) -RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297743) +RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297677) RegistryGuardsTest:test_deployLazyWallet_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 30823) -RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927176) +RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927110) RegistryGuardsTest:test_deployMoreLazyESIMWallets_rejectsACallerOtherThanTheLazyRegistry() (gas: 23540) RegistryGuardsTest:test_initialize_rejectsAZeroAdmin() (gas: 4345432) RegistryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 4344569) @@ -548,9 +530,9 @@ RegistryInitializeTest:test_initialize_revertsWhenDeviceWalletFactoryIsZero() (g RegistryInitializeTest:test_initialize_revertsWhenESIMWalletFactoryIsZero() (gas: 4344569) RegistryInitializeTest:test_initialize_revertsWhenPriceCapIsZero() (gas: 4346107) RegistryInitializeTest:test_initialize_revertsWhenVaultIsZero() (gas: 4346706) -RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1772100) -RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922484) -RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770637) +RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1771762) +RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922374) +RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770299) RegistryTest:test_pause_onlyTheAdminCanTripIt() (gas: 47066) RegistryTest:test_pause_survivesAnAdminRotation() (gas: 55219) RegistryTest:test_requireNotPaused_revertsOnlyWhilePaused() (gas: 28433) @@ -563,7 +545,7 @@ RegistryTest:test_updateVaultAddress_rejectsTheCurrentAddress() (gas: 23282) RegistryTest:test_updateVaultAddress_rejectsTheZeroAddress() (gas: 26180) RenounceOwnershipTest:test_renounceOwnership_revertsOnDeviceWalletFactory() (gas: 30236) RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletFactory() (gas: 29394) -RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 924117) +RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 924007) RenounceOwnershipTest:test_renounceOwnership_revertsOnLazyWalletRegistry() (gas: 22029) RenounceOwnershipTest:test_renounceOwnership_revertsOnRegistry() (gas: 22063) RoleRecoveryTest:test_grantRole_keepsALaterGuardianExecutingOnceExecutionIsClosed() (gas: 236681) @@ -583,36 +565,36 @@ RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 14520 RoleRecoveryTest:test_recovery_nobodyCanStepDownOnAnotherAccountsBehalf() (gas: 19003) RoleRecoveryTest:test_revokeRole_evictsAGuardianCompletelyInOneBatch() (gas: 92280) RoleRecoveryTest:test_revokeRole_leavesAnEvictedGuardianAbleToExecute() (gas: 76667) -SettledPurchaseTest:test_buyDataBundle_rejectsAReferenceSpentBySettlement() (gas: 1120251) -SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19325) -SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1131748) -SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6295612) -SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1111789) -SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1076383) -SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1131290) -SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1127649) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002279) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1056817) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1168124) -SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1118406) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037739) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002458) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1003035) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 1001453) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021316) +SettledPurchaseTest:test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() (gas: 1170914) +SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19347) +SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1131638) +SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6295030) +SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1111760) +SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1076273) +SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1131224) +SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1127620) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002213) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1056729) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1194357) +SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1118274) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037673) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002414) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1002969) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 1001387) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021228) SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49208) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005087) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6083884) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009825) -SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1106465) -SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24201) -SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20780) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005021) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6083312) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009759) +SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1106399) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24223) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20802) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20532) -SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29750) +SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29772) StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21373) StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544338) StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16047) -StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 855159) +StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 855086) StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34461) StorageLayoutTest:test_layout_paymentAdapterSlotsAreUnchanged() (gas: 18652) StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 57163) @@ -621,12 +603,12 @@ UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWall UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39810) UpgradeAuthorityTest:test_upgradeManager_matchesTheOwner() (gas: 29385) UpgradeAuthorityTest:test_upgradeManager_theRetiredOwnerCannotUpgrade() (gas: 4245476) -UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975934) -UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967426) -UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976447) -UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938461) -UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 976110) -UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 947369) +UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975868) +UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967360) +UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976381) +UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938395) +UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 976044) +UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 947303) WebAuthnTest:test_verifySignature_rejectsARegistrationCeremonyType() (gas: 20365) WebAuthnTest:test_verifySignature_rejectsAbsentUserPresentFlag() (gas: 29039) WebAuthnTest:test_verifySignature_rejectsAbsentUserVerifiedFlagWhenRequired() (gas: 28673) diff --git a/docs/device-wallet/DeviceWallet.md b/docs/device-wallet/DeviceWallet.md index 4199e763..c75b1719 100644 --- a/docs/device-wallet/DeviceWallet.md +++ b/docs/device-wallet/DeviceWallet.md @@ -60,16 +60,6 @@ event FundsAccessUpdated(address _eSIMWalletAddress, bool _hasAccessToFunds) Emitted when owner updates an eSIM wallet's access to this wallet's money -### ETHSent - -```solidity -event ETHSent(address _eSIMWalletAddress, uint256 _amount) -``` - -Emitted when ETH is sent out from the contract - -_mostly when an eSIM wallet pulls ETH from this contract_ - ### TokenSent ```solidity @@ -208,29 +198,6 @@ _The new wallet has no eSIM identifier yet. That arrives through | ---- | ---- | ----------- | | [0] | address | eSIM wallet address | -### pullETH - -```solidity -function pullETH(uint256 _amount) external returns (uint256) -``` - -Allow the eSIM wallets associated with this device wallet to pull ETH (for data bundles) - -_Refused while the protocol is paused, and refused for a wallet whose access the owner - has revoked._ - -#### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| _amount | uint256 | Amount of ETH to pull | - -#### Return Values - -| Name | Type | Description | -| ---- | ---- | ----------- | -| [0] | uint256 | The amount pulled | - ### pullToken ```solidity @@ -239,9 +206,10 @@ function pullToken(address _token, uint256 _amount) external returns (uint256) Allow an associated eSIM wallet to pull an ERC-20 (for data bundles) -_Same gate and pause check as `pullETH`. It exists so the admin can charge this wallet - without an owner signature in that transaction; an owner buying for themselves can - batch the transfer and the purchase through `executeBatch` instead._ +_Refused while the protocol is paused, and refused for a wallet whose access the owner + has revoked. It exists so the admin can charge this wallet without an owner signature + in that transaction; an owner buying for themselves can batch the transfer and the + purchase through `executeBatch` instead._ #### Parameters @@ -357,23 +325,6 @@ _Refuses a wallet this device wallet does not already own, so binding cannot run | _eSIMWalletAddress | address | Address of the eSIM wallet to bind | | _hasAccessToFunds | bool | Must be false | -### _transferETH - -```solidity -function _transferETH(address _recipient, uint256 _amount) internal virtual -``` - -Sends ETH out of this wallet, reverting if the call fails - -_A zero amount is a no-op rather than a revert._ - -#### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| _recipient | address | Address receiving the ETH | -| _amount | uint256 | Amount in wei | - ### getVaultAddress ```solidity diff --git a/docs/esim-wallet/ESIMWallet.md b/docs/esim-wallet/ESIMWallet.md index 13936748..044adb19 100644 --- a/docs/esim-wallet/ESIMWallet.md +++ b/docs/esim-wallet/ESIMWallet.md @@ -70,14 +70,6 @@ event ESIMWalletDeployed(address _eSIMWalletAddress, address _deviceWalletAddres Emitted when the eSIM wallet is deployed -### DataBundleBought - -```solidity -event DataBundleBought(bytes32 _dataBundleID, uint64 _priceUSDCents, uint256 _priceWei, uint256 _ethFromUser, bytes32 _paymentReference) -``` - -Emitted when the payment for a data bundle is made - ### DataBundleBoughtWithToken ```solidity @@ -326,37 +318,6 @@ Deliberately not nonReentrant. removeESIMWallet calls this from inside a try/cat | ---- | ---- | ----------- | | _amount | uint256 | Amount of ETH to be sent | -### buyDataBundle - -```solidity -function buyDataBundle(struct DataBundleDetails _dataBundleDetail, uint256 _priceWei, bytes32 _paymentReference) public payable returns (bool) -``` - -Pays the vault for one data bundle in ETH and records the purchase - -_Callable by the owning device wallet or by the admin, since the admin is the party that - knows the price. Any shortfall is pulled from the device wallet, which is why the price - is checked against a ceiling the admin cannot raise. - - The ceiling is in cents and the ETH sent is in wei, and nothing onchain converts - between them. So the ceiling limits what gets recorded, not what gets sent, and - `_priceWei` is taken on trust. The token path closes that gap by working the amount - out from the price instead._ - -#### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| _dataBundleDetail | struct DataBundleDetails | Data bundle being bought. Its settlement field is overwritten here. | -| _priceWei | uint256 | ETH actually being sent to the vault | -| _paymentReference | bytes32 | The offchain order id. Spent once, so a retry of a call that already landed cannot charge the user twice. | - -#### Return Values - -| Name | Type | Description | -| ---- | ---- | ----------- | -| [0] | bool | True if the transaction is successful | - ### buyDataBundleWithToken ```solidity diff --git a/snapshots/DeviceWallet.Operations.json b/snapshots/DeviceWallet.Operations.json index 14781984..0b6f43ec 100644 --- a/snapshots/DeviceWallet.Operations.json +++ b/snapshots/DeviceWallet.Operations.json @@ -1,11 +1,10 @@ { "addDeposit: 1 ether": "35060", - "deployESIMWallet: second wallet on the device": "358366", + "deployESIMWallet: second wallet on the device": "358296", "execute: ETH transfer to an EOA": "36317", "executeBatch: 3 ETH transfers": "79993", - "pullETH: 1 ether to the eSIM wallet": "13418", - "pullToken: first pull, the wallet holds none of it": "30862", - "pullToken: the wallet already holds some": "8962", + "pullToken: first pull, the wallet holds none of it": "30838", + "pullToken: the wallet already holds some": "8938", "toggleAccessToFunds: grant": "23507", "toggleAccessToFunds: revoke": "3607" } \ No newline at end of file diff --git a/snapshots/DeviceWalletFactory.Operations.json b/snapshots/DeviceWalletFactory.Operations.json index d7ea6c3f..17c6a617 100644 --- a/snapshots/DeviceWalletFactory.Operations.json +++ b/snapshots/DeviceWalletFactory.Operations.json @@ -1,8 +1,8 @@ { "createAccount: first wallet, cold factory storage": "326339", "createAccount: second wallet, warm factory storage": "308839", - "deployDeviceWalletForUsers: batch of 1": "881946", - "deployDeviceWalletForUsers: batch of 1, funded": "889763", - "deployDeviceWalletForUsers: batch of 5": "4148102", + "deployDeviceWalletForUsers: batch of 1": "881880", + "deployDeviceWalletForUsers: batch of 1, funded": "889697", + "deployDeviceWalletForUsers: batch of 5": "4147772", "postCreateAccount: registering a wallet": "173975" } \ No newline at end of file diff --git a/snapshots/ESIMWallet.Operations.json b/snapshots/ESIMWallet.Operations.json index 51843b51..ae5c68ee 100644 --- a/snapshots/ESIMWallet.Operations.json +++ b/snapshots/ESIMWallet.Operations.json @@ -1,14 +1,12 @@ { - "acceptOwnershipTransfer": "3551", - "buyDataBundle: pulls from the device wallet": "108255", - "buyDataBundle: wallet already holds the price": "94249", - "buyDataBundleWithToken: pulls from the device wallet": "156926", - "buyDataBundleWithToken: wallet already holds the price": "127357", + "acceptOwnershipTransfer": "3529", + "buyDataBundleWithToken: pulls from the device wallet": "156648", + "buyDataBundleWithToken: wallet already holds the price": "127162", "deployESIMWallet: through the factory": "289547", - "populateHistory: 10 entries": "489385", - "requestTransferOwnership": "64378", - "sendTokenToDeviceWallet: the whole balance": "28723", + "populateHistory: 10 entries": "489333", + "requestTransferOwnership": "64238", + "sendTokenToDeviceWallet: the whole balance": "28701", "setESIMUniqueIdentifier: first time": "27249", - "setPriceCapUSDCents: back to the registry ceiling": "2565", - "setPriceCapUSDCents: set": "22465" + "setPriceCapUSDCents: back to the registry ceiling": "2543", + "setPriceCapUSDCents: set": "22443" } \ No newline at end of file diff --git a/snapshots/LazyWalletRegistry.Operations.json b/snapshots/LazyWalletRegistry.Operations.json index 2063f540..4391d0bc 100644 --- a/snapshots/LazyWalletRegistry.Operations.json +++ b/snapshots/LazyWalletRegistry.Operations.json @@ -1,14 +1,14 @@ { "batchPopulateHistory: 1 device, 5 eSIMs": "668758", "batchPopulateHistory: 5 devices, 22 eSIMs": "2596984", - "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1013412", - "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5005193", - "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9491843", - "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2763953", - "deployMoreESIMWalletsForLazyDevice: batch of 1": "462951", - "deployMoreESIMWalletsForLazyDevice: batch of 18": "8067103", - "deployMoreESIMWalletsForLazyDevice: batch of 5": "2250564", - "setHistoryForLazyWallet: 1 entry": "107253", - "setHistoryForLazyWallet: 10 entries": "494027", - "setHistoryForLazyWallet: 39 entries": "1881728" + "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1013302", + "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5004093", + "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9489643", + "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2763403", + "deployMoreESIMWalletsForLazyDevice: batch of 1": "462841", + "deployMoreESIMWalletsForLazyDevice: batch of 18": "8065123", + "deployMoreESIMWalletsForLazyDevice: batch of 5": "2250014", + "setHistoryForLazyWallet: 1 entry": "107228", + "setHistoryForLazyWallet: 10 entries": "493975", + "setHistoryForLazyWallet: 39 entries": "1881589" } \ No newline at end of file diff --git a/snapshots/Registry.Operations.json b/snapshots/Registry.Operations.json index 4b682019..7d48433c 100644 --- a/snapshots/Registry.Operations.json +++ b/snapshots/Registry.Operations.json @@ -1,13 +1,13 @@ { "acceptAdminUpdate": "4964", - "assignESIMIdentifier: first time": "64852", + "assignESIMIdentifier: first time": "64808", "pause": "13963", - "recordSettledPurchase: first for the wallet": "124847", - "recordSettledPurchase: second for the same wallet": "87947", + "recordSettledPurchase: first for the wallet": "124825", + "recordSettledPurchase: second for the same wallet": "87925", "requestAdminUpdate": "10494", "setDefaultPriceCapUSDCents": "13346", - "toggleESIMWalletStandbyStatus: off standby": "5387", - "toggleESIMWalletStandbyStatus: onto standby": "25287", + "toggleESIMWalletStandbyStatus: off standby": "5365", + "toggleESIMWalletStandbyStatus: onto standby": "25265", "unpause": "4102", "updateDeviceWalletInfo: first device, cold storage": "122096", "updateDeviceWalletInfo: second device, warm storage": "115596", From 6fecc16cbd776d5b7667047b8d41115d9919d26d Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 14:51:14 +0530 Subject: [PATCH 48/75] Bring the README up to date with the token path --- README.md | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0480f3c3..eb936955 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ upgrade one at a time. Every wallet is a beacon proxy, so one beacon call moves | `RegistryHelper` | The registry's storage and lazy deployment half | Inherited by `Registry` | | `LazyWalletRegistry` | Holds data bundle history for users paying in fiat, keyed by device and eSIM identifier strings, until they ask for a wallet | UUPS proxy | | `DeviceWalletFactory` | Deploys device wallets at deterministic CREATE2 addresses and owns their beacon | UUPS proxy | -| `DeviceWallet` | One per phone. Holds ETH, owns the eSIM wallets on that device, verifies the passkey | Beacon proxy | +| `DeviceWallet` | One per phone. Holds ETH and tokens, owns the eSIM wallets on that device, verifies the passkey | Beacon proxy | | `ESIMWalletFactory` | Deploys eSIM wallets and owns their beacon | UUPS proxy | -| `ESIMWallet` | One per eSIM. Buys data bundles, keeps purchase history, pulls ETH and tokens from its device wallet | Beacon proxy | +| `ESIMWallet` | One per eSIM. Buys data bundles, keeps purchase history, pulls tokens from its device wallet | Beacon proxy | | `PaymentAdapter` | The currencies the protocol accepts, the one conversion from USD cents into a token amount, and the payment references each spendable once. Moves tokens to the vault | UUPS proxy | | `Account4337` | The ERC-4337 `IAccount` and `IERC1271` base that `DeviceWallet` builds on | Inherited by `DeviceWallet` | | `WebAuthn` | Verifies WebAuthn authentication assertions. Tries the RIP-7212 precompile first and falls back to FreshCryptoLib | Library | @@ -53,18 +53,18 @@ That parity depends on `bytecode_hash = "none"` in `foundry.toml` and `metadata. ## Testing -573 tests across 57 suites, about seven minutes for a full run. +804 tests across 73 suites, about eight minutes for a full run. | Suite | Files | What it covers | |---|---|---| -| `test/foundry/unit-testing/` | 32 | Per-contract behaviour, access control, revert paths, storage layout and initialiser locks | -| `test/foundry/fuzz-testing/` | 8 | Identifier and array shapes, ETH amounts, signature shapes, a P256 differential against the precompile | -| `test/foundry/invariant-testing/` | 8 | Registry consistency, wallet ownership, ETH accounting, purchase history, timelock behaviour | -| `test/foundry/gas/` | 7 | Per-operation gas with every input pinned, offchain signatures included | +| `test/foundry/unit-testing/` | 44 | Per-contract behaviour, access control, revert paths, storage layout and initialiser locks | +| `test/foundry/fuzz-testing/` | 10 | Identifier and array shapes, price ceilings, token settlement, signature shapes, a P256 differential against the precompile | +| `test/foundry/invariant-testing/` | 9 | Registry consistency, wallet ownership, payment accounting, purchase history, timelock behaviour | +| `test/foundry/gas/` | 8 | Per-operation gas with every input pinned, offchain signatures included | | `test/foundry/fork/` | 1 | A user operation through the deployed EntryPoint on both chains | -Branch coverage is 94.27%, or 181 of 192 arms, with `WebAuthn` at 80% the lowest and -`RegistryHelper`, `Account4337` and `ProtocolAdmin` at 100%. +Branch coverage is 92.58%, or 237 of 256 arms, with `WebAuthn` at 80% the lowest and +`Account4337`, `ProtocolAdmin`, `DeviceWallet` and `PaymentAdapter` at 100%. Measure it with `scripts/checks/branch-coverage.py` rather than reading forge's own percentage: `forge coverage --ir-minimum` does not count `require(cond, "string")` as a branch, so those sites report zero hits on both arms however often they run. The contracts use custom errors throughout, so nothing is currently excluded, but the raw number stops meaning anything the moment a `require` @@ -92,16 +92,17 @@ Two gas baselines, measuring different things. `.gas-snapshot` is the whole-test **Audit.** Reviewed by CD Security in March 2025. The report is in [audits/2025-03-CDSecurity.pdf](./audits/2025-03-CDSecurity.pdf). -**Formal verification.** Seven Certora specs, 60 rules. +**Formal verification.** Eight Certora specs, 83 rules. | Spec | Rules | Subject | |---|---|---| -| `Registry.spec` | 11 | Wallet registration and association records | -| `ProtocolAdmin.spec` | 10 | The delay nothing gets around, and the two things a guardian can say | -| `ESIMWallet.spec` | 9 | Ownership state machine, price ceiling, deploying factory | -| `DeviceWalletFactory.spec` | 9 | Deterministic deployment and beacon control | -| `ESIMWalletFactory.spec` | 9 | Registry wiring, beacon control, the record of what it deployed | -| `DeviceWallet.spec` | 8 | Owner key, eSIM wallet set, ETH access flags | +| `Registry.spec` | 18 | Wallet registration and association records | +| `PaymentAdapter.spec` | 15 | The currency table, the payment references, and what a settlement moves | +| `ProtocolAdmin.spec` | 11 | The delay nothing gets around, and the two things a guardian can say | +| `ESIMWallet.spec` | 10 | Ownership state machine, price ceiling, deploying factory | +| `ESIMWalletFactory.spec` | 10 | Registry wiring, beacon control, the record of what it deployed | +| `DeviceWalletFactory.spec` | 8 | Deterministic deployment and beacon control | +| `DeviceWallet.spec` | 7 | Owner key, eSIM wallet set, funds access flags | | `RegistryCrossContract.spec` | 4 | `Registry`, `DeviceWallet` and `ESIMWallet` agreeing on who holds an eSIM wallet | Three caveats attach to every proof. Loops unroll three times, so a result covers batches of at most three rather than all batches. Hashing of unbounded arguments is assumed within 224 bytes, raised to 1600 in `ESIMWalletFactory.spec` so its CREATE2 address prediction stays reachable. External calls are summarised one signature at a time. @@ -171,9 +172,12 @@ on one chain and a plain EOA on the other. never leaves it. 2. **Deploy the wallets.** For a new device, the app asks the registry for a device wallet and one eSIM wallet, linked at deployment. -3. **Pick and buy a data bundle.** Paying in crypto deploys both wallets immediately, in ETH or in - an ERC-20 the payment adapter has been given a price for. Paying in fiat records the purchase in - the lazy wallet registry, and the wallets are deployed later if the user asks for them. +3. **Pick and buy a data bundle.** Paying in crypto deploys both wallets immediately, in an ERC-20 + the payment adapter has been given a price for. Paying in fiat records the purchase in the lazy + wallet registry, and the wallets are deployed later if the user asks for them. + + Paying in ETH is not currently supported. The adapter works the amount out from the price, and + nothing onchain prices ETH, so it comes back when the adapter can swap into the settlement token. 4. **Provision the eSIM.** The server generates the eSIM identifier, writes it into the eSIM wallet through the device wallet, and returns a QR code for activation. 5. **Use the device wallet.** It holds ETH and ERC-20 tokens and can be used as an ordinary wallet. From 6ff7168e3cac4a797f1ad145b7b4a473fdb69568 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 15:25:29 +0530 Subject: [PATCH 49/75] Let a run point the scripts at a different record The path was a constant, so nothing could read or write anywhere else. --- .gitignore | 4 ++++ scripts/deploy/Deploy.s.sol | 4 ++-- scripts/deploy/config/DeploymentRecord.sol | 28 +++++++++++++++------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 00f03fff..c5decb33 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ forge-cache/ # Written by every `forge script --broadcast` run. The deployment record kept on purpose is # deployments/address.json, which the scripts write and later scripts read. broadcast/ + +# Scratch records the deploy script tests write. They live here because fs_permissions grants the +# scripts write access to this directory and nowhere else. +deployments/.test-*.json lcov.info .DS_Store diff --git a/scripts/deploy/Deploy.s.sol b/scripts/deploy/Deploy.s.sol index a8f54f24..d1fcc03e 100644 --- a/scripts/deploy/Deploy.s.sol +++ b/scripts/deploy/Deploy.s.sol @@ -242,8 +242,8 @@ contract Deploy is Script { record = vm.serializeString("record", "contracts", _contractsSection(deployed)); record = vm.serializeString("record", "status", _statusSection()); - vm.writeJson(record, DeploymentRecord.PATH, string.concat(".", network)); - console.log("Record written to", DeploymentRecord.PATH, "under", network); + vm.writeJson(record, DeploymentRecord.recordPath(), string.concat(".", network)); + console.log("Record written to", DeploymentRecord.recordPath(), "under", network); } /// @notice Everything needed to rebuild this bytecode later, collected offchain diff --git a/scripts/deploy/config/DeploymentRecord.sol b/scripts/deploy/config/DeploymentRecord.sol index af079d7f..bb56f57c 100644 --- a/scripts/deploy/config/DeploymentRecord.sol +++ b/scripts/deploy/config/DeploymentRecord.sol @@ -19,10 +19,20 @@ import {DeployConfig} from "./DeployConfig.sol"; /// address is indistinguishable from a deployed one to every reader of this file. library DeploymentRecord { - string internal constant PATH = "deployments/address.json"; + /// @notice The record a run reads and writes unless it is pointed somewhere else + string internal constant DEFAULT_PATH = "deployments/address.json"; Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + /// @notice File this run reads and writes + /// @dev A function rather than a constant so a test can point a script at a scratch file. The + /// real record is the only thing that remembers a live deployment's addresses, so a test + /// writing over it loses them. `fs_permissions` keeps any value inside `deployments/`. + /// @return location Path to the record for this run + function recordPath() internal view returns (string memory location) { + location = vm.envOr("DEPLOYMENT_RECORD_PATH", DEFAULT_PATH); + } + /// @notice The deployment record has no entry for this contract on this chain error NotRecorded(string network, string key); @@ -36,7 +46,7 @@ library DeploymentRecord { /// @return target Address recorded for this contract on the current chain function readAddress(string memory key) internal view returns (address target) { string memory network = DeployConfig.recordKey(); - string memory json = vm.readFile(PATH); + string memory json = vm.readFile(recordPath()); string memory pointer = string.concat(".", network, ".contracts.", key, ".address"); if(!vm.keyExistsJson(json, pointer)) revert NotRecorded(network, key); @@ -52,7 +62,7 @@ library DeploymentRecord { /// @return value Address at that path function readRaw(string memory path) internal view returns (address value) { string memory network = DeployConfig.recordKey(); - string memory json = vm.readFile(PATH); + string memory json = vm.readFile(recordPath()); string memory pointer = string.concat(".", network, ".", path); if(!vm.keyExistsJson(json, pointer)) revert NotRecorded(network, path); @@ -65,7 +75,7 @@ library DeploymentRecord { /// @return value Number at that path function readUint(string memory path) internal view returns (uint256 value) { string memory network = DeployConfig.recordKey(); - string memory json = vm.readFile(PATH); + string memory json = vm.readFile(recordPath()); string memory pointer = string.concat(".", network, ".", path); if(!vm.keyExistsJson(json, pointer)) revert NotRecorded(network, path); @@ -78,7 +88,7 @@ library DeploymentRecord { /// @return value Bytes at that path function readBytes(string memory path) internal view returns (bytes memory value) { string memory network = DeployConfig.recordKey(); - string memory json = vm.readFile(PATH); + string memory json = vm.readFile(recordPath()); string memory pointer = string.concat(".", network, ".", path); if(!vm.keyExistsJson(json, pointer)) revert NotRecorded(network, path); @@ -91,7 +101,7 @@ library DeploymentRecord { /// @return value Word at that path function readBytes32(string memory path) internal view returns (bytes32 value) { string memory network = DeployConfig.recordKey(); - string memory json = vm.readFile(PATH); + string memory json = vm.readFile(recordPath()); string memory pointer = string.concat(".", network, ".", path); if(!vm.keyExistsJson(json, pointer)) revert NotRecorded(network, path); @@ -109,7 +119,7 @@ library DeploymentRecord { // serialized object. Serializing here would write `{"configured":true}` where the bool goes. vm.writeJson( done ? "true" : "false", - PATH, + recordPath(), string.concat(".", DeployConfig.recordKey(), ".status.", key) ); } @@ -122,14 +132,14 @@ library DeploymentRecord { /// @param path Dotted path below the network key /// @param json Serialized object to write there function writeObject(string memory path, string memory json) internal { - vm.writeJson(json, PATH, string.concat(".", DeployConfig.recordKey(), ".", path)); + vm.writeJson(json, recordPath(), string.concat(".", DeployConfig.recordKey(), ".", path)); } /// @notice True when the record already holds an entry at this path for the current chain /// @param path Dotted path below the network key /// @return present Whether the path resolves function has(string memory path) internal view returns (bool present) { - string memory json = vm.readFile(PATH); + string memory json = vm.readFile(recordPath()); present = vm.keyExistsJson( json, string.concat(".", DeployConfig.recordKey(), ".", path) From c4deeae8fbc9664e6c794fe9e647c538f0798821 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 15:25:35 +0530 Subject: [PATCH 50/75] Split the deploy config checks from reading the environment Environment variables outlive a test, so the checks needed a way in that does not rewrite them. Covers all eight. --- scripts/deploy/config/DeployConfig.sol | 53 +++-- test/foundry/deploy/DeployConfig.t.sol | 288 ++++++++++++++++++++++++ test/foundry/deploy/base/ScriptBase.sol | 119 ++++++++++ 3 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 test/foundry/deploy/DeployConfig.t.sol create mode 100644 test/foundry/deploy/base/ScriptBase.sol diff --git a/scripts/deploy/config/DeployConfig.sol b/scripts/deploy/config/DeployConfig.sol index e4929b9c..77a017b8 100644 --- a/scripts/deploy/config/DeployConfig.sol +++ b/scripts/deploy/config/DeployConfig.sol @@ -77,11 +77,11 @@ library DeployConfig { /// @notice An account appears in two role lists the timelock requires to stay separate error RolesMustNotOverlap(address account); - /// @notice Reads and checks every deployment parameter from the environment - /// @dev Every failure here is one a deployment can recover from, so they all happen before - /// anything is broadcast. The role overlap checks duplicate what `ProtocolAdmin`'s - /// constructor enforces, deliberately: reverting inside a broadcast leaves the earlier - /// contracts of that run deployed and orphaned. + /// @notice Reads every deployment parameter from the environment and checks it + /// @dev Reading and checking are separate so the checks can be reached with a configuration + /// built by hand. Environment variables are process wide and forge does not restore them + /// between tests, so a test that reached these by rewriting the environment would change + /// what every later test in the same file reads. /// @return config Resolved parameters for this run function load() internal view returns (Config memory config) { config.chainId = block.chainid; @@ -90,34 +90,45 @@ library DeployConfig { config.deployer = vm.addr(config.deployerPrivateKey); config.eSIMWalletAdmin = vm.envAddress("ESIM_WALLET_ADMIN"); - if(config.eSIMWalletAdmin == address(0)) revert MissingAddress("ESIM_WALLET_ADMIN"); - config.vault = vm.envAddress("VAULT"); - if(config.vault == address(0)) revert MissingAddress("VAULT"); - - // Required, and never zero: `Registry.initialize` refuses a zero cap, since zero would - // read as "no ceiling" for every wallet that never sets its own. config.priceCapUSDCents = uint64(vm.envUint("PRICE_CAP_USD_CENTS")); - if(config.priceCapUSDCents == 0) revert MissingValue("PRICE_CAP_USD_CENTS"); - - // Nothing reads this until swaps exist, but the adapter takes it at initialisation so - // adding them later needs no migration transaction. config.settlementToken = vm.envAddress("SETTLEMENT_TOKEN"); - if(config.settlementToken == address(0)) revert MissingAddress("SETTLEMENT_TOKEN"); - config.entryPoint = IEntryPoint(ENTRY_POINT_V08); - if(ENTRY_POINT_V08.code.length == 0) { - revert EntryPointNotDeployed(ENTRY_POINT_V08, block.chainid); - } config.proposers = vm.envAddress("TIMELOCK_PROPOSERS", ","); - if(config.proposers.length == 0) revert EmptyList("TIMELOCK_PROPOSERS"); // Empty is allowed. The base constructor already gives every proposer the cancel power, // so this list is for accounts that cancel and do nothing else. config.cancellers = vm.envOr("TIMELOCK_CANCELLERS", ",", new address[](0)); config.guardians = vm.envAddress("TIMELOCK_GUARDIANS", ","); + + validate(config); + } + + /// @notice Refuses a configuration the deployment could not recover from + /// @dev Every failure here is one a deployment can recover from, so they all happen before + /// anything is broadcast. The role overlap checks duplicate what `ProtocolAdmin`'s + /// constructor enforces, deliberately: reverting inside a broadcast leaves the earlier + /// contracts of that run deployed and orphaned. + /// @param config Parameters to check + function validate(Config memory config) internal view { + if(config.eSIMWalletAdmin == address(0)) revert MissingAddress("ESIM_WALLET_ADMIN"); + if(config.vault == address(0)) revert MissingAddress("VAULT"); + + // Never zero: `Registry.initialize` refuses a zero cap, since zero would read as "no + // ceiling" for every wallet that never sets its own. + if(config.priceCapUSDCents == 0) revert MissingValue("PRICE_CAP_USD_CENTS"); + + // Nothing reads this until swaps exist, but the adapter takes it at initialisation so + // adding them later needs no migration transaction. + if(config.settlementToken == address(0)) revert MissingAddress("SETTLEMENT_TOKEN"); + + if(address(config.entryPoint).code.length == 0) { + revert EntryPointNotDeployed(address(config.entryPoint), block.chainid); + } + + if(config.proposers.length == 0) revert EmptyList("TIMELOCK_PROPOSERS"); if(config.guardians.length == 0) revert EmptyList("TIMELOCK_GUARDIANS"); _requireDisjoint(config.proposers, config.cancellers); diff --git a/test/foundry/deploy/DeployConfig.t.sol b/test/foundry/deploy/DeployConfig.t.sol new file mode 100644 index 00000000..d123f0dd --- /dev/null +++ b/test/foundry/deploy/DeployConfig.t.sol @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +// Contracts +import {ScriptBase} from "./base/ScriptBase.sol"; + +// Config +import {DeployConfig} from "scripts/deploy/config/DeployConfig.sol"; + +/// @notice Calls the two library functions from outside, so a revert can be caught +/// @dev Both are internal, which a test calling them directly would inline, and `vm.expectRevert` +/// only sees a revert that crosses a call boundary. +contract ConfigLoader { + function load() external view returns (DeployConfig.Config memory config) { + config = DeployConfig.load(); + } + + function validate(DeployConfig.Config memory config) external view { + DeployConfig.validate(config); + } +} + +/// @notice The environment a deployment reads, and every reason it refuses to start +/// @dev This is the one part of a deployment that costs nothing to get wrong: the checks run before +/// any broadcast, so everything they catch is recoverable. That is why it matters that they +/// work. A check that has never run is not known to run, and the first time these fire is +/// during a real deployment. +/// +/// Nothing here rewrites an environment variable. Forge runs `setUp` once per contract and +/// environment variables belong to the process rather than to the EVM, so a test that set one +/// would change what every later test in this file reads, whichever order they run in. The +/// checks are reached with a configuration built by hand instead. +contract DeployConfigTest is ScriptBase { + + ConfigLoader private loader; + + function setUp() public { + _setUpScriptEnvironment("deploy-config"); + loader = new ConfigLoader(); + } + + /// @notice A configuration that passes every check, for a test to break one field of + function _validConfig() private view returns (DeployConfig.Config memory config) { + config = loader.load(); + } + + function _list(address entry) private pure returns (address[] memory list) { + list = new address[](1); + list[0] = entry; + } + + // --------------------------------------------------------------------------------------------- + // Reading the environment + // --------------------------------------------------------------------------------------------- + + function test_load_resolvesEveryParameter() public view { + DeployConfig.Config memory config = loader.load(); + + assertEq(config.chainId, block.chainid, "chain id"); + assertEq(config.deployer, deployer, "deployer"); + assertEq(config.deployerPrivateKey, DEPLOYER_KEY, "deployer key"); + assertEq(address(config.entryPoint), DeployConfig.ENTRY_POINT_V08, "entry point"); + assertEq(config.eSIMWalletAdmin, eSIMWalletAdmin, "esim admin"); + assertEq(config.vault, vault, "vault"); + assertEq(config.priceCapUSDCents, PRICE_CAP_CENTS, "price cap"); + assertEq(config.settlementToken, address(settlementToken), "settlement token"); + } + + function test_load_resolvesTheThreeRoleLists() public view { + DeployConfig.Config memory config = loader.load(); + + assertEq(config.proposers.length, 1, "one proposer"); + assertEq(config.proposers[0], proposer, "proposer"); + assertEq(config.cancellers.length, 1, "one canceller"); + assertEq(config.cancellers[0], canceller, "canceller"); + assertEq(config.guardians.length, 1, "one guardian"); + assertEq(config.guardians[0], guardian, "guardian"); + } + + /// @dev The deployer is derived from the key rather than read as its own variable, so the two + /// cannot disagree about who is about to sign. + function test_load_derivesTheDeployerFromTheKey() public view { + assertEq(loader.load().deployer, vm.addr(DEPLOYER_KEY), "deployer from key"); + } + + // --------------------------------------------------------------------------------------------- + // Addresses that have to be there + // --------------------------------------------------------------------------------------------- + + function test_validate_revertsWhenTheAdminIsTheZeroAddress() public { + DeployConfig.Config memory config = _validConfig(); + config.eSIMWalletAdmin = address(0); + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.MissingAddress.selector, "ESIM_WALLET_ADMIN") + ); + loader.validate(config); + } + + function test_validate_revertsWhenTheVaultIsTheZeroAddress() public { + DeployConfig.Config memory config = _validConfig(); + config.vault = address(0); + + vm.expectRevert(abi.encodeWithSelector(DeployConfig.MissingAddress.selector, "VAULT")); + loader.validate(config); + } + + function test_validate_revertsWhenTheSettlementTokenIsTheZeroAddress() public { + DeployConfig.Config memory config = _validConfig(); + config.settlementToken = address(0); + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.MissingAddress.selector, "SETTLEMENT_TOKEN") + ); + loader.validate(config); + } + + // --------------------------------------------------------------------------------------------- + // The price ceiling + // --------------------------------------------------------------------------------------------- + + /// @dev Zero is not a low ceiling, it is no ceiling. An eSIM wallet that never sets its own + /// falls back to this one, and `_requirePriceWithinCap` reads zero as "follow the + /// registry" the whole way down. + function test_validate_revertsWhenThePriceCapIsZero() public { + DeployConfig.Config memory config = _validConfig(); + config.priceCapUSDCents = 0; + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.MissingValue.selector, "PRICE_CAP_USD_CENTS") + ); + loader.validate(config); + } + + function test_validate_acceptsTheLargestCapThatFits() public view { + DeployConfig.Config memory config = _validConfig(); + config.priceCapUSDCents = type(uint64).max; + + loader.validate(config); + } + + // --------------------------------------------------------------------------------------------- + // The EntryPoint + // --------------------------------------------------------------------------------------------- + + /// @dev The address is the same on every chain, which is what makes it a constant, and is also + /// why a chain that does not carry it has to be caught here. The wallet implementations + /// take it as an immutable, so a wrong one is not reachable by any admin path afterwards. + function test_validate_revertsWhenTheEntryPointIsNotOnThisChain() public { + DeployConfig.Config memory config = _validConfig(); + vm.etch(DeployConfig.ENTRY_POINT_V08, ""); + + vm.expectRevert( + abi.encodeWithSelector( + DeployConfig.EntryPointNotDeployed.selector, + DeployConfig.ENTRY_POINT_V08, + block.chainid + ) + ); + loader.validate(config); + } + + // --------------------------------------------------------------------------------------------- + // Lists that cannot be empty + // --------------------------------------------------------------------------------------------- + + /// @dev With no proposer nothing can ever be scheduled, so every owner gated call on all five + /// singletons is unreachable for good once the handover has run. + function test_validate_revertsWithoutAProposer() public { + DeployConfig.Config memory config = _validConfig(); + config.proposers = new address[](0); + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.EmptyList.selector, "TIMELOCK_PROPOSERS") + ); + loader.validate(config); + } + + function test_validate_revertsWithoutAGuardian() public { + DeployConfig.Config memory config = _validConfig(); + config.guardians = new address[](0); + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.EmptyList.selector, "TIMELOCK_GUARDIANS") + ); + loader.validate(config); + } + + /// @dev Cancellers are the one list allowed to be empty. Every proposer can already cancel, so + /// this list is for accounts that cancel and do nothing else. + function test_validate_acceptsAnEmptyCancellerList() public view { + DeployConfig.Config memory config = _validConfig(); + config.cancellers = new address[](0); + + loader.validate(config); + } + + // --------------------------------------------------------------------------------------------- + // Roles that have to stay apart + // --------------------------------------------------------------------------------------------- + + function test_validate_revertsWhenAProposerAlsoCancels() public { + DeployConfig.Config memory config = _validConfig(); + config.cancellers = _list(proposer); + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.RolesMustNotOverlap.selector, proposer) + ); + loader.validate(config); + } + + function test_validate_revertsWhenAProposerIsAlsoAGuardian() public { + DeployConfig.Config memory config = _validConfig(); + config.guardians = _list(proposer); + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.RolesMustNotOverlap.selector, proposer) + ); + loader.validate(config); + } + + function test_validate_revertsWhenACancellerIsAlsoAGuardian() public { + DeployConfig.Config memory config = _validConfig(); + config.guardians = _list(canceller); + + vm.expectRevert( + abi.encodeWithSelector(DeployConfig.RolesMustNotOverlap.selector, canceller) + ); + loader.validate(config); + } + + /// @dev The overlap is found wherever it sits, not only when both lists start with it. + function test_validate_findsAnOverlapPastTheFirstEntry() public { + address shared = makeAddr("sharedAccount"); + + DeployConfig.Config memory config = _validConfig(); + config.proposers = new address[](2); + config.proposers[0] = proposer; + config.proposers[1] = shared; + config.guardians = new address[](2); + config.guardians[0] = guardian; + config.guardians[1] = shared; + + vm.expectRevert(abi.encodeWithSelector(DeployConfig.RolesMustNotOverlap.selector, shared)); + loader.validate(config); + } + + // --------------------------------------------------------------------------------------------- + // The record key + // --------------------------------------------------------------------------------------------- + + /// @dev The chain id is in the key rather than only the name, because a name is a label somebody + /// chose and two chains sharing one is how a mainnet record overwrites a testnet one that + /// is still in use. + function test_recordKey_namesTheChainTheIdAndTheEntryPoint() public { + vm.chainId(84532); + assertEq(DeployConfig.recordKey(), "base-sepolia-84532-entrypoint-v8", "base sepolia"); + + vm.chainId(11155420); + assertEq(DeployConfig.recordKey(), "optimism-sepolia-11155420-entrypoint-v8", "op sepolia"); + } + + function test_chainLabel_namesEveryChainTheProtocolTargets() public { + vm.chainId(1); + assertEq(DeployConfig.chainLabel(), "mainnet", "mainnet"); + + vm.chainId(10); + assertEq(DeployConfig.chainLabel(), "optimism", "optimism"); + + vm.chainId(8453); + assertEq(DeployConfig.chainLabel(), "base", "base"); + + vm.chainId(11155111); + assertEq(DeployConfig.chainLabel(), "sepolia", "sepolia"); + + vm.chainId(31337); + assertEq(DeployConfig.chainLabel(), "anvil", "anvil"); + } + + /// @dev An unrecognised chain still gets a key naming its id, so it cannot collide with a chain + /// the table does know. + function test_recordKey_fallsBackToAKeyThatStillNamesTheChain() public { + vm.chainId(4242); + + assertEq(DeployConfig.chainLabel(), "chain", "fallback label"); + assertEq(DeployConfig.recordKey(), "chain-4242-entrypoint-v8", "fallback key"); + } +} diff --git a/test/foundry/deploy/base/ScriptBase.sol b/test/foundry/deploy/base/ScriptBase.sol new file mode 100644 index 00000000..91c04b0c --- /dev/null +++ b/test/foundry/deploy/base/ScriptBase.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +// Contracts +import {Test} from "forge-std/Test.sol"; +import {MockEntryPoint} from "test/utils/mocks/MockEntryPoint.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; + +// Config +import {DeployConfig} from "scripts/deploy/config/DeployConfig.sol"; + +/// @notice Shared setup for the tests that run the deployment scripts +/// @dev The scripts read every parameter from the environment and their record from a file, so a +/// test standing one up has to provide both. `rehearse.sh` covers the same scripts against a +/// fork and is the better check of the happy path; what these tests reach that it cannot is +/// the failure and resume branches, which need a broken environment on purpose. +/// +/// The environment is process wide, and so is the scratch record path built on top of it. +/// Each test contract therefore writes to a file named after itself, and `setUp` re-seeds it +/// so one test never reads what the previous one left. +abstract contract ScriptBase is Test { + + /// @notice Anvil's first account, the key `rehearse.sh` deploys with + /// @dev Published in Foundry's own documentation, so it controls nothing anywhere. A test must + /// never be able to reach for a key that does. + uint256 internal constant DEPLOYER_KEY = + 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + + /// @notice Ceiling on one data bundle, in USD cents + uint64 internal constant PRICE_CAP_CENTS = 100_000; + + /// @notice Decimals the stand-in settlement token reports + /// @dev Six, matching USDC. `Configure` reads this off the token rather than assuming it, so a + /// token answering something else is what that path has to survive. + uint8 internal constant SETTLEMENT_DECIMALS = 6; + + address internal deployer; + address internal eSIMWalletAdmin; + address internal vault; + address internal proposer; + address internal canceller; + address internal guardian; + + /// @notice Stand-in for USDC, deployed so `Configure` has a real `decimals()` to read + MockERC20 internal settlementToken; + + /// @notice Record this contract's tests read and write + string internal scratchRecord; + + /// @notice Puts a complete, valid environment in place and seeds an empty record + /// @dev Call from `setUp` before anything runs a script. The name has to be unique per test + /// contract, since forge runs contracts in parallel against one filesystem. + /// @param _name Short name for this contract's scratch record + function _setUpScriptEnvironment(string memory _name) internal { + deployer = vm.addr(DEPLOYER_KEY); + eSIMWalletAdmin = makeAddr("eSIMWalletAdmin"); + vault = makeAddr("vault"); + + // The three timelock lists have to stay disjoint. `ProtocolAdmin`'s constructor rejects an + // overlap and `DeployConfig` checks the same thing before anything is broadcast. + proposer = makeAddr("proposer"); + canceller = makeAddr("canceller"); + guardian = makeAddr("guardian"); + + settlementToken = new MockERC20("USD Coin", "USDC", SETTLEMENT_DECIMALS); + + // The scripts refuse a chain the EntryPoint is not on, which is every chain a test runs on + // until something is put there. + vm.etch(DeployConfig.ENTRY_POINT_V08, address(new MockEntryPoint()).code); + + _setEnvironment(); + _resetRecord(_name); + } + + /// @notice Writes every variable `DeployConfig.load` reads + function _setEnvironment() internal { + vm.setEnv("DEPLOYER_PRIVATE_KEY", vm.toString(DEPLOYER_KEY)); + vm.setEnv("ESIM_WALLET_ADMIN", vm.toString(eSIMWalletAdmin)); + vm.setEnv("VAULT", vm.toString(vault)); + vm.setEnv("PRICE_CAP_USD_CENTS", vm.toString(uint256(PRICE_CAP_CENTS))); + vm.setEnv("SETTLEMENT_TOKEN", vm.toString(address(settlementToken))); + vm.setEnv("TIMELOCK_PROPOSERS", vm.toString(proposer)); + vm.setEnv("TIMELOCK_CANCELLERS", vm.toString(canceller)); + vm.setEnv("TIMELOCK_GUARDIANS", vm.toString(guardian)); + } + + /// @notice Points the scripts at an empty record of this contract's own + /// @dev `fs_permissions` grants write access to `deployments` and nowhere else, so the scratch + /// file lives beside the real record rather than in a temporary directory. The leading dot + /// and the `test-` prefix are what `.gitignore` matches on. + /// @param _name Short name for this contract's scratch record + function _resetRecord(string memory _name) internal { + scratchRecord = string.concat("deployments/.test-", _name, ".json"); + + vm.writeFile(scratchRecord, "{}"); + vm.setEnv("DEPLOYMENT_RECORD_PATH", scratchRecord); + } + + /// @notice The key this chain's entry lives under, which is `anvil-31337-entrypoint-v8` in tests + function _recordKey() internal view returns (string memory key) { + key = DeployConfig.recordKey(); + } + + /// @notice Reads a value out of the scratch record + /// @param _path Dotted path below the network key + function _recorded(string memory _path) internal view returns (string memory value) { + value = vm.readFile(scratchRecord); + value = vm.parseJsonString(value, string.concat(".", _recordKey(), ".", _path)); + } + + /// @notice True when the scratch record holds something at this path + /// @param _path Dotted path below the network key + function _recordHas(string memory _path) internal view returns (bool present) { + present = vm.keyExistsJson( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".", _path) + ); + } +} From 926d39450232ec1548c251b9e6b03690dd1b4ece Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 15:44:33 +0530 Subject: [PATCH 51/75] Cover the deploy scripts and their failure branches 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. --- foundry.toml | 16 + .../deploy => scripts}/DeployConfig.t.sol | 2 +- test/scripts/DeployScripts.t.sol | 585 ++++++++++++++++++ .../deploy => scripts}/base/ScriptBase.sol | 52 +- 4 files changed, 640 insertions(+), 15 deletions(-) rename test/{foundry/deploy => scripts}/DeployConfig.t.sol (99%) create mode 100644 test/scripts/DeployScripts.t.sol rename test/{foundry/deploy => scripts}/base/ScriptBase.sol (67%) diff --git a/foundry.toml b/foundry.toml index 90998618..14a63c1b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -48,4 +48,20 @@ runs = 5000 depth = 100 fail_on_revert = false +# The deploy script tests, which have to run one at a time. +# FOUNDRY_PROFILE=scripts forge test --threads 1 +# +# They live outside `test/foundry` so an ordinary `forge test` does not pick them up. Two of them +# cannot run at once: the scripts read their parameters from the process environment and their +# record from one file, and forge runs every setUp before any test and then runs the tests in +# parallel. So a second test rewrites the record between the first one's write and its read. +# +# `--threads 1` on the command line is what actually serializes them. The setting below resolves, +# and `forge config` reports it, but the run is still parallel without the flag. Measured: with the +# flag, CPU time equals wall time and 50 tests pass five runs running; without it, CPU time is ten +# times wall and between 2 and 12 tests fail each run. +[profile.scripts] +test = 'test/scripts' +threads = 1 + # See more config options https://book.getfoundry.sh/reference/config.html diff --git a/test/foundry/deploy/DeployConfig.t.sol b/test/scripts/DeployConfig.t.sol similarity index 99% rename from test/foundry/deploy/DeployConfig.t.sol rename to test/scripts/DeployConfig.t.sol index d123f0dd..adc822e8 100644 --- a/test/foundry/deploy/DeployConfig.t.sol +++ b/test/scripts/DeployConfig.t.sol @@ -35,7 +35,7 @@ contract DeployConfigTest is ScriptBase { ConfigLoader private loader; function setUp() public { - _setUpScriptEnvironment("deploy-config"); + _setUpScriptEnvironment(); loader = new ConfigLoader(); } diff --git a/test/scripts/DeployScripts.t.sol b/test/scripts/DeployScripts.t.sol new file mode 100644 index 00000000..75de5240 --- /dev/null +++ b/test/scripts/DeployScripts.t.sol @@ -0,0 +1,585 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.36; + +// Contracts +import {ScriptBase} from "./base/ScriptBase.sol"; +import {Registry} from "contracts/Registry.sol"; +import {LazyWalletRegistry} from "contracts/LazyWalletRegistry.sol"; +import {DeviceWalletFactory} from "contracts/device-wallet/DeviceWalletFactory.sol"; +import {ESIMWalletFactory} from "contracts/esim-wallet/ESIMWalletFactory.sol"; +import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; +import {ProtocolAdmin} from "contracts/admin/ProtocolAdmin.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; + +// Scripts +import {Deploy} from "scripts/deploy/Deploy.s.sol"; +import {Configure} from "scripts/deploy/Configure.s.sol"; +import {TransferOwnership} from "scripts/deploy/TransferOwnership.s.sol"; +import {DeployConfig} from "scripts/deploy/config/DeployConfig.sol"; +import {DeploymentRecord} from "scripts/deploy/config/DeploymentRecord.sol"; + +/// @notice Reaches the record library from outside, so a revert can be caught +contract RecordReader { + function readAddress(string memory key) external view returns (address target) { + target = DeploymentRecord.readAddress(key); + } + + function readRaw(string memory path) external view returns (address value) { + value = DeploymentRecord.readRaw(path); + } + + function readUint(string memory path) external view returns (uint256 value) { + value = DeploymentRecord.readUint(path); + } +} + +/// @notice A contract with code that answers nothing, for the token checks +contract NotAToken { + fallback() external { revert(); } +} + +/// @notice The three deployment scripts, run against a scratch record +/// @dev `rehearse.sh` already runs these against a Base Sepolia fork and is the better check of the +/// happy path, but it needs an RPC key and a live anvil, so CI never runs it. What no fork +/// rehearsal reaches at all is the failure and resume branches: every one of them needs a +/// deployment that has already gone wrong in a particular way, which is not a state a +/// rehearsal can arrive at. Those are what most of this file is. +/// +/// One contract for all of it, and that is forced rather than chosen. The record path is an +/// environment variable, forge runs every `setUp` before any test, and the environment belongs +/// to the process, so two contracts cannot point at two different files. Sharing one file +/// across contracts that run at the same time would interleave their writes. +/// +/// For the same reason nothing here rewrites an environment variable. A broken configuration +/// is arrived at through EVM state instead, which forge does restore between tests. +contract DeployScriptsTest is ScriptBase { + + /// @notice Symbols `Configure` puts in the adapter's table + bytes32 private constant ASSET_USD = bytes32("USD"); + bytes32 private constant ASSET_USDC = bytes32("USDC"); + + /// @notice ERC-1967 implementation slot, read back rather than assumed + bytes32 private constant IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + RecordReader private reader; + + function setUp() public { + _setUpScriptEnvironment(); + reader = new RecordReader(); + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + /// @notice Empties the record and runs the deploy + /// @dev Every test starts here rather than in `setUp`, because `setUp` runs once for the whole + /// contract while the file it wrote outlives each test. + function _deploy() private { + _resetRecord(); + new Deploy().run(); + } + + function _configure() private { + new Configure().run(); + } + + function _recordedAddress(string memory key) private view returns (address target) { + target = vm.parseJsonAddress( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".contracts.", key, ".address") + ); + } + + function _registry() private view returns (Registry) { + return Registry(_recordedAddress("RegistryProxy")); + } + + function _adapter() private view returns (PaymentAdapter) { + return PaymentAdapter(_recordedAddress("PaymentAdapterProxy")); + } + + // --------------------------------------------------------------------------------------------- + // The record itself + // --------------------------------------------------------------------------------------------- + + function test_recordPath_followsTheOverride() public view { + assertEq(DeploymentRecord.recordPath(), scratchRecord, "override"); + assertEq(DeploymentRecord.DEFAULT_PATH, "deployments/address.json", "default"); + } + + function test_readAddress_returnsWhatTheDeployWrote() public { + _deploy(); + + assertEq( + DeploymentRecord.readAddress("RegistryProxy"), + address(_registry()), + "registry read back" + ); + } + + function test_readAddress_revertsForAKeyThisChainHasNoEntryFor() public { + _deploy(); + + vm.expectRevert( + abi.encodeWithSelector( + DeploymentRecord.NotRecorded.selector, + _recordKey(), + "NotAContractWeDeploy" + ) + ); + reader.readAddress("NotAContractWeDeploy"); + } + + /// @dev The code check is the point of `readAddress`. A key holding an address from another + /// chain parses exactly like one from this chain, and only the code tells them apart. + function test_readAddress_revertsWhenTheRecordedAddressHasNoCode() public { + _deploy(); + + address registry = address(_registry()); + vm.etch(registry, ""); + + vm.expectRevert( + abi.encodeWithSelector(DeploymentRecord.NoCodeAt.selector, "RegistryProxy", registry) + ); + reader.readAddress("RegistryProxy"); + } + + function test_readRaw_returnsAnAccountThatCarriesNoCode() public { + _deploy(); + + address recordedAdmin = DeploymentRecord.readRaw("admin.protocolAdmin"); + assertEq(recordedAdmin, _recordedAddress("ProtocolAdmin"), "protocol admin"); + } + + function test_readRaw_revertsForAPathTheRecordDoesNotHold() public { + _deploy(); + + vm.expectRevert( + abi.encodeWithSelector( + DeploymentRecord.NotRecorded.selector, + _recordKey(), + "admin.notARole" + ) + ); + reader.readRaw("admin.notARole"); + } + + function test_readUint_returnsARecordedNumber() public { + _deploy(); + + assertEq( + DeploymentRecord.readUint("params.priceCapUSDCents"), + PRICE_CAP_CENTS, + "price cap" + ); + assertEq( + DeploymentRecord.readUint("admin.initialDelay"), + DeployConfig.TIMELOCK_DELAY, + "timelock delay" + ); + } + + function test_readUint_revertsForAPathTheRecordDoesNotHold() public { + _deploy(); + + vm.expectRevert( + abi.encodeWithSelector( + DeploymentRecord.NotRecorded.selector, + _recordKey(), + "params.notAParameter" + ) + ); + reader.readUint("params.notAParameter"); + } + + function test_has_answersForBothAPresentAndAnAbsentPath() public { + _deploy(); + + assertTrue(DeploymentRecord.has("contracts.RegistryProxy"), "present"); + assertFalse(DeploymentRecord.has("contracts.SomethingElse"), "absent"); + } + + /// @dev `writeStatus` passes the bool as JSON text rather than serializing it, so the value + /// lands where the bool goes instead of as an object wrapping it. + function test_writeStatus_writesABoolAndNotAnObject() public { + _deploy(); + + DeploymentRecord.writeStatus("configured", true); + + assertTrue( + vm.parseJsonBool( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".status.configured") + ), + "configured reads as a bool" + ); + } + + // --------------------------------------------------------------------------------------------- + // Deploy + // --------------------------------------------------------------------------------------------- + + function test_deploy_wiresTheRegistryToBothFactoriesAndTheAdapter() public { + _deploy(); + + Registry registry = _registry(); + + assertEq( + address(registry.deviceWalletFactory()), + _recordedAddress("DeviceWalletFactoryProxy"), + "device factory" + ); + assertEq( + address(registry.eSIMWalletFactory()), + _recordedAddress("ESIMWalletFactoryProxy"), + "esim factory" + ); + assertEq( + registry.paymentAdapter(), + _recordedAddress("PaymentAdapterProxy"), + "payment adapter" + ); + } + + function test_deploy_pointsTheAdapterAndLazyRegistryBackAtTheRegistry() public { + _deploy(); + + address registry = address(_registry()); + + assertEq(_adapter().registry(), registry, "adapter registry"); + assertEq( + address(LazyWalletRegistry(_recordedAddress("LazyWalletRegistryProxy")).registry()), + registry, + "lazy registry" + ); + } + + /// @dev The deployer holds all five across the three scripts, because the wiring calls are owner + /// gated and cannot run through a timelock that is not yet the owner. + function test_deploy_leavesTheDeployerOwningEverySingleton() public { + _deploy(); + + assertEq(_registry().owner(), deployer, "registry"); + assertEq(LazyWalletRegistry(_recordedAddress("LazyWalletRegistryProxy")).owner(), deployer, "lazy"); + assertEq(DeviceWalletFactory(_recordedAddress("DeviceWalletFactoryProxy")).owner(), deployer, "device"); + assertEq(ESIMWalletFactory(_recordedAddress("ESIMWalletFactoryProxy")).owner(), deployer, "esim"); + assertEq(_adapter().owner(), deployer, "adapter"); + } + + function test_deploy_takesTheParametersFromTheEnvironment() public { + _deploy(); + + Registry registry = _registry(); + + assertEq(registry.eSIMWalletAdmin(), eSIMWalletAdmin, "admin"); + assertEq(registry.vault(), vault, "vault"); + assertEq(registry.defaultPriceCapUSDCents(), PRICE_CAP_CENTS, "price cap"); + assertEq(_adapter().settlementToken(), address(settlementToken), "settlement token"); + } + + /// @dev Read out of the ERC-1967 slot rather than repeated from the constructor argument, so a + /// proxy that came up pointing somewhere else shows in the record instead of being + /// described by it. + function test_deploy_recordsTheImplementationEachProxyActuallyPointsAt() public { + _deploy(); + + address proxy = address(_registry()); + address recorded = vm.parseJsonAddress( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".contracts.RegistryProxy.implementation") + ); + + assertEq( + recorded, + address(uint160(uint256(vm.load(proxy, IMPLEMENTATION_SLOT)))), + "recorded implementation" + ); + assertTrue(recorded.code.length > 0, "implementation carries code"); + } + + function test_deploy_recordsBothBeacons() public { + _deploy(); + + assertEq( + vm.parseJsonAddress( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".contracts.ESIMWalletFactoryProxy.beacon") + ), + address(ESIMWalletFactory(_recordedAddress("ESIMWalletFactoryProxy")).beacon()), + "esim beacon" + ); + assertEq( + vm.parseJsonAddress( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".contracts.DeviceWalletFactoryProxy.beacon") + ), + address(DeviceWalletFactory(_recordedAddress("DeviceWalletFactoryProxy")).beacon()), + "device beacon" + ); + } + + function test_deploy_startsWithBothLaterStepsUnfinished() public { + _deploy(); + + string memory record = vm.readFile(scratchRecord); + + assertFalse( + vm.parseJsonBool(record, string.concat(".", _recordKey(), ".status.configured")), + "not configured yet" + ); + assertFalse( + vm.parseJsonBool( + record, + string.concat(".", _recordKey(), ".status.ownershipTransferred") + ), + "not handed over yet" + ); + } + + /// @dev Overwriting the record is how a live proxy stops being reachable by any script, since + /// nothing else remembers where it is. + function test_deploy_refusesAChainThatAlreadyHasADeployment() public { + _deploy(); + + Deploy second = new Deploy(); + vm.expectRevert(abi.encodeWithSelector(Deploy.AlreadyDeployed.selector, _recordKey())); + second.run(); + } + + // --------------------------------------------------------------------------------------------- + // Configure + // --------------------------------------------------------------------------------------------- + + function test_configure_bindsBothFactoriesToTheRegistry() public { + _deploy(); + _configure(); + + address registry = address(_registry()); + + assertEq( + address(DeviceWalletFactory(_recordedAddress("DeviceWalletFactoryProxy")).registry()), + registry, + "device factory" + ); + assertEq( + address(ESIMWalletFactory(_recordedAddress("ESIMWalletFactoryProxy")).registry()), + registry, + "esim factory" + ); + } + + function test_configure_pointsTheRegistryAtTheLazyWalletRegistry() public { + _deploy(); + _configure(); + + assertEq( + _registry().lazyWalletRegistry(), + _recordedAddress("LazyWalletRegistryProxy"), + "lazy wallet registry" + ); + } + + /// @dev An adapter with an empty table prices nothing, so every purchase on both paths reverts + /// until this has run. + function test_configure_registersBothCurrencies() public { + _deploy(); + _configure(); + + (bool usdAllowed, bool usdIsDollar, uint8 usdDecimals, address usdToken) = + _adapter().assets(ASSET_USD); + + assertTrue(usdAllowed, "USD allowed"); + assertTrue(usdIsDollar, "USD is a dollar unit"); + assertEq(usdDecimals, 2, "USD decimals are cents"); + assertEq(usdToken, address(0), "USD moves no token"); + + (bool usdcAllowed,, uint8 usdcDecimals, address usdcToken) = _adapter().assets(ASSET_USDC); + + assertTrue(usdcAllowed, "USDC allowed"); + assertEq(usdcDecimals, SETTLEMENT_DECIMALS, "USDC decimals off the token"); + assertEq(usdcToken, address(settlementToken), "USDC token"); + } + + function test_configure_marksTheRecordConfigured() public { + _deploy(); + _configure(); + + assertTrue( + vm.parseJsonBool( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".status.configured") + ), + "configured" + ); + } + + /// @dev The whole point of the skip branches. Both `addRegistryAddress` functions and + /// `registerAsset` refuse a second call, so without them one failed transaction would + /// leave this script unable to finish the run it started. + function test_configure_canBeRunTwice() public { + _deploy(); + _configure(); + _configure(); + + assertEq( + address(DeviceWalletFactory(_recordedAddress("DeviceWalletFactoryProxy")).registry()), + address(_registry()), + "still bound after a second run" + ); + } + + function test_configure_revertsWhenAFactoryIsBoundToADifferentRegistry() public { + _deploy(); + + address factory = _recordedAddress("DeviceWalletFactoryProxy"); + address impostor = makeAddr("impostorRegistry"); + + vm.prank(deployer); + DeviceWalletFactory(factory).addRegistryAddress(impostor); + + Configure configure = new Configure(); + vm.expectRevert( + abi.encodeWithSelector( + Configure.BoundElsewhere.selector, + "DeviceWalletFactory", + address(_registry()), + impostor + ) + ); + configure.run(); + } + + /// @dev `updateAsset` would change the address every future payment is recorded into, so a + /// symbol already pointing somewhere else has to stop the run rather than be corrected. + function test_configure_revertsWhenACurrencyPointsAtADifferentToken() public { + _deploy(); + + address other = address(new MockERC20("Other", "OTH", 6)); + + vm.prank(deployer); + _adapter().registerAsset(ASSET_USDC, Asset({ + allowed: true, + isDollarUnit: true, + decimals: 6, + token: other + })); + + Configure configure = new Configure(); + vm.expectRevert( + abi.encodeWithSelector( + Configure.AssetBoundElsewhere.selector, + ASSET_USDC, + address(settlementToken), + other + ) + ); + configure.run(); + } + + /// @dev USDC sits at a different address on every chain, and the address for the wrong one can + /// still hold code, so this has to fail before anything is broadcast. + function test_configure_revertsWhenTheSettlementTokenHasNoCode() public { + _deploy(); + + vm.etch(SETTLEMENT_TOKEN_ADDRESS, ""); + + Configure configure = new Configure(); + vm.expectRevert( + abi.encodeWithSelector( + Configure.SettlementTokenNotDeployed.selector, + SETTLEMENT_TOKEN_ADDRESS, + block.chainid + ) + ); + configure.run(); + } + + function test_configure_revertsWhenTheSettlementTokenAnswersNothing() public { + _deploy(); + + vm.etch(SETTLEMENT_TOKEN_ADDRESS, address(new NotAToken()).code); + + Configure configure = new Configure(); + vm.expectRevert( + abi.encodeWithSelector( + Configure.SettlementTokenNotTokenLike.selector, + SETTLEMENT_TOKEN_ADDRESS + ) + ); + configure.run(); + } + + // --------------------------------------------------------------------------------------------- + // TransferOwnership + // --------------------------------------------------------------------------------------------- + + function test_transferOwnership_movesAllFiveToTheTimelock() public { + _deploy(); + _configure(); + new TransferOwnership().run(); + + address protocolAdmin = _recordedAddress("ProtocolAdmin"); + + assertEq(_registry().owner(), protocolAdmin, "registry"); + assertEq(LazyWalletRegistry(_recordedAddress("LazyWalletRegistryProxy")).owner(), protocolAdmin, "lazy"); + assertEq(DeviceWalletFactory(_recordedAddress("DeviceWalletFactoryProxy")).owner(), protocolAdmin, "device"); + assertEq(ESIMWalletFactory(_recordedAddress("ESIMWalletFactoryProxy")).owner(), protocolAdmin, "esim"); + assertEq(_adapter().owner(), protocolAdmin, "adapter"); + } + + function test_transferOwnership_marksTheRecordHandedOver() public { + _deploy(); + _configure(); + new TransferOwnership().run(); + + assertTrue( + vm.parseJsonBool( + vm.readFile(scratchRecord), + string.concat(".", _recordKey(), ".status.ownershipTransferred") + ), + "ownership transferred" + ); + } + + /// @dev Checked over all five before the first offer is sent, so a run that would hand over + /// four and revert on the fifth never sends any of them. + function test_transferOwnership_refusesToStartWhenTheDeployerLostOne() public { + _deploy(); + _configure(); + + address stranger = makeAddr("stranger"); + PaymentAdapter adapter = _adapter(); + + vm.prank(deployer); + adapter.transferOwnership(stranger); + vm.prank(stranger); + adapter.acceptOwnership(); + + TransferOwnership handover = new TransferOwnership(); + vm.expectRevert( + abi.encodeWithSelector( + TransferOwnership.NotOwner.selector, + address(adapter), + stranger, + deployer + ) + ); + handover.run(); + } + + /// @dev The timelock is what every owner gated call waits on afterwards, so the delay it comes + /// up with is part of the deployment rather than something set later. + function test_transferOwnership_leavesTheTimelockHoldingItsConfiguredDelay() public { + _deploy(); + _configure(); + new TransferOwnership().run(); + + ProtocolAdmin admin = ProtocolAdmin(payable(_recordedAddress("ProtocolAdmin"))); + + assertEq(admin.getMinDelay(), DeployConfig.TIMELOCK_DELAY, "delay"); + assertTrue(admin.hasRole(admin.PROPOSER_ROLE(), proposer), "proposer"); + assertTrue(admin.hasRole(admin.CANCELLER_ROLE(), canceller), "canceller"); + } +} diff --git a/test/foundry/deploy/base/ScriptBase.sol b/test/scripts/base/ScriptBase.sol similarity index 67% rename from test/foundry/deploy/base/ScriptBase.sol rename to test/scripts/base/ScriptBase.sol index 91c04b0c..33b9331b 100644 --- a/test/foundry/deploy/base/ScriptBase.sol +++ b/test/scripts/base/ScriptBase.sol @@ -15,9 +15,16 @@ import {DeployConfig} from "scripts/deploy/config/DeployConfig.sol"; /// fork and is the better check of the happy path; what these tests reach that it cannot is /// the failure and resume branches, which need a broken environment on purpose. /// -/// The environment is process wide, and so is the scratch record path built on top of it. -/// Each test contract therefore writes to a file named after itself, and `setUp` re-seeds it -/// so one test never reads what the previous one left. +/// Forge runs every `setUp` in the run before it runs any test, and environment variables +/// belong to the process rather than to the EVM. So the last `setUp` to write a variable +/// decides what every test in every contract reads, and two contracts cannot hold different +/// values for one name. Everything written here is therefore identical whichever contract +/// calls it, down to the settlement token, which is put at a fixed address for that reason +/// rather than deployed wherever the next nonce lands. +/// +/// The same applies to the scratch record, which is one file for the whole suite. Two +/// contracts writing it at once would interleave, so the tests that write it share a single +/// contract. abstract contract ScriptBase is Test { /// @notice Anvil's first account, the key `rehearse.sh` deploys with @@ -34,6 +41,18 @@ abstract contract ScriptBase is Test { /// token answering something else is what that path has to survive. uint8 internal constant SETTLEMENT_DECIMALS = 6; + /// @notice Where the stand-in settlement token is put + /// @dev Fixed, because its address goes into an environment variable every contract in this + /// suite has to agree on. Deploying it normally would put it wherever the deploying + /// contract's next nonce landed, which differs per contract. + address internal constant SETTLEMENT_TOKEN_ADDRESS = + 0x0000000000000000000000000000000000005DC0; + + /// @notice Scratch record shared by every test in this suite + /// @dev One file, for the same reason the environment values are identical: the path is itself + /// an environment variable, so per-contract paths are not possible. + string internal constant SCRATCH_RECORD = "deployments/.test-deploy-scripts.json"; + address internal deployer; address internal eSIMWalletAdmin; address internal vault; @@ -48,10 +67,10 @@ abstract contract ScriptBase is Test { string internal scratchRecord; /// @notice Puts a complete, valid environment in place and seeds an empty record - /// @dev Call from `setUp` before anything runs a script. The name has to be unique per test - /// contract, since forge runs contracts in parallel against one filesystem. - /// @param _name Short name for this contract's scratch record - function _setUpScriptEnvironment(string memory _name) internal { + /// @dev Call from `setUp` before anything runs a script. Every value it writes is the same in + /// whichever contract calls it, which is what lets more than one contract in this suite + /// exist at all. + function _setUpScriptEnvironment() internal { deployer = vm.addr(DEPLOYER_KEY); eSIMWalletAdmin = makeAddr("eSIMWalletAdmin"); vault = makeAddr("vault"); @@ -62,14 +81,19 @@ abstract contract ScriptBase is Test { canceller = makeAddr("canceller"); guardian = makeAddr("guardian"); - settlementToken = new MockERC20("USD Coin", "USDC", SETTLEMENT_DECIMALS); + deployCodeTo( + "MockERC20.sol:MockERC20", + abi.encode("USD Coin", "USDC", SETTLEMENT_DECIMALS), + SETTLEMENT_TOKEN_ADDRESS + ); + settlementToken = MockERC20(SETTLEMENT_TOKEN_ADDRESS); // The scripts refuse a chain the EntryPoint is not on, which is every chain a test runs on // until something is put there. vm.etch(DeployConfig.ENTRY_POINT_V08, address(new MockEntryPoint()).code); _setEnvironment(); - _resetRecord(_name); + _resetRecord(); } /// @notice Writes every variable `DeployConfig.load` reads @@ -84,13 +108,13 @@ abstract contract ScriptBase is Test { vm.setEnv("TIMELOCK_GUARDIANS", vm.toString(guardian)); } - /// @notice Points the scripts at an empty record of this contract's own + /// @notice Points the scripts at an empty scratch record /// @dev `fs_permissions` grants write access to `deployments` and nowhere else, so the scratch /// file lives beside the real record rather than in a temporary directory. The leading dot - /// and the `test-` prefix are what `.gitignore` matches on. - /// @param _name Short name for this contract's scratch record - function _resetRecord(string memory _name) internal { - scratchRecord = string.concat("deployments/.test-", _name, ".json"); + /// and the `test-` prefix are what `.gitignore` matches on. Call it again at the top of a + /// test that needs a clean record, since `setUp` runs once for the whole contract. + function _resetRecord() internal { + scratchRecord = SCRATCH_RECORD; vm.writeFile(scratchRecord, "{}"); vm.setEnv("DEPLOYMENT_RECORD_PATH", scratchRecord); From aaa21b88261d9c6baa9752db4c4b4af00ce37043 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 15:44:38 +0530 Subject: [PATCH 52/75] Run the deploy script tests in CI --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37454c33..01fe0902 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,15 @@ jobs: forge test --via-ir -vvv id: test + # The deploy scripts, which the fork rehearsal covers better but which it cannot run here: + # that needs an RPC key and a live anvil. These reach the failure and resume branches + # instead. They run one at a time because they share one record file and one process + # environment, so `--threads 1` is not optional. + - name: Run deploy script tests + run: | + FOUNDRY_PROFILE=scripts forge test --via-ir --threads 1 -vvv + id: script-test + # Fuzz and invariant runs draw a fresh seed each time, and the malleable twin case verifies a # signature generated offchain per run, so all three report a different figure every time. # They are left out of the baseline rather than given a tolerance, so what remains is checked From a79419370f0f7dab2a12e415fec2950235a79390 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 15:45:32 +0530 Subject: [PATCH 53/75] Keep the rehearsal out of the real deployment record It wrote into the live file and then removed its own entry on the way out. --- scripts/fork/rehearse.sh | 41 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/scripts/fork/rehearse.sh b/scripts/fork/rehearse.sh index f8f182d5..51378619 100755 --- a/scripts/fork/rehearse.sh +++ b/scripts/fork/rehearse.sh @@ -10,13 +10,14 @@ # # The fork reports chain id 31337 rather than 84532. That is deliberate. The record key is built # from the chain id, so a fork answering 84532 would write the production -# base-sepolia-84532-entrypoint-v8 entry into deployments/address.json from a run that deployed -# nothing real. The forked state still carries the real EntryPoint v0.8 and the real RIP-7212 -# precompile, which is the reason to fork at all. What it does not carry is the production EIP-712 -# domain, since v0.8 folds the chain id into userOpHash; EntryPointValidation.t.sol covers that at -# the real chain id and this does not try to. +# base-sepolia-84532-entrypoint-v8 entry from a run that deployed nothing real. The forked state +# still carries the real EntryPoint v0.8 and the real RIP-7212 precompile, which is the reason to +# fork at all. What it does not carry is the production EIP-712 domain, since v0.8 folds the chain +# id into userOpHash; EntryPointValidation.t.sol covers that at the real chain id and this does not +# try to. # -# The anvil-31337-entrypoint-v8 record this writes is a rehearsal artifact. It is removed on exit. +# The whole run writes to its own record rather than the real one, so nothing here can reach the +# file that tracks live deployments. It is deleted on exit. set -euo pipefail @@ -25,7 +26,11 @@ cd "$(dirname "${BASH_SOURCE[0]}")/../.." FORK_BLOCK="${FORK_BLOCK:-45600000}" CHAIN_ID=31337 RPC="http://127.0.0.1:8545" -RECORD="deployments/address.json" + +# The scripts read this and write nowhere else. fs_permissions only grants them deployments/, so +# the rehearsal's record has to sit beside the real one rather than in a temporary directory. +RECORD="deployments/.test-rehearsal.json" +export DEPLOYMENT_RECORD_PATH="$RECORD" RECORD_KEY="anvil-${CHAIN_ID}-entrypoint-v8" LOG_DIR="$(mktemp -d)" ANVIL_PID="" @@ -37,18 +42,9 @@ cleanup() { local status=$? [[ -n "$ANVIL_PID" ]] && kill "$ANVIL_PID" 2>/dev/null || true - # Drop the rehearsal's record entry however the run ended. Leaving it behind is how a fork - # address gets mistaken for a deployed one later. - if [[ -f "$RECORD" ]]; then - python3 - "$RECORD" "$RECORD_KEY" <<'PY' || true -import collections, json, pathlib, sys -path, key = pathlib.Path(sys.argv[1]), sys.argv[2] -data = json.loads(path.read_text(), object_pairs_hook=collections.OrderedDict) -if data.pop(key, None) is not None: - path.write_text(json.dumps(data, indent=2) + "\n") - print(f"Removed the rehearsal record entry {key}") -PY - fi + # Drop the rehearsal's record however the run ended. Leaving it behind is how a fork address + # gets mistaken for a deployed one later. + rm -f "$RECORD" echo "Logs kept in $LOG_DIR" exit $status @@ -140,10 +136,9 @@ probe_p256() { probe_p256 "$RPC" "the fork" probe_p256 "$ALCHEMY_BASE_SEPOLIA_HTTPS" "Base Sepolia itself" -# Refuse to start on a record that already holds this key, the same way Deploy.s.sol does. -if python3 -c "import json,sys; sys.exit(0 if '$RECORD_KEY' in json.load(open('$RECORD')) else 1)" 2>/dev/null; then - fail "$RECORD already holds $RECORD_KEY. Remove it before rehearsing again." -fi +# Start from an empty record. A leftover from an interrupted run would make Deploy.s.sol refuse to +# start, which is the behaviour a real deployment wants and not what a rehearsal wants. +echo '{}' > "$RECORD" # Forge sizes a broadcast transaction from what the simulation consumed. That is wrong for # handleOps: the EntryPoint checks the transaction carries the operation's whole declared gas From 7b757f8e577acc2fae4f21c93433b60c8543dcd6 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 15:46:34 +0530 Subject: [PATCH 54/75] Document the deploy script suite and why it runs alone --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index eb936955..649165c9 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,23 @@ The long invariant campaign is a separate profile, run before a release rather t FOUNDRY_PROFILE=campaign forge test --match-path "test/foundry/invariant-testing/*" ``` +The deployment scripts have their own suite, kept out of the default run: + +```bash +FOUNDRY_PROFILE=scripts forge test --threads 1 +``` + +50 tests in `test/scripts/`, exercising `Deploy.s.sol`, `Configure.s.sol` and `TransferOwnership.s.sol` +against a scratch record. `scripts/fork/rehearse.sh` runs the same three against a Base Sepolia fork +and checks the happy path better than any test can, but it needs an RPC key and a live anvil, so CI +never runs it. These reach what it cannot: the resume branches and the mismatch guards, which only +fire on a deployment that has already gone wrong. + +`--threads 1` is not optional. The scripts read their parameters from the process environment and +their record from one file, and forge runs every `setUp` before any test and then runs the tests in +parallel. Without the flag, somewhere between 2 and 12 of the 50 fail on each run, and which ones +changes every time. + Two gas baselines, measuring different things. `.gas-snapshot` is the whole-test-body figure, useful as a regression tripwire and not as a protocol gas number, since a row can include whatever that test deployed. `snapshots/*.json` is per operation, written by the tests under `test/foundry/gas/` and rewritten by any ordinary `forge test`. From cfdb046f2e5c5b825939fea1dca63ad77177b4b6 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 15:52:22 +0530 Subject: [PATCH 55/75] Name the two test files after what they now cover Neither had held an ETH purchase test since the path was retired. --- .gas-snapshot | 26 +++++++++---------- .../{ETHAmounts.t.sol => TokenAmounts.t.sol} | 5 +++- ...TH.t.sol => DeviceWalletFundsAccess.t.sol} | 2 +- 3 files changed, 18 insertions(+), 15 deletions(-) rename test/foundry/fuzz-testing/{ETHAmounts.t.sol => TokenAmounts.t.sol} (97%) rename test/foundry/unit-testing/device-wallet/{DeviceWalletETH.t.sol => DeviceWalletFundsAccess.t.sol} (99%) diff --git a/.gas-snapshot b/.gas-snapshot index 59e5375e..da246058 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -83,19 +83,6 @@ DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3418333) DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403384) DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3745124) -DeviceWalletETHTest:test_aFreshESIMWalletStartsWithNoFundsAccess() (gas: 1270323) -DeviceWalletETHTest:test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() (gas: 3393673) -DeviceWalletETHTest:test_deployESIMWallet_cannotGrantFundsAccess() (gas: 3685306) -DeviceWalletETHTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402168) -DeviceWalletETHTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3437784) -DeviceWalletETHTest:test_getVaultAddress() (gas: 3395658) -DeviceWalletETHTest:test_getVaultAddress_followsTheRegistry() (gas: 3418337) -DeviceWalletETHTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4242702) -DeviceWalletETHTest:test_theOwnerGrantsAfterBinding() (gas: 4012726) -DeviceWalletETHTest:test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() (gas: 3659153) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() (gas: 3580830) -DeviceWalletETHTest:test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() (gas: 3605526) -DeviceWalletETHTest:test_toggleAccessToFunds_unauthorised() (gas: 3396388) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4551264) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936843) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960853) @@ -156,6 +143,19 @@ DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnOffCur DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOwnerKey() (gas: 83875) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnOffCurveOwnerKey() (gas: 19826) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnZeroOwnerKey() (gas: 19342) +DeviceWalletFundsAccessTest:test_aFreshESIMWalletStartsWithNoFundsAccess() (gas: 1270323) +DeviceWalletFundsAccessTest:test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() (gas: 3393673) +DeviceWalletFundsAccessTest:test_deployESIMWallet_cannotGrantFundsAccess() (gas: 3685306) +DeviceWalletFundsAccessTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402168) +DeviceWalletFundsAccessTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3437784) +DeviceWalletFundsAccessTest:test_getVaultAddress() (gas: 3395658) +DeviceWalletFundsAccessTest:test_getVaultAddress_followsTheRegistry() (gas: 3418337) +DeviceWalletFundsAccessTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4242702) +DeviceWalletFundsAccessTest:test_theOwnerGrantsAfterBinding() (gas: 4012726) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() (gas: 3659153) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() (gas: 3580830) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() (gas: 3605526) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_unauthorised() (gas: 3396388) DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961341) DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954195) DeviceWalletGuardsTest:test_init_rejectsAZeroESIMWalletFactory() (gas: 117237) diff --git a/test/foundry/fuzz-testing/ETHAmounts.t.sol b/test/foundry/fuzz-testing/TokenAmounts.t.sol similarity index 97% rename from test/foundry/fuzz-testing/ETHAmounts.t.sol rename to test/foundry/fuzz-testing/TokenAmounts.t.sol index da4b0a33..eeae27de 100644 --- a/test/foundry/fuzz-testing/ETHAmounts.t.sol +++ b/test/foundry/fuzz-testing/TokenAmounts.t.sol @@ -17,7 +17,10 @@ import {FuzzBase} from "test/foundry/fuzz-testing/base/FuzzBase.sol"; /// /// The ceiling now bounds what gets spent rather than only what gets recorded, because the /// adapter works the amount out from the same price the cap is checked against. -contract ETHAmountsTest is FuzzBase { +/// +/// The last two cases are about the other two amounts on that path: what a pull moves, and the +/// access flag a bind is never allowed to set. +contract TokenAmountsTest is FuzzBase { /// @dev $10,000,000 in cents uint64 private constant MAX_FUZZED_CENTS = 1_000_000_000; diff --git a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol b/test/foundry/unit-testing/device-wallet/DeviceWalletFundsAccess.t.sol similarity index 99% rename from test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol rename to test/foundry/unit-testing/device-wallet/DeviceWalletFundsAccess.t.sol index 2e5be3dd..5bc15ecb 100644 --- a/test/foundry/unit-testing/device-wallet/DeviceWalletETH.t.sol +++ b/test/foundry/unit-testing/device-wallet/DeviceWalletFundsAccess.t.sol @@ -14,7 +14,7 @@ import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; /// @notice Who may reach a device wallet's money, and where the vault address comes from. /// @dev The pull itself is covered in DeviceWalletTokens. What is here is the access flag seen /// through a purchase, which is the only thing that spends on a user's behalf. -contract DeviceWalletETHTest is DeviceWalletFixture { +contract DeviceWalletFundsAccessTest is DeviceWalletFixture { function test_getVaultAddress() public { deployWallets(); From f6ffdc617850b7b63ee1a04a409b0c365664b3d5 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 19:41:11 +0530 Subject: [PATCH 56/75] Fix two event name typos to match sibling naming 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. --- contracts/Registry.sol | 2 +- contracts/RegistryHelper.sol | 2 +- contracts/esim-wallet/ESIMWalletFactory.sol | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/Registry.sol b/contracts/Registry.sol index a7e5a339..82554090 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -550,7 +550,7 @@ contract Registry is } isESIMWalletValid[_eSIMWalletAddress] = _deviceWalletAddress; - emit UpdatedDeviceWalletassociatedWithESIMWallet(_eSIMWalletAddress, _deviceWalletAddress); + emit UpdatedDeviceWalletAssociatedWithESIMWallet(_eSIMWalletAddress, _deviceWalletAddress); // Only written on a change, so a wallet that was never released emits nothing here if(isESIMWalletOnStandby[_eSIMWalletAddress]) { diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index affffdac..0d950a55 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -113,7 +113,7 @@ contract RegistryHelper { ); /// @notice Emitted when an eSIM wallet is bound to a device wallet - event UpdatedDeviceWalletassociatedWithESIMWallet( + event UpdatedDeviceWalletAssociatedWithESIMWallet( address indexed _eSIMWalletAddress, address indexed _deviceWalletAddress ); diff --git a/contracts/esim-wallet/ESIMWalletFactory.sol b/contracts/esim-wallet/ESIMWalletFactory.sol index 1f00f72d..79113d84 100644 --- a/contracts/esim-wallet/ESIMWalletFactory.sol +++ b/contracts/esim-wallet/ESIMWalletFactory.sol @@ -35,10 +35,10 @@ contract ESIMWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgrad mapping(address eSIMWalletAddress => bool isDeployed) public isESIMWalletDeployed; /// @notice Emitted when the eSIM wallet factory is deployed - event ESIMWalletFactorydeployed( + event ESIMWalletFactoryDeployed( address indexed _upgradeManager, address indexed _eSIMWalletImplementation, - address indexed beacon + address indexed _beacon ); /// @notice Emitted when a new eSIM wallet is deployed @@ -97,7 +97,7 @@ contract ESIMWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgrad beacon = new UpgradeableBeacon(_eSIMWalletImplementation, (address(this))); - emit ESIMWalletFactorydeployed( + emit ESIMWalletFactoryDeployed( _upgradeManager, _eSIMWalletImplementation, address(beacon) From c908b867ff7fee3d3cc74e949b24233920a87fe7 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 19:49:18 +0530 Subject: [PATCH 57/75] Update stale payment wording in comments 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. --- contracts/Registry.sol | 10 +++++----- contracts/RegistryHelper.sol | 2 +- contracts/admin/ProtocolAdmin.sol | 2 +- contracts/device-wallet/DeviceWallet.sol | 6 +++--- contracts/esim-wallet/ESIMWallet.sol | 10 +++++----- scripts/deploy/Deploy.s.sol | 2 +- test/foundry/gas/ESIMWallet.Operations.gas.t.sol | 2 +- .../invariant-testing/ETHInvariants.t.sol | 4 ++-- .../invariant-testing/base/ProtocolState.sol | 6 +++--- .../invariant-testing/handler/WalletHandler.sol | 6 +++--- .../device-wallet/base/DeviceWalletFixture.sol | 16 ++++++++-------- .../esim-wallet/ESIMWalletTokenPurchase.t.sol | 2 +- test/utils/DeployerBase.sol | 2 +- 13 files changed, 35 insertions(+), 35 deletions(-) diff --git a/contracts/Registry.sol b/contracts/Registry.sol index 82554090..cb7d993d 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -76,7 +76,7 @@ contract Registry is /// that is never accepted leaves the role dormant rather than shared. address public newRequestedAdmin; - /// @notice True while the ETH-moving paths are stopped protocol-wide + /// @notice True while the protocol's guarded purchase and pull paths are stopped /// @dev Held here for the same reason the admin address is: device wallets and eSIM wallets are /// beacon proxies tracked by a mapping with no enumerable list, so there is no way to /// write a flag into each of them. Both already read this contract on their guarded paths, @@ -319,7 +319,7 @@ contract Registry is // Pause and price ceiling // --------------------------------------------------------------------------------------------- - /// @notice Stops the ETH-moving paths on every device wallet and eSIM wallet + /// @notice Stops the token purchase and pull paths on every device wallet and eSIM wallet /// @dev The admin trips this and the owner clears it. The admin key signs backend batches all /// day and is the one watching, so it needs to act without waiting; giving it the release /// as well would let a single hot key hold user funds indefinitely. Neither key can reach @@ -372,7 +372,7 @@ contract Registry is // Purchases paid for outside the protocol // --------------------------------------------------------------------------------------------- - /// @notice Spends a payment reference for an eSIM wallet buying with ETH + /// @notice Spends a payment reference for an eSIM wallet paying with USDC (or any other acceptable stablecoin/ERC20) /// @dev The adapter only accepts this from the registry, so the wallet has to come through /// here. One reference then cannot be spent once on each path. /// @param _paymentReference Hash tying the purchase to the offchain order behind it @@ -380,7 +380,7 @@ contract Registry is _consumePaymentReference(_paymentReference); } - /// @notice Records a data bundle paid for through Moonpay, a card or an external wallet + /// @notice Records a data bundle paid for through an external wallet or a card /// @dev No money moves here. Three things bound what the admin can state: the price ceiling, /// the payment reference being spendable once, and the settlement not being /// `DeviceWallet`. Written here and not by the adapter, because eSIM wallets accept @@ -390,7 +390,7 @@ contract Registry is /// @param _asset Symbol of the currency the user paid in /// @param _tokenAmount What the user actually paid, in that currency's smallest unit. Recorded /// for offchain matching, never checked: it and the price both come from the admin. - /// @param _paymentReference Hash of the Moonpay charge or the Stripe payment intent + /// @param _paymentReference Hash tying this purchase to its offchain payment intent function recordSettledPurchase( address _eSIMWallet, DataBundleDetails calldata _dataBundleDetail, diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index 0d950a55..a5597d2e 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -152,7 +152,7 @@ contract RegistryHelper { /// @notice Emitted when the owner points data bundle payments at a different vault event VaultAddressUpdated(address indexed _updatedVaultAddress); - /// @notice Emitted when the admin stops the ETH-moving paths protocol-wide + /// @notice Emitted when the admin stops the protocol's guarded purchase and pull paths event Paused(address indexed _admin); /// @notice Emitted when the owner releases the pause diff --git a/contracts/admin/ProtocolAdmin.sol b/contracts/admin/ProtocolAdmin.sol index d01d33d6..fa5affcd 100644 --- a/contracts/admin/ProtocolAdmin.sol +++ b/contracts/admin/ProtocolAdmin.sol @@ -34,7 +34,7 @@ import {TimelockController} from "@openzeppelin/contracts/governance/TimelockCon /// chosen by the guardian. Restoring any of the three is an owner action and waits, so the /// side taking power away always wins the race against the side handing it back. A guardian /// able to reinstate an admin would lose that, and a guardian able to appoint one would reach -/// `ESIMWallet.buyDataBundle` and every wallet holding ETH access through it. +/// `ESIMWallet.buyDataBundleWithToken` and every wallet holding funds access through it. /// /// The second one exists because without it a compromised canceller is permanent. Evicting any /// role holder means scheduling `revokeRole`, a scheduled operation can be cancelled by any diff --git a/contracts/device-wallet/DeviceWallet.sol b/contracts/device-wallet/DeviceWallet.sol index 665b4208..f1dffdcb 100644 --- a/contracts/device-wallet/DeviceWallet.sol +++ b/contracts/device-wallet/DeviceWallet.sol @@ -41,9 +41,9 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// @notice Set to true if the eSIM wallet belongs to this device wallet mapping(address eSIMWalletAddress => bool isValid) public isValidESIMWallet; - /// @notice Tracks if an associated eSIM wallet may pull this wallet's ETH and tokens - /// @dev One flag for both. A wallet trusted with the ETH can already drain the owner, so a - /// second flag per asset would cost another signature and limit nothing. + /// @notice Tracks if an associated eSIM wallet may pull this wallet's tokens + /// @dev A wallet trusted with the funds can already drain the owner, so a second flag per + /// asset would cost another signature and limit nothing. mapping(address eSIMWalletAddress => bool isAllowedToPullFunds) public canPullFunds; /// @notice Emitted when owner updates an eSIM wallet's access to this wallet's money diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index f2d6b51e..49aed9e8 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -20,7 +20,7 @@ import {DeviceWallet} from "../device-wallet/DeviceWallet.sol"; import {PaymentAdapter, Asset} from "../payments/PaymentAdapter.sol"; import {Registry} from "../Registry.sol"; -/// @notice One eSIM, its purchase history and the ETH that pays for its data bundles +/// @notice One eSIM, its purchase history, and the funds that move through it while it buys data bundles /// @dev A beacon proxy deployed by `ESIMWalletFactory`, always owned by a device wallet. The owner /// is a contract rather than a key, so every call that moves ETH or ownership arrives through /// a device wallet `execute` and has already been signed for. The admin can charge this wallet @@ -58,7 +58,7 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade address indexed _owner ); - /// @notice Emitted when a data bundle is paid for in an ERC-20 + /// @notice Emitted when a data bundle is paid for in USDC (or any other acceptable stablecoin/ERC20) /// @dev The adapter emits the settlement. This one is for an indexer watching one wallet. event DataBundleBoughtWithToken( bytes32 _dataBundleID, @@ -295,9 +295,9 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade return _amount; } - /// @notice Pays the vault for one data bundle in an ERC-20 and records the purchase - /// @dev The adapter works the amount out from the price, so unlike the ETH path there is no - /// second figure to take on trust. Any shortfall is pulled from the device wallet. + /// @notice Pays the vault for one data bundle in USDC (or any other acceptable stablecoin/ERC20) and records the purchase + /// @dev The adapter works the amount out from the price, so there is never a second figure to + /// take on trust. Any shortfall is pulled from the device wallet. /// @param _dataBundleDetail Data bundle being bought. Its settlement field is overwritten here. /// @param _asset Symbol of the currency to pay in /// @param _maxAmountIn Most of that currency the buyer will spend, in its smallest unit diff --git a/scripts/deploy/Deploy.s.sol b/scripts/deploy/Deploy.s.sol index d1fcc03e..b2d25800 100644 --- a/scripts/deploy/Deploy.s.sol +++ b/scripts/deploy/Deploy.s.sol @@ -37,7 +37,7 @@ import {DeploymentRecord} from "./config/DeploymentRecord.sol"; /// depend on it and the handover script has nothing left to decide. /// /// Ordinary `ERC1967Proxy`, not the upgrades plugin. The whole test suite stands the protocol -/// up this way, so the deployed shape is the shape 573 tests run against, and the safety the +/// up this way, so the deployed shape is the shape the whole test suite runs against, and the safety the /// plugin checks at deploy time is already pinned by `ImplementationLocks.t.sol` and /// `StorageLayout.t.sol`. contract Deploy is Script { diff --git a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol index 5b5f9769..e6ca64b4 100644 --- a/test/foundry/gas/ESIMWallet.Operations.gas.t.sol +++ b/test/foundry/gas/ESIMWallet.Operations.gas.t.sol @@ -38,7 +38,7 @@ contract ESIMWalletOperationsGasTest is GasBase { vm.snapshotGasLastCall(NAMESPACE, "deployESIMWallet: through the factory"); } - /// @notice Buying a data bundle with an ERC-20, funded and unfunded + /// @notice Buying a data bundle with USDC (or any other acceptable stablecoin/ERC20), funded and unfunded /// @dev The unfunded case is the common one and costs a pull on top. A first purchase writes /// the vault a balance it did not have, so one runs unmeasured before either figure is /// taken and the two are then comparable. diff --git a/test/foundry/invariant-testing/ETHInvariants.t.sol b/test/foundry/invariant-testing/ETHInvariants.t.sol index 010abfc4..98394c6d 100644 --- a/test/foundry/invariant-testing/ETHInvariants.t.sol +++ b/test/foundry/invariant-testing/ETHInvariants.t.sol @@ -39,8 +39,8 @@ contract ETHInvariantsTest is CampaignBase { } /// @notice The right to spend a device wallet's money only ever comes from its owner - /// @dev One flag covers ETH and tokens, so this is the whole spending right rather than the ETH - /// half. A pair holding it with no grant recorded is access the campaign got some other + /// @dev The flag covers only tokens now, so this is the whole spending right rather than part of + /// it. A pair holding it with no grant recorded is access the campaign got some other /// way. Both the current device wallet and the last one are checked, since the two differ /// while a transfer is outstanding. function invariant_fundsAccessOnlyEverCameFromTheOwner() public view { diff --git a/test/foundry/invariant-testing/base/ProtocolState.sol b/test/foundry/invariant-testing/base/ProtocolState.sol index cf36f06e..146e5e2e 100644 --- a/test/foundry/invariant-testing/base/ProtocolState.sol +++ b/test/foundry/invariant-testing/base/ProtocolState.sol @@ -39,11 +39,11 @@ contract ProtocolState { /// @notice The last device wallet that held each eSIM wallet, kept after a detachment /// @dev The mapping above goes back to zero when a wallet is removed, which loses the one /// address worth checking afterwards. A removal that cleared the association but left the - /// right to pull ETH behind would leave the leftover on the wallet that just let go, and + /// right to pull funds behind would leave the leftover on the wallet that just let go, and /// finding it any other way means comparing every device wallet against every eSIM wallet. mapping(address eSIMWallet => address deviceWallet) public ghost_lastDevice; - /// @notice Set for each pair the owner has granted the right to pull ETH and not since revoked + /// @notice Set for each pair the owner has granted the right to pull funds and not since revoked /// @dev Keyed by the pair, so a wallet that moves to a second device wallet does not read as /// carrying the first one's grant. mapping(address deviceWallet => mapping(address eSIMWallet => bool granted)) public ghost_ethAccessGranted; @@ -230,7 +230,7 @@ contract ProtocolState { _setESIMOwner(wallet, device); } - /// @notice Records that the owner granted an eSIM wallet the right to pull ETH + /// @notice Records that the owner granted an eSIM wallet the right to pull funds function recordETHAccessGrant(address device, address wallet) external { ghost_ethAccessGranted[device][wallet] = true; } diff --git a/test/foundry/invariant-testing/handler/WalletHandler.sol b/test/foundry/invariant-testing/handler/WalletHandler.sol index e23e1905..8521bb2d 100644 --- a/test/foundry/invariant-testing/handler/WalletHandler.sol +++ b/test/foundry/invariant-testing/handler/WalletHandler.sol @@ -78,7 +78,7 @@ contract WalletHandler is HandlerBase { ); assertFalse( DeviceWallet(payable(device)).canPullFunds(wallet), - "A removed eSIM wallet kept its right to pull ETH" + "A removed eSIM wallet kept its right to pull funds" ); assertEq( registry.isESIMWalletValid(wallet), @@ -181,7 +181,7 @@ contract WalletHandler is HandlerBase { ); assertFalse( DeviceWallet(payable(device)).canPullFunds(wallet), - "An added eSIM wallet arrived with the right to pull ETH" + "An added eSIM wallet arrived with the right to pull funds" ); assertEq( registry.isESIMWalletValid(wallet), @@ -197,7 +197,7 @@ contract WalletHandler is HandlerBase { } } - /// @notice The wallet owner revokes or restores an eSIM wallet's right to pull ETH + /// @notice The wallet owner revokes or restores an eSIM wallet's right to pull funds /// @param eSIMIndex Which eSIM wallet to toggle /// @param hasAccessToFunds The access it should end up with function toggleAccessToFunds(uint256 eSIMIndex, bool hasAccessToFunds) external counted { diff --git a/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol b/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol index c63a5266..bf55f357 100644 --- a/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol +++ b/test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol @@ -18,9 +18,9 @@ abstract contract DeviceWalletFixture is DeployerBase { MockDeviceWallet deviceWallet; MockDeviceWallet deviceWallet2; MockDeviceWallet deviceWallet3; // Carol's (Malicious actor) device wallet - MockESIMWallet eSIMWallet1; // has access to ETH, has eSIM identifier set, belongs to deviceWallet1 - MockESIMWallet eSIMWallet2; // no access to ETH, no eSIM identifier set, belongs to deviceWallet1 - MockESIMWallet eSIMWallet3; // has access to ETH, has eSIM identifier set, belongs to deviceWallet2 + MockESIMWallet eSIMWallet1; // has funds access, has eSIM identifier set, belongs to deviceWallet1 + MockESIMWallet eSIMWallet2; // no funds access, no eSIM identifier set, belongs to deviceWallet1 + MockESIMWallet eSIMWallet3; // has funds access, has eSIM identifier set, belongs to deviceWallet2 MockDeviceWallet userDeviceWallet; // Custom device wallet deployed with user defined x and y keys MockESIMWallet userESIMWallet; // eSIM wallet associated with user's custom device wallet @@ -109,7 +109,7 @@ abstract contract DeviceWalletFixture is DeployerBase { /// @notice Deploys the three device wallets and three eSIM wallets the tests share /// @dev The three eSIM wallets differ deliberately: eSIMWallet1 has an identifier and may pull - /// ETH, eSIMWallet2 has neither, and eSIMWallet3 belongs to a second device wallet so + /// funds, eSIMWallet2 has neither, and eSIMWallet3 belongs to a second device wallet so /// cross-owner cases have somewhere to move a wallet to. function deployWallets() public { address admin = deviceWalletFactory.eSIMWalletAdmin(); @@ -141,7 +141,7 @@ abstract contract DeviceWalletFixture is DeployerBase { ); vm.stopPrank(); - // eSIMWallet1 -> has access to ETH, has eSIM identifier set + // eSIMWallet1 -> has funds access, has eSIM identifier set deviceWallet = MockDeviceWallet(payable(wallets[0].deviceWallet)); deviceWallet2 = MockDeviceWallet(payable(wallets[1].deviceWallet)); deviceWallet3 = MockDeviceWallet(payable(wallets[2].deviceWallet)); @@ -149,7 +149,7 @@ abstract contract DeviceWalletFixture is DeployerBase { eSIMWallet3 = MockESIMWallet(payable(wallets[1].eSIMWallet)); vm.startPrank(admin); - // eSIMWallet1 -> has access to ETH, has eSIM identifier set + // eSIMWallet1 -> has funds access, has eSIM identifier set registry.assignESIMIdentifier(address(eSIMWallet1), "ESIM_0_1"); registry.assignESIMIdentifier(address(eSIMWallet3), "ESIM_1_1"); vm.stopPrank(); @@ -161,11 +161,11 @@ abstract contract DeviceWalletFixture is DeployerBase { deviceWallet2.toggleAccessToFunds(address(eSIMWallet3), true); vm.startPrank(admin); - // eSIMWallet2 -> no access to ETH, no eSIM identifier set + // eSIMWallet2 -> no funds access, no eSIM identifier set address newESIMWallet = deviceWallet.deployESIMWallet(false, 919); vm.stopPrank(); - // eSIMWallet2 -> no access to ETH, no eSIM identifier set + // eSIMWallet2 -> no funds access, no eSIM identifier set eSIMWallet2 = MockESIMWallet(payable(newESIMWallet)); assertNotEq(address(deviceWallet), address(0), "deviceWallet address cannot be address(0)"); diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol index c374858c..d856dcb6 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol @@ -12,7 +12,7 @@ import {DeviceWalletFixture} from "test/foundry/unit-testing/device-wallet/base/ import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; import {MockReenteringERC20} from "test/utils/mocks/tokens/MockReenteringERC20.sol"; -/// @notice Buying a data bundle with an ERC-20, and getting a token balance back out again. +/// @notice Buying a data bundle with USDC (or any other acceptable stablecoin/ERC20), and getting a token balance back out again. contract ESIMWalletTokenPurchaseTest is DeviceWalletFixture { uint64 constant PRICE_CENTS = 1_250; // $12.50 diff --git a/test/utils/DeployerBase.sol b/test/utils/DeployerBase.sol index f3fb8839..09ea8fc3 100644 --- a/test/utils/DeployerBase.sol +++ b/test/utils/DeployerBase.sol @@ -246,7 +246,7 @@ contract DeployerBase is Test { } /// @notice One purchase the admin says was paid for outside the protocol - /// @dev `buyDataBundle` overwrites the settlement itself, so this shape suits both paths. + /// @dev `buyDataBundleWithToken` overwrites the settlement itself, so this shape suits both paths. function bundle(bytes32 _id, uint64 _priceUSDCents) internal pure returns (DataBundleDetails memory) { return DataBundleDetails({ id: _id, From b722f4f5225cc547c4deca7e35a027302107db53 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 19:50:30 +0530 Subject: [PATCH 58/75] Remove dead debug output and unused imports from the test harness 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. --- .../registry/LazyWalletRegistry.t.sol | 3 -- test/utils/DeployerBase.sol | 30 ------------------- test/utils/mocks/MockDeviceWallet.sol | 3 -- test/utils/mocks/MockDeviceWalletV2.sol | 3 -- test/utils/mocks/MockESIMWallet.sol | 3 -- test/utils/mocks/MockESIMWalletV2.sol | 3 -- test/utils/mocks/MockEntryPoint.sol | 4 --- test/utils/mocks/MockLazyWalletRegistry.sol | 3 -- test/utils/mocks/MockRegistry.sol | 3 -- 9 files changed, 55 deletions(-) diff --git a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol index 62a05e7a..c4e1a977 100644 --- a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol +++ b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol @@ -97,9 +97,6 @@ contract LazyWalletRegistryTest is DeployerBase { assertEq(storedData.length, 5, "DataBundleDetails array length should be 5"); string[] memory listOfESIMIdentifiers = lazyWalletRegistry.getESIMIdentifiersAssociatedWithDeviceIdentifier(customDeviceUniqueIdentifiers[0]); - for(uint256 i=0; i uint256) public nonces; - // Events for verification purposes in tests event MockHandleOpsCalled(); event MockHandleAggregatedOpsCalled(); @@ -91,7 +90,6 @@ contract MockEntryPoint is IEntryPoint, MockStakeManager, MockNonceManager { return ISenderCreator(address(0)); } - // Add mock implementations for required methods from inherited interfaces function incrementNonce(address user) external { nonces[user]++; } @@ -99,6 +97,4 @@ contract MockEntryPoint is IEntryPoint, MockStakeManager, MockNonceManager { function getNonce(address user) external view returns (uint256) { return nonces[user]; } - - // Additional methods to mock IStakeManager and INonceManager methods as needed } diff --git a/test/utils/mocks/MockLazyWalletRegistry.sol b/test/utils/mocks/MockLazyWalletRegistry.sol index 11ede41e..e82629c6 100644 --- a/test/utils/mocks/MockLazyWalletRegistry.sol +++ b/test/utils/mocks/MockLazyWalletRegistry.sol @@ -2,9 +2,6 @@ pragma solidity 0.8.36; -import "forge-std/Test.sol"; -import "forge-std/console.sol"; - import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; diff --git a/test/utils/mocks/MockRegistry.sol b/test/utils/mocks/MockRegistry.sol index ced8c980..77fedef9 100644 --- a/test/utils/mocks/MockRegistry.sol +++ b/test/utils/mocks/MockRegistry.sol @@ -2,9 +2,6 @@ pragma solidity 0.8.36; -import "forge-std/Test.sol"; -import "forge-std/console.sol"; - import "contracts/Registry.sol"; contract MockRegistry is Registry { From fe644a602e5d6297f68891c850fb7cc19d5c0219 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Sat, 29 Aug 2026 19:50:44 +0530 Subject: [PATCH 59/75] Tidy formatting and comment phrasing in a few mocks 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. --- contracts/device-wallet/DeviceWalletFactory.sol | 2 +- test/utils/mocks/MockDeviceWalletV2.sol | 2 +- test/utils/mocks/MockESIMWalletV2.sol | 2 +- test/utils/mocks/MockNonceManager.sol | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/contracts/device-wallet/DeviceWalletFactory.sol b/contracts/device-wallet/DeviceWalletFactory.sol index 8ac5bd04..a937f733 100644 --- a/contracts/device-wallet/DeviceWalletFactory.sol +++ b/contracts/device-wallet/DeviceWalletFactory.sol @@ -41,7 +41,7 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr /// @notice Contract the device wallets verify WebAuthn assertions through P256Verifier public verifier; - ///@notice Registry contract instance + /// @notice Registry contract instance Registry public registry; /// @notice eSIM wallet factory contract instance diff --git a/test/utils/mocks/MockDeviceWalletV2.sol b/test/utils/mocks/MockDeviceWalletV2.sol index 7cbe7970..1a04a392 100644 --- a/test/utils/mocks/MockDeviceWalletV2.sol +++ b/test/utils/mocks/MockDeviceWalletV2.sol @@ -15,7 +15,7 @@ contract MockDeviceWalletV2 is DeviceWallet { return owner; } - // Just to check upgradability of the contract + // Confirms an upgrade actually replaced the logic contract function addTwoNumbers(uint256 _a, uint256 _b) public pure returns (uint256) { return _a + _b; } diff --git a/test/utils/mocks/MockESIMWalletV2.sol b/test/utils/mocks/MockESIMWalletV2.sol index 2c99145f..cc31072e 100644 --- a/test/utils/mocks/MockESIMWalletV2.sol +++ b/test/utils/mocks/MockESIMWalletV2.sol @@ -10,7 +10,7 @@ contract MockESIMWalletV2 is ESIMWallet { return transactionHistory; } - // Just to check upgradability of the contract + // Confirms an upgrade actually replaced the logic contract function addTwoNumbers(uint256 _a, uint256 _b) public pure returns (uint256) { return _a + _b; } diff --git a/test/utils/mocks/MockNonceManager.sol b/test/utils/mocks/MockNonceManager.sol index 76f3680f..7c726d71 100644 --- a/test/utils/mocks/MockNonceManager.sol +++ b/test/utils/mocks/MockNonceManager.sol @@ -12,11 +12,11 @@ contract MockNonceManager is INonceManager { /// @param sender The account address /// @param key The high 192 bits of the nonce /// @return nonce The full nonce for the next user operation from this sender - function getNonce(address sender, uint192 key) - external - view - override - returns (uint256 nonce) + function getNonce(address sender, uint192 key) + external + view + override + returns (uint256 nonce) { return nonces[sender][key]; } From f0f17bb6932060b01ab88fc073480c9c002b573a Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 31 Aug 2026 13:45:50 +0530 Subject: [PATCH 60/75] Harden the two payment paths against replay, front-running and short 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. --- .gas-snapshot | 482 +++++++++--------- contracts/Registry.sol | 44 +- contracts/RegistryHelper.sol | 3 + contracts/device-wallet/DeviceWallet.sol | 7 + contracts/esim-wallet/ESIMWallet.sol | 25 +- contracts/esim-wallet/ESIMWalletFactory.sol | 6 + contracts/payments/PaymentAdapter.sol | 22 +- snapshots/ESIMWallet.Operations.json | 4 +- snapshots/PaymentAdapter.Operations.json | 6 +- snapshots/ProtocolAdmin.Operations.json | 2 +- snapshots/Registry.Operations.json | 6 +- .../invariant-testing/PaymentInvariants.t.sol | 20 +- .../handler/PaymentHandler.sol | 34 +- .../esim-wallet/ESIMWalletTokenPurchase.t.sol | 53 +- .../payments/PaymentAdapterSettle.t.sol | 15 + .../registry/SettledPurchase.t.sol | 144 +++++- 16 files changed, 581 insertions(+), 292 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index da246058..87c8c98c 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -15,27 +15,27 @@ Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 10 Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966732) Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970416) Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964520) -AdminRotationTest:test_acceptAdminUpdate() (gas: 47187) -AdminRotationTest:test_acceptAdminUpdate_afterRevoke() (gas: 48918) -AdminRotationTest:test_acceptAdminUpdate_clearsASuspension() (gas: 49065) -AdminRotationTest:test_acceptAdminUpdate_currentAdmin() (gas: 42653) -AdminRotationTest:test_acceptAdminUpdate_reachesEveryReader() (gas: 68118) +AdminRotationTest:test_acceptAdminUpdate() (gas: 47231) +AdminRotationTest:test_acceptAdminUpdate_afterRevoke() (gas: 49006) +AdminRotationTest:test_acceptAdminUpdate_clearsASuspension() (gas: 49109) +AdminRotationTest:test_acceptAdminUpdate_currentAdmin() (gas: 42697) +AdminRotationTest:test_acceptAdminUpdate_reachesEveryReader() (gas: 68162) AdminRotationTest:test_acceptAdminUpdate_withoutRequest() (gas: 19779) -AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175693) +AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175748) AdminRotationTest:test_disableAdmin_endsThePauseLoopAgainstACompromisedKey() (gas: 64565) AdminRotationTest:test_disableAdmin_rejectsARepeat() (gas: 29532) AdminRotationTest:test_disableAdmin_rejectsAnyoneButTheOwner() (gas: 38707) -AdminRotationTest:test_enableAdmin_leavesAnOutstandingHandoverAlone() (gas: 283045) +AdminRotationTest:test_enableAdmin_leavesAnOutstandingHandoverAlone() (gas: 283089) AdminRotationTest:test_enableAdmin_rejectsAnyoneButTheOwner() (gas: 39370) AdminRotationTest:test_enableAdmin_rejectsWhenNotDisabled() (gas: 21074) AdminRotationTest:test_enableAdmin_restoresTheGates() (gas: 282241) -AdminRotationTest:test_requestAdminUpdate() (gas: 37239) -AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentLiftsASuspension() (gas: 36137) -AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 285017) -AdminRotationTest:test_requestAdminUpdate_rejectsAnyoneButTheOwner() (gas: 45957) -AdminRotationTest:test_requestAdminUpdate_revoke() (gas: 43550) -AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1222055) -AdminRotationTest:test_requestAdminUpdate_zeroAddress() (gas: 19658) +AdminRotationTest:test_requestAdminUpdate() (gas: 37283) +AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentLiftsASuspension() (gas: 36181) +AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 285105) +AdminRotationTest:test_requestAdminUpdate_rejectsAnyoneButTheOwner() (gas: 46045) +AdminRotationTest:test_requestAdminUpdate_revoke() (gas: 43638) +AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1222154) +AdminRotationTest:test_requestAdminUpdate_zeroAddress() (gas: 19702) AdminStorageLayoutTest:test_layout_aDoneOperationNeverReadsReady() (gas: 8064) AdminStorageLayoutTest:test_layout_accessControlRolesReadSlotZero() (gas: 16215) AdminStorageLayoutTest:test_layout_minDelayReadsSlotTwo() (gas: 7179) @@ -44,52 +44,52 @@ AdminStorageLayoutTest:test_layout_readinessComesOnlyFromTheTimestamp() (gas: 90 AdminStorageLayoutTest:test_layout_theContractAddsNoStorageOfItsOwn() (gas: 7864) AdminStorageLayoutTest:test_layout_theDelayFloorIsNotInStorage() (gas: 8697) AdminStorageLayoutTest:test_layout_theGuardianRoleLivesInTheSameMappingAsTheRest() (gas: 17469) -AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2573241) -AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 4252888) +AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2615351) +AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 4309460) AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3496626) -AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2644859) -AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12932702) +AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2686969) +AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12989275) AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3658751) -AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 12981881) -AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5587964) -AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 4286072) -AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 4225962) -AdminUpgradesTest:test_upgrade_rejectsAProposerActingDirectly() (gas: 4225984) -AdminUpgradesTest:test_upgrade_rejectsTheAccountThatUsedToOwnTheProxy() (gas: 4226331) +AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 13038454) +AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5630047) +AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 4342644) +AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 4282534) +AdminUpgradesTest:test_upgrade_rejectsAProposerActingDirectly() (gas: 4282556) +AdminUpgradesTest:test_upgrade_rejectsTheAccountThatUsedToOwnTheProxy() (gas: 4282903) Deployer:test_deviceWalletFactory_ownable2Step() (gas: 44710) Deployer:test_deviceWalletFactory_setUp() (gas: 53722) Deployer:test_eSIMWalletFactory_ownable2Step() (gas: 44690) Deployer:test_eSIMWalletFactory_setUp() (gas: 27320) Deployer:test_lazyWalletRegistry_setUp() (gas: 21019) Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45819) -Deployer:test_registry_ownable2step() (gas: 47346) -Deployer:test_registry_setUp() (gas: 54915) -DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3745718) -DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3393661) -DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3449525) -DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395175) -DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399404) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3431405) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3400912) -DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388363) -DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3403929) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471083) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3393973) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPull() (gas: 3774443) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390057) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395156) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3457768) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3396691) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3418333) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403384) -DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3745124) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4551264) +Deployer:test_registry_ownable2step() (gas: 47416) +Deployer:test_registry_setUp() (gas: 54959) +DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3752809) +DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3393881) +DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3449877) +DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395395) +DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399624) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3431669) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3401176) +DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388583) +DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3404193) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471347) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3394237) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPull() (gas: 3774663) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390277) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395376) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3457988) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3396911) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3418597) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403648) +DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3752215) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4551484) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936843) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960853) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977499) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931871) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930389) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946408) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946379) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_withoutAdminOrRegistry() (gas: 76765) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959686) DeviceWalletFactoryConfigTest:test_addRegistryAddress_onlyOnce() (gas: 22234) @@ -110,7 +110,7 @@ DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredO DeviceWalletFactoryGuardsTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 3556215) DeviceWalletFactoryGuardsTest:test_createAccount_rejectsAnEmptyDeviceIdentifier() (gas: 19711) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsADepositAboveTheETHSent() (gas: 53937) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938226) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938219) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936150) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortDepositArray() (gas: 58027) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortKeyArray() (gas: 52070) @@ -121,7 +121,7 @@ DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefu DeviceWalletFactoryGuardsTest:test_eSIMWalletAdmin_isEmptyUntilTheRegistryIsAdded() (gas: 3565235) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroESIMWalletFactory() (gas: 3141656) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 3141332) -DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3142144) +DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3142162) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroVerifier() (gas: 3141855) DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539403) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412211) @@ -144,41 +144,41 @@ DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOw DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnOffCurveOwnerKey() (gas: 19826) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnZeroOwnerKey() (gas: 19342) DeviceWalletFundsAccessTest:test_aFreshESIMWalletStartsWithNoFundsAccess() (gas: 1270323) -DeviceWalletFundsAccessTest:test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() (gas: 3393673) -DeviceWalletFundsAccessTest:test_deployESIMWallet_cannotGrantFundsAccess() (gas: 3685306) -DeviceWalletFundsAccessTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402168) -DeviceWalletFundsAccessTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3437784) -DeviceWalletFundsAccessTest:test_getVaultAddress() (gas: 3395658) -DeviceWalletFundsAccessTest:test_getVaultAddress_followsTheRegistry() (gas: 3418337) -DeviceWalletFundsAccessTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4242702) -DeviceWalletFundsAccessTest:test_theOwnerGrantsAfterBinding() (gas: 4012726) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() (gas: 3659153) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() (gas: 3580830) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() (gas: 3605526) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_unauthorised() (gas: 3396388) +DeviceWalletFundsAccessTest:test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() (gas: 3393893) +DeviceWalletFundsAccessTest:test_deployESIMWallet_cannotGrantFundsAccess() (gas: 3685526) +DeviceWalletFundsAccessTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402388) +DeviceWalletFundsAccessTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3438004) +DeviceWalletFundsAccessTest:test_getVaultAddress() (gas: 3395922) +DeviceWalletFundsAccessTest:test_getVaultAddress_followsTheRegistry() (gas: 3418733) +DeviceWalletFundsAccessTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4246822) +DeviceWalletFundsAccessTest:test_theOwnerGrantsAfterBinding() (gas: 4019737) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() (gas: 3664164) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() (gas: 3584942) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() (gas: 3611716) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_unauthorised() (gas: 3396608) DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961341) DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954195) DeviceWalletGuardsTest:test_init_rejectsAZeroESIMWalletFactory() (gas: 117237) DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117832) DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115147) -DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972371) +DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972415) DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963517) DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957100) -DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960915) -DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3411799) -DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4422748) -DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3427664) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3401970) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3412835) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402013) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3401756) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402081) -DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128823) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458865) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149481) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132066) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120477) -DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3394604) +DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960889) +DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3412019) +DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4423012) +DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3427884) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3402190) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3413055) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402254) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3401997) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402322) +DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128867) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458909) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149525) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132110) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120521) +DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3394824) DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion() (gas: 30674) DeviceWalletTokensTest:test_pullToken_emitsTokenSent() (gas: 77477) DeviceWalletTokensTest:test_pullToken_movesATokenThatReturnsNothing() (gas: 327509) @@ -201,8 +201,8 @@ ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother( ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() (gas: 331736) ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617297) ESIMWalletFactoryTest:test_deployESIMWallet_unauthorised() (gas: 35389) -ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065366) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3584883) +ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065372) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3626995) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_rejectsTheZeroAddress() (gas: 18268) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_unauthorised() (gas: 21012) ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105666) @@ -210,77 +210,78 @@ ESIMWalletGuardsTest:test_initialize_rejectsAZeroFactory() (gas: 104832) ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979538) ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 977993) ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039218) -ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1231946) -ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2396324) -ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2460907) -ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2429995) -ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2401114) -ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411824) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395867) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397608) -ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510277) -ESIMWalletTest:test_buyDataBundleWithToken_followsTheRotatedAdmin() (gas: 1768374) -ESIMWalletTest:test_buyDataBundleWithToken_recordsTheHistoryBeforeAnythingMoves() (gas: 3016767) -ESIMWalletTest:test_buyDataBundleWithToken_theWalletCapOverridesTheRegistryDefault() (gas: 1788275) -ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2513906) -ESIMWalletTest:test_owner() (gas: 1505476) -ESIMWalletTest:test_populateHistory() (gas: 1795415) -ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906640) -ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844193) -ESIMWalletTest:test_requestTransferOwnership() (gas: 2405037) -ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425360) -ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3245219) -ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263835) -ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423425) -ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434675) -ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517074) -ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510440) -ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517113) -ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2476297) -ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508407) -ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533352) -ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511235) -ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508158) -ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510406) -ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1510932) -ESIMWalletTest:test_transferOwnership() (gas: 1508324) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 276186) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 262954) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 272718) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 269735) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 284520) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 276543) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 269984) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() (gas: 337755) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1485183) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86755) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 104988) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 146417) +ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1237924) +ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2396412) +ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2461083) +ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2430083) +ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2401202) +ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411912) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395955) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397696) +ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510365) +ESIMWalletTest:test_buyDataBundleWithToken_followsTheRotatedAdmin() (gas: 1775289) +ESIMWalletTest:test_buyDataBundleWithToken_recordsTheHistoryBeforeAnythingMoves() (gas: 3023686) +ESIMWalletTest:test_buyDataBundleWithToken_theWalletCapOverridesTheRegistryDefault() (gas: 1795147) +ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2513994) +ESIMWalletTest:test_owner() (gas: 1505564) +ESIMWalletTest:test_populateHistory() (gas: 1795503) +ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906728) +ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844281) +ESIMWalletTest:test_requestTransferOwnership() (gas: 2405125) +ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425492) +ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3245307) +ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263967) +ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423513) +ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434851) +ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517162) +ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510528) +ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517201) +ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2476473) +ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508495) +ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533440) +ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511323) +ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508246) +ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510494) +ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511020) +ESIMWalletTest:test_transferOwnership() (gas: 1508412) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_aDifferentWalletCanUseTheSameRawReference() (gas: 501771) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 295991) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 282759) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 292501) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 289540) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 304362) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 296326) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 289767) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1504175) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86777) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 105010) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsCleanlyOnAFeeOnTransferAsset() (gas: 1107688) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 163323) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 75609) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 128803) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 287186) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79615) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79238) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 218951) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 124938) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 130525) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88409) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 265571) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 267308) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 145687) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 312578) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79637) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79260) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 235865) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 132844) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 147409) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88431) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 284541) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 286300) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() (gas: 53602) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsATokenTheProtocolNeverPricedIn() (gas: 687121) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsTheBalance() (gas: 53836) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsForAnyoneElse() (gas: 58143) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnTheZeroTokenAddress() (gas: 22347) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnZeroAmount() (gas: 23994) -GuardianPowersTest:test_disableAdminInstantly_cannotChooseAReplacement() (gas: 36672) +GuardianPowersTest:test_disableAdminInstantly_cannotChooseAReplacement() (gas: 36716) GuardianPowersTest:test_disableAdminInstantly_cannotReinstate() (gas: 95421) GuardianPowersTest:test_disableAdminInstantly_emitsTheSuspension() (gas: 39312) GuardianPowersTest:test_disableAdminInstantly_endsThePauseLoop() (gas: 64087) GuardianPowersTest:test_disableAdminInstantly_rejectsAnAccountWithoutTheGuardianRole() (gas: 35593) GuardianPowersTest:test_disableAdminInstantly_suspendsWithoutWaiting() (gas: 43602) GuardianPowersTest:test_disableAndNominate_rejectsEveryCallerButTheTimelock() (gas: 34230) -GuardianPowersTest:test_disableAndNominate_stripsAndNominatesOnceTheDelayIsServed() (gas: 101900) +GuardianPowersTest:test_disableAndNominate_stripsAndNominatesOnceTheDelayIsServed() (gas: 101944) GuardianPowersTest:test_execute_closesToOutsidersOnceOpenExecutionIsRevoked() (gas: 110078) GuardianPowersTest:test_guardian_canStillExecuteOnceOpenExecutionIsClosed() (gas: 134114) GuardianPowersTest:test_guardian_cannotCancel() (gas: 55808) @@ -319,11 +320,11 @@ IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: ImplementationLocksTest:test_DeviceWalletFactory_implementationCannotBeInitialized() (gas: 3065558) ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3357746) ImplementationLocksTest:test_ESIMWalletFactory_implementationCannotBeInitialized() (gas: 1991966) -ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2457348) +ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2499458) ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3591688) -ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 4224587) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2178245) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168316) +ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 4281181) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2178302) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168367) LazySaltCollisionTest:test_getCounterFactualAddress_matchesTheDeployedAddress() (gas: 325495) LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortDataBundleArray() (gas: 35457) LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortESIMIdentifierArray() (gas: 34648) @@ -343,33 +344,33 @@ LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_reje LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfTheSameLength() (gas: 198014) LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsSwitchingToTheSameDevice() (gas: 197302) LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenTheIdentifierIsNotFound() (gas: 84432) -LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3229497) +LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3229590) LazyWalletRegistryTest:test_batchPopulateHistory_acceptsAnIdentifierAtTheLimit() (gas: 204715) -LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4422448) -LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6268369) -LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2943885) -LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3287905) -LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3321324) +LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4422634) +LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6268537) +LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2940807) +LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3288078) +LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3321417) LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1607341) LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongDeviceIdentifier() (gas: 46482) LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongESIMIdentifier() (gas: 60738) -LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 235233) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6230420) +LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 235103) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6230718) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1673912) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6094908) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6095001) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2203528) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27260955) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12140539) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6062449) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6062542) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutAdmin() (gas: 44658) LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutESIMIdentifier() (gas: 51638) -LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6077727) +LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6077820) LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3531113) LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_refusesADeviceWithNoFirstBatch() (gas: 461277) LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2373493) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6232019) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3234439) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4427566) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6232317) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3234532) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4427752) LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_unregisteredIdentfier() (gas: 17775) LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2032818) LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2519811) @@ -381,30 +382,30 @@ LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopie LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6764527) LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2771899) LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2047111) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3333481) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3333574) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1602422) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6255952) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6253035) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6256250) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6253333) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_unregistered() (gas: 37057) LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_withoutAdmin() (gas: 32465) NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157788) NemesisIdentifierSquatPoC:test_NM007_extraESIMWalletsGiveNoRouteIn() (gas: 361617) NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143208) -NemesisIdentifierSquatPoC:test_NM007_theOldClaimSelectorIsUnreachable() (gas: 13826) +NemesisIdentifierSquatPoC:test_NM007_theOldClaimSelectorIsUnreachable() (gas: 13870) NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55549) -NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 49456) +NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 49500) NemesisPausePoC:test_NM005_suspendingTheAdminRemovesThePauseLever() (gas: 49382) NemesisPausePoC:test_NM005_theProtocolStaysUnpausedWhileThePauseIsUnreachable() (gas: 31162) OwnershipHandoverTest:test_accept_doesNothingForAnEmptyBatch() (gas: 2491228) -OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2674937) -OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671837) -OwnershipHandoverTest:test_accept_rejectsATargetThatOfferedNothing() (gas: 2500053) -OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647631) +OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2675003) +OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671903) +OwnershipHandoverTest:test_accept_rejectsATargetThatOfferedNothing() (gas: 2500075) +OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647697) OwnershipHandoverTest:test_handover_cannotEndWithNoOwnerAtAll() (gas: 74874) -OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292387) -OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2699318) -OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218369) -OwnershipHandoverTest:test_handover_rejectsATransferProposedByAnyoneButTheAdminContract() (gas: 32910) +OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292519) +OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2699384) +OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218404) +OwnershipHandoverTest:test_handover_rejectsATransferProposedByAnyoneButTheAdminContract() (gas: 32998) OwnershipHandoverTest:test_pause_canAlsoBeReleasedThroughTheDelayIfNobodyIsInAHurry() (gas: 82980) OwnershipHandoverTest:test_pause_staysWithTheAdminKeyWhileTheReleaseMovesToTheContract() (gas: 45797) P256VerificationTest:test_verifySignature_acceptsARealAssertion() (gas: 32871) @@ -412,25 +413,26 @@ P256VerificationTest:test_verifySignature_rejectsAnAssertionMadeForAnotherChalle P256VerificationTest:test_verify_bothPathsAcceptAValidSignature() (gas: 236037) P256VerificationTest:test_verify_bothPathsRejectACorruptedSignature() (gas: 239570) P256VerificationTest:test_verify_theFallbackCostsFarMoreThanThePrecompile() (gas: 239733) -PaymentAdapterSettleTest:test_settle_aCallerNamingMoreThanItSentTakesTheDifference() (gas: 128849) -PaymentAdapterSettleTest:test_settle_emitsPaymentSettled() (gas: 104066) -PaymentAdapterSettleTest:test_settle_handlesTheSmallestPrice() (gas: 733077) -PaymentAdapterSettleTest:test_settle_leavesATokenSentHereByMistake() (gas: 123503) -PaymentAdapterSettleTest:test_settle_movesATokenThatReturnsNothing() (gas: 341309) -PaymentAdapterSettleTest:test_settle_paysTheVaultCurrentlySet() (gas: 118467) -PaymentAdapterSettleTest:test_settle_paysTheVaultTheQuotedAmount() (gas: 103719) -PaymentAdapterSettleTest:test_settle_refundsWhatThePriceDidNotNeed() (gas: 128419) -PaymentAdapterSettleTest:test_settle_refusesAReentrantCallFromTheToken() (gas: 1277381) +PaymentAdapterSettleTest:test_settle_aCallerNamingMoreThanItSentTakesTheDifference() (gas: 128915) +PaymentAdapterSettleTest:test_settle_emitsPaymentSettled() (gas: 104132) +PaymentAdapterSettleTest:test_settle_handlesTheSmallestPrice() (gas: 733143) +PaymentAdapterSettleTest:test_settle_leavesATokenSentHereByMistake() (gas: 123569) +PaymentAdapterSettleTest:test_settle_matchesQuoteForTheSameInputsWithinOneTransaction() (gas: 100404) +PaymentAdapterSettleTest:test_settle_movesATokenThatReturnsNothing() (gas: 341375) +PaymentAdapterSettleTest:test_settle_paysTheVaultCurrentlySet() (gas: 118511) +PaymentAdapterSettleTest:test_settle_paysTheVaultTheQuotedAmount() (gas: 103785) +PaymentAdapterSettleTest:test_settle_refundsWhatThePriceDidNotNeed() (gas: 128463) +PaymentAdapterSettleTest:test_settle_refusesAReentrantCallFromTheToken() (gas: 1277457) PaymentAdapterSettleTest:test_settle_revertsForACallerTheRegistryDoesNotKnow() (gas: 85771) -PaymentAdapterSettleTest:test_settle_revertsForADeviceWallet() (gas: 86479) +PaymentAdapterSettleTest:test_settle_revertsForADeviceWallet() (gas: 86501) PaymentAdapterSettleTest:test_settle_revertsForAFeeOnTransferToken() (gas: 853158) PaymentAdapterSettleTest:test_settle_revertsForAWithdrawnAsset() (gas: 51510) -PaymentAdapterSettleTest:test_settle_revertsForAnAssetThatNeedsASwap() (gas: 675967) -PaymentAdapterSettleTest:test_settle_revertsForAnUnregisteredSymbol() (gas: 36700) -PaymentAdapterSettleTest:test_settle_revertsForFiat() (gas: 36722) -PaymentAdapterSettleTest:test_settle_revertsOnAZeroPrice() (gas: 34187) -PaymentAdapterSettleTest:test_settle_revertsOnAZeroRefundAddress() (gas: 34675) -PaymentAdapterSettleTest:test_settle_revertsWhenTheCallerHasNotFundedIt() (gas: 43151) +PaymentAdapterSettleTest:test_settle_revertsForAnAssetThatNeedsASwap() (gas: 675989) +PaymentAdapterSettleTest:test_settle_revertsForAnUnregisteredSymbol() (gas: 36722) +PaymentAdapterSettleTest:test_settle_revertsForFiat() (gas: 36744) +PaymentAdapterSettleTest:test_settle_revertsOnAZeroPrice() (gas: 34209) +PaymentAdapterSettleTest:test_settle_revertsOnAZeroRefundAddress() (gas: 34697) +PaymentAdapterSettleTest:test_settle_revertsWhenTheCallerHasNotFundedIt() (gas: 43173) PaymentAdapterSettleTest:test_settle_revertsWhenThePriceIsAboveWhatTheCallerWillSpend() (gas: 88627) PaymentAdapterTest:test_consumePaymentReference_marksTheReference() (gas: 46315) PaymentAdapterTest:test_consumePaymentReference_rejectsAReferenceAlreadySpent() (gas: 45293) @@ -439,9 +441,9 @@ PaymentAdapterTest:test_consumePaymentReference_rejectsAnyoneButTheRegistry() (g PaymentAdapterTest:test_consumePaymentReference_rejectsTheOwner() (gas: 18214) PaymentAdapterTest:test_implementationCannotBeInitialized() (gas: 1660321) PaymentAdapterTest:test_initialize_cannotRunTwice() (gas: 23543) -PaymentAdapterTest:test_initialize_rejectsAZeroRegistry() (gas: 1734111) -PaymentAdapterTest:test_initialize_rejectsAZeroSettlementToken() (gas: 1734218) -PaymentAdapterTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 1734828) +PaymentAdapterTest:test_initialize_rejectsAZeroRegistry() (gas: 1734126) +PaymentAdapterTest:test_initialize_rejectsAZeroSettlementToken() (gas: 1734236) +PaymentAdapterTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 1734837) PaymentAdapterTest:test_initialize_storesTheAddressesItWasGiven() (gas: 25897) PaymentAdapterTest:test_quote_convertsCentsToSixDecimals() (gas: 14487) PaymentAdapterTest:test_quote_convertsCentsToTwoDecimals() (gas: 13295) @@ -451,7 +453,7 @@ PaymentAdapterTest:test_quote_rejectsAWithdrawnCurrency() (gas: 31846) PaymentAdapterTest:test_quote_rejectsAnUnregisteredCurrency() (gas: 16868) PaymentAdapterTest:test_quote_returnsZeroForAZeroPrice() (gas: 13799) PaymentAdapterTest:test_registerAsset_rejectsASymbolAlreadyRegistered() (gas: 24147) -PaymentAdapterTest:test_registerAsset_rejectsAnEmptySymbol() (gas: 22630) +PaymentAdapterTest:test_registerAsset_rejectsAnEmptySymbol() (gas: 22642) PaymentAdapterTest:test_registerAsset_rejectsFewerThanTwoDecimals() (gas: 24228) PaymentAdapterTest:test_registerAsset_rejectsTheAdmin() (gas: 21684) PaymentAdapterTest:test_registerAsset_rejectsZeroDecimals() (gas: 24042) @@ -460,7 +462,7 @@ PaymentAdapterTest:test_renounceOwnership_alwaysReverts() (gas: 16668) PaymentAdapterTest:test_resolveAsset_rejectsAnUnregisteredCurrency() (gas: 17574) PaymentAdapterTest:test_resolveAsset_returnsACurrencyThatNeedsARate() (gas: 13942) PaymentAdapterTest:test_resolveAsset_returnsTheStoredEntry() (gas: 16792) -PaymentAdapterTest:test_updateAsset_rejectsAnUnregisteredSymbol() (gas: 22011) +PaymentAdapterTest:test_updateAsset_rejectsAnUnregisteredSymbol() (gas: 22029) PaymentAdapterTest:test_updateAsset_rejectsFewerThanTwoDecimals() (gas: 24880) PaymentAdapterTest:test_updateAsset_rejectsTheAdmin() (gas: 22592) PaymentAdapterTest:test_updateAsset_rewritesTheEntry() (gas: 31328) @@ -468,10 +470,10 @@ PaymentAdapterTest:test_updateAsset_withdrawsACurrency() (gas: 34405) PaymentAdapterTest:test_upgradeManager_matchesTheOwner() (gas: 13961) PaymentAdapterTest:test_upgrade_keepsTheSpentReferences() (gas: 1700526) PaymentAdapterTest:test_upgrade_rejectsAnyoneButTheOwner() (gas: 1664768) -PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011356) -PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1207654) +PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011334) +PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1213632) PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982762) -PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1135364) +PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1132596) PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814265) PriceCapSlotMigrationTest:test_migration_theOldCeilingSlotIsReadAsTheAdapter() (gas: 11352) ProtocolAdminTest:test_cancel_leavesThePayloadSchedulableAgain() (gas: 96360) @@ -508,41 +510,41 @@ ProtocolAdminTest:test_updateDelay_cannotBringTheDelayBelowTheFloor() (gas: 6904 ProtocolAdminTest:test_updateDelay_rejectsACallerThatIsNotTheContractItself() (gas: 12158) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheAdmin() (gas: 25405) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheZeroAddress() (gas: 18743) -RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295436) +RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295407) RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272251) RegistryGuardsTest:test_bindESIMWallet_rejectsACallerThatIsNotADeviceWallet() (gas: 20718) RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297677) RegistryGuardsTest:test_deployLazyWallet_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 30823) RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927110) RegistryGuardsTest:test_deployMoreLazyESIMWallets_rejectsACallerOtherThanTheLazyRegistry() (gas: 23540) -RegistryGuardsTest:test_initialize_rejectsAZeroAdmin() (gas: 4345432) -RegistryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 4344569) -RegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 4345457) -RegistryGuardsTest:test_initialize_rejectsAZeroVault() (gas: 4346491) +RegistryGuardsTest:test_initialize_rejectsAZeroAdmin() (gas: 4402027) +RegistryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 4401164) +RegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 4402052) +RegistryGuardsTest:test_initialize_rejectsAZeroVault() (gas: 4403086) RegistryGuardsTest:test_populateLazyHistory_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 20927) RegistryGuardsTest:test_populateLazyHistory_rejectsAnAddressThatIsNotAProtocolESIMWallet() (gas: 23245) RegistryGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsACallerThatIsNotADeviceWallet() (gas: 21630) RegistryGuardsTest:test_updateDeviceWalletInfo_rejectsACallerOtherThanTheFactory() (gas: 30094) -RegistryGuardsTest:test_updateDeviceWalletOwnerKey_rejectsACallerThatIsNotADeviceWallet() (gas: 23398) +RegistryGuardsTest:test_updateDeviceWalletOwnerKey_rejectsACallerThatIsNotADeviceWallet() (gas: 23371) RegistryInitializeTest:test_initialize_recordsBothFactories() (gas: 20817) RegistryInitializeTest:test_initialize_recordsThePriceCap() (gas: 16094) -RegistryInitializeTest:test_initialize_revertsWhenDeviceWalletFactoryIsZero() (gas: 4344836) -RegistryInitializeTest:test_initialize_revertsWhenESIMWalletFactoryIsZero() (gas: 4344569) -RegistryInitializeTest:test_initialize_revertsWhenPriceCapIsZero() (gas: 4346107) -RegistryInitializeTest:test_initialize_revertsWhenVaultIsZero() (gas: 4346706) +RegistryInitializeTest:test_initialize_revertsWhenDeviceWalletFactoryIsZero() (gas: 4401431) +RegistryInitializeTest:test_initialize_revertsWhenESIMWalletFactoryIsZero() (gas: 4401164) +RegistryInitializeTest:test_initialize_revertsWhenPriceCapIsZero() (gas: 4402702) +RegistryInitializeTest:test_initialize_revertsWhenVaultIsZero() (gas: 4403301) RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1771762) -RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922374) +RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922418) RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770299) RegistryTest:test_pause_onlyTheAdminCanTripIt() (gas: 47066) -RegistryTest:test_pause_survivesAnAdminRotation() (gas: 55219) +RegistryTest:test_pause_survivesAnAdminRotation() (gas: 55263) RegistryTest:test_requireNotPaused_revertsOnlyWhilePaused() (gas: 28433) RegistryTest:test_setDefaultPriceCapUSDCents_rejectsTheAdmin() (gas: 38373) RegistryTest:test_setDefaultPriceCapUSDCents_rejectsZero() (gas: 17849) RegistryTest:test_unpause_onlyTheOwnerCanReleaseIt() (gas: 36448) -RegistryTest:test_updateVaultAddress() (gas: 31024) -RegistryTest:test_updateVaultAddress_rejectsTheAdmin() (gas: 40101) +RegistryTest:test_updateVaultAddress() (gas: 31068) +RegistryTest:test_updateVaultAddress_rejectsTheAdmin() (gas: 40145) RegistryTest:test_updateVaultAddress_rejectsTheCurrentAddress() (gas: 23282) -RegistryTest:test_updateVaultAddress_rejectsTheZeroAddress() (gas: 26180) +RegistryTest:test_updateVaultAddress_rejectsTheZeroAddress() (gas: 26224) RenounceOwnershipTest:test_renounceOwnership_revertsOnDeviceWalletFactory() (gas: 30236) RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletFactory() (gas: 29394) RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 924007) @@ -561,48 +563,52 @@ RoleRecoveryTest:test_recovery_aKeyholderCanStepDownWithoutWaitingForAnyone() (g RoleRecoveryTest:test_recovery_addingTheCancelPowerBackTakesTheFullDelay() (gas: 93088) RoleRecoveryTest:test_recovery_anInstantRevocationBeatsAScheduledGrant() (gas: 94377) RoleRecoveryTest:test_recovery_hasNoRouteAtAllWithASingleProposer() (gas: 2448798) -RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 145205) +RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 145227) RoleRecoveryTest:test_recovery_nobodyCanStepDownOnAnotherAccountsBehalf() (gas: 19003) RoleRecoveryTest:test_revokeRole_evictsAGuardianCompletelyInOneBatch() (gas: 92280) RoleRecoveryTest:test_revokeRole_leavesAnEvictedGuardianAbleToExecute() (gas: 76667) -SettledPurchaseTest:test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() (gas: 1170914) -SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19347) -SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1131638) -SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6295030) -SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1111760) -SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1076273) -SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1131224) -SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1127620) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002213) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1056729) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1194357) -SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1118274) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037673) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002414) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1002969) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 1001387) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021228) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49208) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005021) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6083312) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009759) -SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1106399) -SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24223) -SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20802) -SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20532) -SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29772) +SettledPurchaseTest:test_buyDataBundleWithToken_acceptsOnceHistoryIsCopied() (gas: 6412579) +SettledPurchaseTest:test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() (gas: 1171827) +SettledPurchaseTest:test_buyDataBundleWithToken_rejectsWhileHistoryIsOutstanding() (gas: 6169097) +SettledPurchaseTest:test_consumePaymentReference_anUnrelatedWalletCannotBlockAnothersSettlement() (gas: 2159021) +SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19369) +SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1128978) +SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6292392) +SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1109145) +SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1073678) +SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1128586) +SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1124960) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002343) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1054134) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1197414) +SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1114520) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037795) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002544) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1003143) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 998190) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021416) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49252) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005239) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6083494) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009977) +SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1103030) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24311) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20846) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20664) +SettledPurchaseTest:test_setPaymentAdapter_rotationDoesNotReviveASpentReference() (gas: 2993580) +SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29838) StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21373) StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544338) StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16047) StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 855086) StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34461) StorageLayoutTest:test_layout_paymentAdapterSlotsAreUnchanged() (gas: 18652) -StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 57163) -UpgradeAuthorityTest:test_upgradeManager_acceptsAContractAsTheUpgradeAuthority() (gas: 4404048) +StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 57251) +UpgradeAuthorityTest:test_upgradeManager_acceptsAContractAsTheUpgradeAuthority() (gas: 4460664) UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWalletRegistry() (gas: 38767) -UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39810) +UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39845) UpgradeAuthorityTest:test_upgradeManager_matchesTheOwner() (gas: 29385) -UpgradeAuthorityTest:test_upgradeManager_theRetiredOwnerCannotUpgrade() (gas: 4245476) +UpgradeAuthorityTest:test_upgradeManager_theRetiredOwnerCannotUpgrade() (gas: 4302092) UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975868) UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967360) UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976381) diff --git a/contracts/Registry.sol b/contracts/Registry.sol index cb7d993d..b5748d69 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -102,6 +102,14 @@ contract Registry is /// registry. address public paymentAdapter; + /// @notice Payment references already spent, scoped per eSIM wallet + /// @dev Held here rather than on the payment adapter, so replay protection survives an adapter + /// rotation through `setPaymentAdapter` instead of resetting with it. Keyed by + /// `keccak256(abi.encode(eSIMWallet, paymentReference))` rather than by the reference + /// alone, so one wallet spending a reference cannot burn it for an unrelated wallet's + /// pending settlement. + mapping(bytes32 scopedReference => bool used) public usedPaymentReferences; + /// @notice Restricts a call to an eSIM wallet this registry has recorded modifier onlyESIMWallet() { if(isESIMWalletValid[msg.sender] == address(0)) { @@ -373,11 +381,11 @@ contract Registry is // --------------------------------------------------------------------------------------------- /// @notice Spends a payment reference for an eSIM wallet paying with USDC (or any other acceptable stablecoin/ERC20) - /// @dev The adapter only accepts this from the registry, so the wallet has to come through - /// here. One reference then cannot be spent once on each path. + /// @dev Scoped to `msg.sender`, so this and `recordSettledPurchase` cannot spend the same + /// reference once on each for the same wallet. /// @param _paymentReference Hash tying the purchase to the offchain order behind it function consumePaymentReference(bytes32 _paymentReference) external onlyESIMWallet { - _consumePaymentReference(_paymentReference); + _consumePaymentReference(msg.sender, _paymentReference); } /// @notice Records a data bundle paid for through an external wallet or a card @@ -413,7 +421,7 @@ contract Registry is _requireLazyHistoryCopied(_eSIMWallet); Asset memory asset = PaymentAdapter(_requirePaymentAdapter()).resolveAsset(_asset); - _consumePaymentReference(_paymentReference); + _consumePaymentReference(_eSIMWallet, _paymentReference); ESIMWallet(payable(_eSIMWallet)).recordSettledPurchase(_dataBundleDetail); @@ -432,7 +440,16 @@ contract Registry is /// @notice Refuses a new entry while older history is still waiting to be copied in /// @dev The new entry would land first and the older ones append after it, leaving the history /// out of order. The backend retries the whole onchain step on failure, so this cannot be - /// left as an ordering rule for the caller to follow. + /// left as an ordering rule for the caller to follow. External so a wallet can run the same + /// check on its own paths that see the money move; `recordSettledPurchase` uses the + /// private form since it already has this contract's own state in scope. + /// @param _eSIMWallet Wallet being appended to + function requireLazyHistoryCopied(address _eSIMWallet) external view { + _requireLazyHistoryCopied(_eSIMWallet); + } + + /// @notice Refuses a new entry while older history is still waiting to be copied in + /// @dev See `requireLazyHistoryCopied`. /// @param _eSIMWallet Wallet being appended to function _requireLazyHistoryCopied(address _eSIMWallet) private view { if(lazyWalletRegistry == address(0)) return; @@ -444,8 +461,21 @@ contract Registry is if(outstanding != 0) revert Errors.HistoryNotFullyCopied(eSIMIdentifier, outstanding); } - function _consumePaymentReference(bytes32 _paymentReference) private { - PaymentAdapter(_requirePaymentAdapter()).consumePaymentReference(_paymentReference); + /// @notice Spends a payment reference for one eSIM wallet, refusing one already spent by it + /// @dev Checked and written here rather than on the payment adapter, for the two reasons + /// `usedPaymentReferences` documents: this survives an adapter rotation, and scoping by + /// wallet means the reference space is not shared, so nothing here can be front-run to + /// burn a reference an unrelated wallet's settlement is about to spend. + /// @param _eSIMWallet Wallet the reference is being spent for + /// @param _paymentReference Hash tying this purchase to the offchain payment behind it + function _consumePaymentReference(address _eSIMWallet, bytes32 _paymentReference) private { + if(_paymentReference == bytes32(0)) revert Errors.EmptyPaymentReference(); + + bytes32 scopedReference = keccak256(abi.encode(_eSIMWallet, _paymentReference)); + if(usedPaymentReferences[scopedReference]) revert Errors.PaymentReferenceAlreadyUsed(_paymentReference); + + usedPaymentReferences[scopedReference] = true; + emit PaymentReferenceConsumed(_eSIMWallet, _paymentReference); } /// @notice The payment adapter, or a revert if none is set yet diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index a5597d2e..8da7d5c9 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -164,6 +164,9 @@ contract RegistryHelper { /// @notice Emitted when the owner points the registry at a payment adapter event PaymentAdapterUpdated(address indexed _paymentAdapter); + /// @notice Emitted when a payment reference is spent for an eSIM wallet + event PaymentReferenceConsumed(address indexed _eSIMWallet, bytes32 indexed _paymentReference); + /// @notice Emitted for a purchase paid for outside the protocol /// @dev On the registry and not the wallet, so an indexer follows one address instead of one /// per wallet. `_tokenAmount` is unchecked: it and the price both come from the admin. diff --git a/contracts/device-wallet/DeviceWallet.sol b/contracts/device-wallet/DeviceWallet.sol index f1dffdcb..24eb105e 100644 --- a/contracts/device-wallet/DeviceWallet.sol +++ b/contracts/device-wallet/DeviceWallet.sol @@ -181,6 +181,13 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 /// /// Access to this wallet's money is granted only afterwards, by the owner, with /// `toggleAccessToFunds`. + /// + /// The admin gate here is a workflow convenience, not a security boundary against this + /// wallet's own owner: `ESIMWalletFactory.deployESIMWallet` also accepts a call from any + /// device wallet the registry knows, so the owner can reach the same outcome directly + /// through `execute` with no admin involved. Closing that would need the deployment + /// triggered by the admin rather than by this wallet, since nothing downstream of a device + /// wallet's own call can tell one caller's signed intent from another's. /// @param _hasAccessToFunds Must be false /// @param _salt CREATE2 salt for the new eSIM wallet /// @return eSIM wallet address diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 49aed9e8..0545efd5 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -297,7 +297,12 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Pays the vault for one data bundle in USDC (or any other acceptable stablecoin/ERC20) and records the purchase /// @dev The adapter works the amount out from the price, so there is never a second figure to - /// take on trust. Any shortfall is pulled from the device wallet. + /// take on trust. Any shortfall is pulled from the device wallet. What reaches the adapter + /// is this wallet's real balance after the pull, not the nominal amount asked for, so a + /// non-standard token that delivers less than requested (fee-on-transfer, deflationary) + /// fails at `settle`'s funding check with a clear reason instead of an opaque transfer + /// revert here. The protocol does not otherwise support such tokens: `settle` still needs + /// the price in full. /// @param _dataBundleDetail Data bundle being bought. Its settlement field is overwritten here. /// @param _asset Symbol of the currency to pay in /// @param _maxAmountIn Most of that currency the buyer will spend, in its smallest unit @@ -315,6 +320,7 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade if(_dataBundleDetail.id == bytes32(0)) revert Errors.EmptyDataBundleID(); if(_dataBundleDetail.priceUSDCents == 0) revert Errors.ZeroDataBundlePrice(); _requirePriceWithinCap(_dataBundleDetail.priceUSDCents, registry); + registry.requireLazyHistoryCopied(address(this)); // The money moves through this contract, so this path can say the protocol saw it. _dataBundleDetail.settlement = Settlement.DeviceWallet; @@ -336,12 +342,23 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade IERC20 token = IERC20(asset.token); uint256 held = token.balanceOf(address(this)); - if(held < amountIn) deviceWallet.pullToken(asset.token, amountIn - held); + if(held < amountIn) { + deviceWallet.pullToken(asset.token, amountIn - held); + // A fee-on-transfer asset delivers less than requested, so the shortfall pull can still + // leave this wallet short of amountIn. Re-read the real balance rather than assume the + // pull landed in full. + held = token.balanceOf(address(this)); + } + + // Forwards what this wallet actually holds, not the nominal amountIn: the same fee can bite + // again on the way to the adapter. settle() re-checks its own balance against the price and + // is what decides whether the purchase was funded. + uint256 toForward = held < amountIn ? held : amountIn; // Funded first, then told to settle. That is what lets a swap be added there later // without changing anything here. - token.safeTransfer(adapterAddress, amountIn); - (uint256 spent,) = adapter.settle(_asset, _dataBundleDetail.priceUSDCents, amountIn, address(this)); + token.safeTransfer(adapterAddress, toForward); + (uint256 spent,) = adapter.settle(_asset, _dataBundleDetail.priceUSDCents, toForward, address(this)); // The adapter's figure, not what this wallet sent. The two match today, and will not once // a swap can spend less than the ceiling it was funded to. diff --git a/contracts/esim-wallet/ESIMWalletFactory.sol b/contracts/esim-wallet/ESIMWalletFactory.sol index 79113d84..0e17edbd 100644 --- a/contracts/esim-wallet/ESIMWalletFactory.sol +++ b/contracts/esim-wallet/ESIMWalletFactory.sol @@ -59,6 +59,12 @@ contract ESIMWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgrad /// @notice Restricts a call to the registry, the device wallet factory or a known device wallet /// @dev The first two deploy on behalf of a device wallet during setup. A device wallet reaching /// this directly is constrained further inside `deployESIMWallet`. + /// + /// That third caller is what makes `DeviceWallet.deployESIMWallet`'s admin gate a workflow + /// convenience rather than a boundary: an owner can sign an `execute` straight at this + /// function and get the same wallet with no admin in the call. Deliberate, since a device + /// wallet reaches every external function through `execute` and no check downstream of its + /// call can tell which of its owner's intents produced it. modifier onlyRegistryOrDeviceWalletFactoryOrDeviceWallet() { if( msg.sender != address(registry) && diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index 7b14f626..db36104a 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -61,8 +61,12 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab mapping(bytes32 symbol => Asset asset) public assets; /// @notice Payment references already spent, protocol-wide - /// @dev One reference is one offchain payment. The backend retries the whole onchain step on - /// any failure, so without this a retry of a call that already landed records it twice. + /// @dev Retired: `Registry.usedPaymentReferences` is now the live replay-protection store, kept + /// there instead of here so it survives `setPaymentAdapter` rotating this contract out, and + /// scoped per wallet there so one wallet cannot burn a reference for another. Left declared + /// at this slot, unread and unwritten by the live purchase paths, because + /// `StorageLayout.t.sol` pins it here behind the proxy and removing it would shift every + /// variable below. mapping(bytes32 paymentReference => bool used) public usedReferences; /// @notice Emitted when this contract is wired up @@ -208,6 +212,13 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// eSIM wallet has no way to call this with a figure of its own choosing. Keep it that way. /// /// A fee-on-transfer token delivers less than declared and fails the funding check. + /// + /// `spent` is recomputed here from `_symbol` and `_priceUSDCents` rather than taken from + /// the caller's own `quote()` call, so the two agree only because nothing mutates + /// `assets[_symbol]` between the two calls in the same transaction today. Nothing + /// structurally enforces that: a swap path or any other step that can change an asset's + /// entry mid-transaction would need `settle` to check a value the caller's own `quote()` + /// call committed to, not one recomputed fresh here. /// @param _symbol Currency being paid in /// @param _priceUSDCents Price of the data bundle, in USD cents /// @param _amountIn Amount the caller has funded, and the most it is willing to spend @@ -246,8 +257,11 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab // Payment references // --------------------------------------------------------------------------------------------- - /// @notice Spends a payment reference, refusing one already spent - /// @dev Registry only, on both payment paths, so one reference cannot be spent once on each. + /// @notice Spends a payment reference against this adapter's own record, refusing one already + /// spent through it + /// @dev Retired from the registry's live purchase paths; see `usedReferences`. Left callable so + /// an adapter instance's own record still means what it says, but nothing in the protocol + /// calls this any more. /// @param _paymentReference Hash tying this purchase to the offchain payment behind it function consumePaymentReference(bytes32 _paymentReference) external onlyRegistry { if(_paymentReference == bytes32(0)) revert Errors.EmptyPaymentReference(); diff --git a/snapshots/ESIMWallet.Operations.json b/snapshots/ESIMWallet.Operations.json index ae5c68ee..6a87c3ff 100644 --- a/snapshots/ESIMWallet.Operations.json +++ b/snapshots/ESIMWallet.Operations.json @@ -1,7 +1,7 @@ { "acceptOwnershipTransfer": "3529", - "buyDataBundleWithToken: pulls from the device wallet": "156648", - "buyDataBundleWithToken: wallet already holds the price": "127162", + "buyDataBundleWithToken: pulls from the device wallet": "161439", + "buyDataBundleWithToken: wallet already holds the price": "131140", "deployESIMWallet: through the factory": "289547", "populateHistory: 10 entries": "489333", "requestTransferOwnership": "64238", diff --git a/snapshots/PaymentAdapter.Operations.json b/snapshots/PaymentAdapter.Operations.json index dff7ab24..fa4d5d51 100644 --- a/snapshots/PaymentAdapter.Operations.json +++ b/snapshots/PaymentAdapter.Operations.json @@ -4,8 +4,8 @@ "quote: 6 decimals": "7842", "registerAsset: a currency not in the table": "32911", "resolveAsset": "7991", - "settle: funded above the price, with a refund": "40529", - "settle: funded to the exact price": "15314", - "settle: the vault's first payment in this currency": "49714", + "settle: funded above the price, with a refund": "40573", + "settle: funded to the exact price": "15358", + "settle: the vault's first payment in this currency": "49758", "updateAsset: withdrawing a currency": "16144" } \ No newline at end of file diff --git a/snapshots/ProtocolAdmin.Operations.json b/snapshots/ProtocolAdmin.Operations.json index 1ae5a484..7c83e86d 100644 --- a/snapshots/ProtocolAdmin.Operations.json +++ b/snapshots/ProtocolAdmin.Operations.json @@ -1,5 +1,5 @@ { - "acceptOwnershipBatch: four contracts": "36608", + "acceptOwnershipBatch: four contracts": "36630", "cancel": "4694", "execute: one call, delay served": "23558", "executeBatch: both wallet beacons": "69122", diff --git a/snapshots/Registry.Operations.json b/snapshots/Registry.Operations.json index 7d48433c..2ea458c1 100644 --- a/snapshots/Registry.Operations.json +++ b/snapshots/Registry.Operations.json @@ -2,9 +2,9 @@ "acceptAdminUpdate": "4964", "assignESIMIdentifier: first time": "64808", "pause": "13963", - "recordSettledPurchase: first for the wallet": "124825", - "recordSettledPurchase: second for the same wallet": "87925", - "requestAdminUpdate": "10494", + "recordSettledPurchase: first for the wallet": "122013", + "recordSettledPurchase: second for the same wallet": "87113", + "requestAdminUpdate": "10538", "setDefaultPriceCapUSDCents": "13346", "toggleESIMWalletStandbyStatus: off standby": "5365", "toggleESIMWalletStandbyStatus: onto standby": "25265", diff --git a/test/foundry/invariant-testing/PaymentInvariants.t.sol b/test/foundry/invariant-testing/PaymentInvariants.t.sol index 9a2696b8..180def2b 100644 --- a/test/foundry/invariant-testing/PaymentInvariants.t.sol +++ b/test/foundry/invariant-testing/PaymentInvariants.t.sol @@ -11,28 +11,30 @@ contract PaymentInvariantsTest is CampaignBase { bytes32[3] private symbols = [bytes32("USD"), bytes32("USDC"), bytes32("ETH")]; - /// @notice One payment reference pays for one purchase, whichever path spends it + /// @notice One payment reference pays for one purchase on one wallet, whichever path spends it /// @dev A reference is one offchain payment. The backend retries the whole onchain step on any - /// failure, so a reference that could be spent a second time is a user charged once and - /// billed twice. The two payment paths reach the adapter by different routes, which is - /// what makes this a question about the pair rather than about either one. + /// failure, so a reference that could be spent a second time on the same wallet is a user + /// charged once and billed twice. The two payment paths reach the registry by different + /// routes, which is what makes this a question about the pair rather than about either + /// one. Two different wallets spending the same raw reference is not a double-spend, since + /// the registry scopes the record per wallet. function invariant_aPaymentReferenceIsSpentAtMostOnce() public view { assertFalse( paymentHandler.ghost_referenceSpentTwice(), - "A payment reference paid for a second purchase" + "A wallet-scoped payment reference paid for a second purchase" ); uint256 count = paymentHandler.spentReferenceCount(); for (uint256 i = 0; i < count; ++i) { - bytes32 paymentReference = paymentHandler.spentReferences(i); + bytes32 scopedReference = paymentHandler.spentReferences(i); assertEq( - paymentHandler.ghost_spendCount(paymentReference), + paymentHandler.ghost_spendCount(scopedReference), 1, - "A payment reference was spent more than once" + "A wallet-scoped payment reference was spent more than once" ); assertTrue( - paymentAdapter.usedReferences(paymentReference), + registry.usedPaymentReferences(scopedReference), "A reference a purchase spent does not read as spent" ); } diff --git a/test/foundry/invariant-testing/handler/PaymentHandler.sol b/test/foundry/invariant-testing/handler/PaymentHandler.sol index eea9a159..717fa8b8 100644 --- a/test/foundry/invariant-testing/handler/PaymentHandler.sol +++ b/test/foundry/invariant-testing/handler/PaymentHandler.sol @@ -10,9 +10,11 @@ import {HandlerBase, HandlerConfig} from "test/foundry/invariant-testing/handler import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; /// @notice Drives both payment paths and the currency table against one another. -/// @dev The two paths spend payment references through the same adapter but reach it by different -/// routes, one from the wallet and one from the admin, so whether a reference can be spent -/// twice is a question about the pair rather than about either one. +/// @dev The two paths spend payment references through the registry but reach it by different +/// routes, one from the wallet and one from the admin, so whether one wallet's reference can +/// be spent twice is a question about the pair rather than about either one. Two different +/// wallets spending the same raw reference is not a double-spend, since the registry scopes +/// the record per wallet. /// /// Ghost state sits on this handler rather than in `ProtocolState`, because this is the only /// contract that writes it. A handler body that reverts takes its ghost writes with it, so a @@ -39,10 +41,13 @@ contract PaymentHandler is HandlerBase { /// @notice What every settled purchase should have moved to the vault, added up uint256 public ghost_settledToVault; - /// @notice How many times each reference has been spent by a call that went through - mapping(bytes32 paymentReference => uint256 spends) public ghost_spendCount; + /// @notice How many times each wallet-scoped reference has been spent by a call that went through + /// @dev Keyed the same way `Registry.usedPaymentReferences` is, `keccak256(abi.encode(wallet, + /// paymentReference))`, since two different wallets spending the same raw reference is not + /// a double-spend of anything. + mapping(bytes32 scopedReference => uint256 spends) public ghost_spendCount; - /// @notice Every reference a call has spent, listed once each + /// @notice Every wallet-scoped reference a call has spent, listed once each bytes32[] public spentReferences; /// @notice Set when a reference was spent by a second call that went through @@ -72,15 +77,18 @@ contract PaymentHandler is HandlerBase { return symbols[bound(seed, 0, symbols.length - 1)]; } - /// @notice Counts a spend that went through, and notices a second one on the same reference - function _recordSpend(bytes32 _paymentReference) internal { - if (ghost_spendCount[_paymentReference] == 0) { - spentReferences.push(_paymentReference); + /// @notice Counts a spend that went through, and notices a second one on the same wallet-scoped + /// reference + function _recordSpend(address _wallet, bytes32 _paymentReference) internal { + bytes32 scopedReference = keccak256(abi.encode(_wallet, _paymentReference)); + + if (ghost_spendCount[scopedReference] == 0) { + spentReferences.push(scopedReference); } else { ghost_referenceSpentTwice = true; } - ghost_spendCount[_paymentReference] += 1; + ghost_spendCount[scopedReference] += 1; } /// @notice The ceiling that applies to this wallet right now @@ -137,7 +145,7 @@ contract PaymentHandler is HandlerBase { needed, paymentReference ) { - _recordSpend(paymentReference); + _recordSpend(wallet, paymentReference); ghost_settledToVault += needed; state.recordCall("buyDataBundleWithToken"); } catch { @@ -179,7 +187,7 @@ contract PaymentHandler is HandlerBase { uint256(priceUSDCents), paymentReference ) { - _recordSpend(paymentReference); + _recordSpend(wallet, paymentReference); state.recordCall("recordSettledPurchase"); } catch { state.recordRevert("recordSettledPurchase"); diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol index d856dcb6..16b4b5e7 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol @@ -10,6 +10,7 @@ import {Asset} from "contracts/payments/PaymentAdapter.sol"; import {DeviceWalletFixture} from "test/foundry/unit-testing/device-wallet/base/DeviceWalletFixture.sol"; import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; +import {MockFeeOnTransferERC20} from "test/utils/mocks/tokens/MockFeeOnTransferERC20.sol"; import {MockReenteringERC20} from "test/utils/mocks/tokens/MockReenteringERC20.sol"; /// @notice Buying a data bundle with USDC (or any other acceptable stablecoin/ERC20), and getting a token balance back out again. @@ -257,17 +258,57 @@ contract ESIMWalletTokenPurchaseTest is DeviceWalletFixture { eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, spentRef); } - /// @notice A second wallet cannot spend a reference the first one used - function test_buyDataBundleWithToken_referenceCannotBeReusedByAnotherWallet() public { - bytes32 spentRef = nextRef(); + /// @notice A second, unrelated wallet can use the same raw reference: the record is scoped per + /// wallet, so this is not the same spend + /// @dev A reference already used by one wallet used to block every other wallet from ever using + /// the identical raw value, which let anyone burn a reference an admin's pending + /// settlement for a completely different wallet was about to spend. Scoping by wallet + /// closes that off: the two are independent records. + function test_buyDataBundleWithToken_aDifferentWalletCanUseTheSameRawReference() public { + bytes32 sharedRef = nextRef(); vm.prank(eSIMWalletAdmin); - eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), spentRef); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), sharedRef); fundSettlementToken(address(deviceWallet2), DEVICE_BALANCE); vm.prank(eSIMWalletAdmin); - vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, spentRef)); - eSIMWallet3.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), spentRef); + eSIMWallet3.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, settlementAmount(PRICE_CENTS), sharedRef); + + assertEq(eSIMWallet3.getTransactionHistory().length, 1, "The second wallet's purchase must have landed"); + } + + // --------------------------------------------------------------------------------------------- + // Fee-on-transfer assets + // --------------------------------------------------------------------------------------------- + + /// @notice A fee-on-transfer asset fails at settle's own funding check, not on an opaque + /// transfer revert inside this wallet + /// @dev Before this, the wallet forwarded the nominal price regardless of what the pull actually + /// delivered, so the plain `token.safeTransfer` a few lines below reverted with a bare + /// ERC-20 balance error for every purchase in a mistakenly registered fee-on-transfer + /// asset. Forwarding the real balance instead means the wallet's own device wallet balance + /// is never touched (the whole call still reverts atomically either way), and the failure + /// now surfaces through settle()'s existing, tested funding check. + function test_buyDataBundleWithToken_revertsCleanlyOnAFeeOnTransferAsset() public { + uint256 feeBps = 100; // 1% + MockFeeOnTransferERC20 feeToken = new MockFeeOnTransferERC20("Fee Token", "FEE", 6, feeBps); + vm.prank(upgradeManager); + paymentAdapter.registerAsset(bytes32("FEE"), Asset({ + allowed: true, + isDollarUnit: true, + decimals: 6, + token: address(feeToken) + })); + + uint256 amountIn = settlementAmount(PRICE_CENTS); + feeToken.mint(address(deviceWallet), amountIn * 10); + + // The fee bites once pulling from the device wallet and again forwarding to the adapter. + uint256 afterPull = amountIn - (amountIn * feeBps) / 10_000; + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.SettlementAboveMax.selector, amountIn, afterPull)); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_FEE", PRICE_CENTS), bytes32("FEE"), amountIn, nextRef()); } // --------------------------------------------------------------------------------------------- diff --git a/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol b/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol index d0ff2443..cf846ab4 100644 --- a/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol +++ b/test/foundry/unit-testing/payments/PaymentAdapterSettle.t.sol @@ -122,6 +122,21 @@ contract PaymentAdapterSettleTest is DeviceWalletFixture { assertEq(token.balanceOf(vault), needed, "The vault should have been paid"); } + /// @notice `settle` still charges exactly what `quote()` said for the same inputs + /// @dev This holds today only because nothing mutates `assets[_symbol]` between the two calls in + /// the same transaction; see the note on `settle`. Pinned here so a future change that + /// breaks that assumption (a swap path, or anything else that can move an asset's entry + /// mid-transaction) fails this test first, rather than surfacing as a quiet mismatch. + function test_settle_matchesQuoteForTheSameInputsWithinOneTransaction() public { + uint256 quoted = paymentAdapter.quote(ASSET_USDC, PRICE_CENTS); + fundSettlementToken(address(paymentAdapter), quoted); + + vm.prank(address(eSIMWallet1)); + (uint256 spent,) = paymentAdapter.settle(ASSET_USDC, PRICE_CENTS, quoted, address(eSIMWallet1)); + + assertEq(spent, quoted, "settle must charge exactly what quote() said for the same inputs"); + } + // --------------------------------------------------------------------------------------------- // What settle refuses // --------------------------------------------------------------------------------------------- diff --git a/test/foundry/unit-testing/registry/SettledPurchase.t.sol b/test/foundry/unit-testing/registry/SettledPurchase.t.sol index fa006e05..08991d75 100644 --- a/test/foundry/unit-testing/registry/SettledPurchase.t.sol +++ b/test/foundry/unit-testing/registry/SettledPurchase.t.sol @@ -9,6 +9,8 @@ import {Errors} from "contracts/Errors.sol"; import {Registry} from "contracts/Registry.sol"; import {PaymentAdapter, Asset} from "contracts/payments/PaymentAdapter.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + import "test/utils/DeployerBase.sol"; /// @notice Covers purchases the admin says were paid for outside the protocol. @@ -45,6 +47,48 @@ contract SettledPurchaseTest is DeployerBase { deviceWallet.toggleAccessToFunds(address(eSIMWallet), true); } + /// @notice Deploys a second, unrelated device wallet and eSIM wallet + /// @dev Used to show that one wallet's spend cannot reach into another's payment references. + function _deploySecondWallet() internal returns (DeviceWallet secondDeviceWallet, MockESIMWallet secondESIMWallet) { + string[] memory identifiers = new string[](1); + bytes32[2][] memory keys = new bytes32[2][](1); + uint256[] memory salts = new uint256[](1); + + identifiers[0] = customDeviceUniqueIdentifiers[1]; + keys[0] = pubKey2; + salts[0] = 2; + + vm.prank(eSIMWalletAdmin); + Wallets[] memory wallets = + deviceWalletFactory.deployDeviceWalletForUsers(identifiers, keys, salts, new uint256[](1)); + + secondDeviceWallet = DeviceWallet(payable(wallets[0].deviceWallet)); + secondESIMWallet = MockESIMWallet(payable(wallets[0].eSIMWallet)); + + vm.prank(address(secondDeviceWallet)); + secondDeviceWallet.toggleAccessToFunds(address(secondESIMWallet), true); + } + + /// @notice Deploys a fresh payment adapter wired to the same registry, with USDC registered + function _deployFreshAdapter() internal returns (PaymentAdapter) { + PaymentAdapter freshImpl = new PaymentAdapter(); + ERC1967Proxy freshProxy = new ERC1967Proxy( + address(freshImpl), + abi.encodeCall(freshImpl.initialize, (address(registry), settlementToken, upgradeManager)) + ); + PaymentAdapter fresh = PaymentAdapter(address(freshProxy)); + + vm.prank(upgradeManager); + fresh.registerAsset(ASSET_USDC, Asset({ + allowed: true, + isDollarUnit: true, + decimals: SETTLEMENT_DECIMALS, + token: settlementToken + })); + + return fresh; + } + /// @notice Records a purchase the admin says was paid for offchain function _settle(bytes32 _id, uint64 _priceUSDCents, bytes32 _paymentReference) internal { vm.prank(eSIMWalletAdmin); @@ -280,14 +324,17 @@ contract SettledPurchaseTest is DeployerBase { // Payment references // --------------------------------------------------------------------------------------------- - /// @notice Recording a purchase spends its reference + /// @notice Recording a purchase spends its reference, scoped to the wallet it was recorded for function test_recordSettledPurchase_spendsTheReference() public { _deployWallets(); bytes32 orderRef = paymentRef("stripe-intent"); _settle("DB_ID", TEST_PRICE_CENTS, orderRef); - assertTrue(paymentAdapter.usedReferences(orderRef), "The reference must be spent"); + assertTrue( + registry.usedPaymentReferences(keccak256(abi.encode(address(eSIMWallet), orderRef))), + "The reference must be spent for this wallet" + ); } /// @notice A retried call cannot record the purchase twice @@ -358,6 +405,50 @@ contract SettledPurchaseTest is DeployerBase { registry.consumePaymentReference(paymentRef("forged-order")); } + /// @notice A reference cannot be replayed by rotating to a fresh payment adapter + /// @dev Replay protection lives on the registry, not the adapter, so a rotation that starts a + /// new adapter's own record empty changes nothing about whether this reference is spent. + function test_setPaymentAdapter_rotationDoesNotReviveASpentReference() public { + _deployWallets(); + bytes32 orderRef = paymentRef("moonpay-charge"); + _settle("DB_FIRST", TEST_PRICE_CENTS, orderRef); + + PaymentAdapter freshAdapter = _deployFreshAdapter(); + vm.prank(upgradeManager); + registry.setPaymentAdapter(address(freshAdapter)); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector(Errors.PaymentReferenceAlreadyUsed.selector, orderRef)); + registry.recordSettledPurchase( + address(eSIMWallet), bundle("DB_REPLAYED", TEST_PRICE_CENTS), ASSET_USDC, 1e6, orderRef + ); + } + + /// @notice An unrelated wallet cannot burn a reference an admin's pending settlement for a + /// different wallet is about to spend + /// @dev Before scoping, the two payment paths shared one reference space, so any wallet owner + /// could watch the public mempool for a pending `recordSettledPurchase` naming a reference + /// and front-run it with their own cheap `buyDataBundleWithToken` call on that same raw + /// reference, on their own unrelated wallet, permanently burning it. Scoping by wallet + /// means the two spends are independent records, so the admin's settlement still lands. + function test_consumePaymentReference_anUnrelatedWalletCannotBlockAnothersSettlement() public { + _deployWallets(); + (, MockESIMWallet attackerWallet) = _deploySecondWallet(); + + bytes32 victimRef = paymentRef("victim-stripe-charge"); + uint256 needed = settlementAmount(TEST_PRICE_CENTS); + fundSettlementToken(address(attackerWallet), needed); + + vm.prank(eSIMWalletAdmin); + attackerWallet.buyDataBundleWithToken(bundle("DB_ATTACK", TEST_PRICE_CENTS), ASSET_USDC, needed, victimRef); + + // The admin's settlement for the actual, unrelated victim wallet still lands. + _settle("DB_VICTIM", TEST_PRICE_CENTS, victimRef); + + (bytes32 id,,) = eSIMWallet.transactionHistory(0); + assertEq(id, "DB_VICTIM", "The victim's settlement must not have been blocked"); + } + // --------------------------------------------------------------------------------------------- // The ordering guard // --------------------------------------------------------------------------------------------- @@ -380,6 +471,55 @@ contract SettledPurchaseTest is DeployerBase { ); } + /// @notice A token-paid purchase cannot land ahead of history still waiting to be copied in + /// either, the same as a settled one + /// @dev Without this guard the purchase pushed straight to `transactionHistory` regardless, so + /// it landed at index 0 and the older, pre-deployment entries appended after it once the + /// copy caught up. + function test_buyDataBundleWithToken_rejectsWhileHistoryIsOutstanding() public { + MockESIMWallet lazyWallet = _lazyDeployWithHistoryOutstanding(); + string memory eSIMIdentifier = customESIMUniqueIdentifiers[0][0]; + uint256 outstanding = lazyWalletRegistry.outstandingHistoryEntries(eSIMIdentifier); + assertGt(outstanding, 0, "The fixture must leave history uncopied"); + + address lazyDeviceWallet = address(lazyWallet.deviceWallet()); + fundSettlementToken(lazyDeviceWallet, settlementAmount(TEST_PRICE_CENTS)); + vm.prank(lazyDeviceWallet); + DeviceWallet(payable(lazyDeviceWallet)).toggleAccessToFunds(address(lazyWallet), true); + + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector( + Errors.HistoryNotFullyCopied.selector, eSIMIdentifier, outstanding + )); + lazyWallet.buyDataBundleWithToken( + bundle("DB_NEW", TEST_PRICE_CENTS), ASSET_USDC, settlementAmount(TEST_PRICE_CENTS), nextRef() + ); + } + + /// @notice Once the history is copied a token-paid purchase goes through and lands last + function test_buyDataBundleWithToken_acceptsOnceHistoryIsCopied() public { + MockESIMWallet lazyWallet = _lazyDeployWithHistoryOutstanding(); + string memory eSIMIdentifier = customESIMUniqueIdentifiers[0][0]; + + vm.prank(eSIMWalletAdmin); + lazyWalletRegistry.setHistoryForLazyWallet(eSIMIdentifier, FULL_BATCH); + uint256 copiedEntries = lazyWallet.getTransactionHistory().length; + + address lazyDeviceWallet = address(lazyWallet.deviceWallet()); + fundSettlementToken(lazyDeviceWallet, settlementAmount(TEST_PRICE_CENTS)); + vm.prank(lazyDeviceWallet); + DeviceWallet(payable(lazyDeviceWallet)).toggleAccessToFunds(address(lazyWallet), true); + + vm.prank(eSIMWalletAdmin); + lazyWallet.buyDataBundleWithToken( + bundle("DB_LAST", TEST_PRICE_CENTS), ASSET_USDC, settlementAmount(TEST_PRICE_CENTS), nextRef() + ); + + assertEq(lazyWallet.getTransactionHistory().length, copiedEntries + 1, "The purchase must be appended"); + (bytes32 id,,) = lazyWallet.transactionHistory(copiedEntries); + assertEq(id, "DB_LAST", "The new purchase must be the last entry"); + } + /// @notice Once the history is copied the purchase goes through and lands last function test_recordSettledPurchase_acceptsOnceHistoryIsCopied() public { MockESIMWallet lazyWallet = _lazyDeployWithHistoryOutstanding(); From ff88e9fc578d134233fedebf1d80c373bd4b4ae4 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 31 Aug 2026 14:08:32 +0530 Subject: [PATCH 61/75] Say what the standby flag and the price ceiling actually bound 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. --- .gas-snapshot | 57 +++++++------- contracts/RegistryHelper.sol | 16 ++-- contracts/esim-wallet/ESIMWallet.sol | 4 + .../DeviceWalletFactoryCreateAccount.t.sol | 74 +++++++++++++++++++ .../unit-testing/esim-wallet/ESIMWallet.t.sol | 38 ++++++++++ .../esim-wallet/ESIMWalletTokenPurchase.t.sol | 43 +++++++++++ 6 files changed, 200 insertions(+), 32 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 87c8c98c..69d83235 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -97,15 +97,17 @@ DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutAdmin() (gas: 37790 DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutOwner() (gas: 21191) DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3875740) DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation_admin() (gas: 36155) -DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 566022) +DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 566044) +DeviceWalletFactoryCreateAccountTest:test_createAccount_anUnregisteredTwinReachesNothing() (gas: 865059) DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller() (gas: 382397) DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller_cannotCallPostCreateAccount() (gas: 363023) -DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579639) +DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579683) DeviceWalletFactoryCreateAccountTest:test_createAccount_forwardsValueToAnAlreadyDeployedWallet() (gas: 391140) -DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369842) -DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351699) -DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928224) -DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885367) +DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369864) +DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351721) +DeviceWalletFactoryCreateAccountTest:test_deployDeviceWalletForUsers_adoptsAWalletDeployedByAnyoneElse() (gas: 940047) +DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928361) +DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885393) DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886830) DeviceWalletFactoryGuardsTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 3556215) DeviceWalletFactoryGuardsTest:test_createAccount_rejectsAnEmptyDeviceIdentifier() (gas: 19711) @@ -217,12 +219,13 @@ ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2430083) ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2401202) ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411912) ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395955) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397696) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397718) +ESIMWalletTest:test_acceptOwnershipTransfer_theStaleAssociationGrantsTheOldOwnerNothing() (gas: 2412281) ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510365) ESIMWalletTest:test_buyDataBundleWithToken_followsTheRotatedAdmin() (gas: 1775289) ESIMWalletTest:test_buyDataBundleWithToken_recordsTheHistoryBeforeAnythingMoves() (gas: 3023686) ESIMWalletTest:test_buyDataBundleWithToken_theWalletCapOverridesTheRegistryDefault() (gas: 1795147) -ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2513994) +ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2514016) ESIMWalletTest:test_owner() (gas: 1505564) ESIMWalletTest:test_populateHistory() (gas: 1795503) ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906728) @@ -230,7 +233,7 @@ ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844281) ESIMWalletTest:test_requestTransferOwnership() (gas: 2405125) ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425492) ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3245307) -ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263967) +ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263989) ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423513) ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434851) ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517162) @@ -242,38 +245,40 @@ ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533440) ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511323) ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508246) ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510494) -ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511020) +ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511042) ESIMWalletTest:test_transferOwnership() (gas: 1508412) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_aDifferentWalletCanUseTheSameRawReference() (gas: 501771) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 295991) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 282759) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 296013) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 282781) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 292501) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_isNotHeldByTheStandbyFlag() (gas: 323249) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 289540) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 304362) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 304406) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 296326) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 289767) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1504175) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86777) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86821) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 105010) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsCleanlyOnAFeeOnTransferAsset() (gas: 1107688) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 163323) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsCleanlyOnAFeeOnTransferAsset() (gas: 1107710) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 163367) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 75609) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 145687) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 312578) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79637) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 312600) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79659) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79260) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 235865) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 132844) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 235887) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 132866) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 147409) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88431) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 284541) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 286300) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88475) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 284566) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 286344) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_theCeilingDoesNotBoundRepeatedCharges() (gas: 1532348) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() (gas: 53602) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsATokenTheProtocolNeverPricedIn() (gas: 687121) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsTheBalance() (gas: 53836) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsTheBalance() (gas: 53854) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsForAnyoneElse() (gas: 58143) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnTheZeroTokenAddress() (gas: 22347) -ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnZeroAmount() (gas: 23994) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnTheZeroTokenAddress() (gas: 22369) +ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_revertsOnZeroAmount() (gas: 24016) GuardianPowersTest:test_disableAdminInstantly_cannotChooseAReplacement() (gas: 36716) GuardianPowersTest:test_disableAdminInstantly_cannotReinstate() (gas: 95421) GuardianPowersTest:test_disableAdminInstantly_emitsTheSuspension() (gas: 39312) diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index 8da7d5c9..72ce47b5 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -58,12 +58,16 @@ contract RegistryHelper { /// is what makes the first sentence true rather than assumed. mapping(address eSIMWalletAddress => address deviceWalletAddress) public isESIMWalletValid; - /// @notice If an existing eSIM wallet is in the process of being transferred from one device wallet to another - /// @dev If bool is `true`, the eSIM wallet is in a transient state. `isESIMWalletValid` still - /// points at the old device wallet. Do not use this mapping to check whether an eSIM - /// wallet belongs to the protocol; that is what `isESIMWalletValid` is for. Its job is to - /// hold transactions on this eSIM wallet until it reads false again, meaning the new - /// device wallet has accepted it. + /// @notice True while an eSIM wallet sits between device wallets, released by one and not yet + /// taken on by another + /// @dev A marker for offchain readers, not a gate: nothing in the protocol reads it, and no + /// purchase, pull or history path is held while it is true. Gating spend on it would brick + /// a wallet its device wallet simply removed, since `removeESIMWallet` raises it whether or + /// not a transfer follows and only a later bind lowers it again. + /// + /// `isESIMWalletValid` still names the device wallet that last held the wallet while this + /// is true. Do not use this mapping to ask whether an eSIM wallet belongs to the protocol; + /// that is what `isESIMWalletValid` is for. mapping(address eSIMWalletAddress => bool isOnStandby) public isESIMWalletOnStandby; /// @notice The eSIM wallet holding each eSIM identifier, protocol-wide diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 0545efd5..1a813f85 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -192,6 +192,10 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// price on `buyDataBundle`, so it must not also be able to raise the ceiling on that /// price. Setting zero hands the wallet back to the registry's ceiling. A handover clears /// it, so an incoming owner starts on the registry ceiling. + /// + /// The ceiling bounds one charge and not what the admin can charge in total. Nothing limits + /// how many purchases it makes, so a wallet holding `canPullFunds` is an open allowance over + /// the device wallet's balance in that asset rather than a capped one. /// @param _cap Maximum price in USD cents, or zero to follow the registry function setPriceCapUSDCents(uint64 _cap) external onlyDeviceWallet { priceCapUSDCents = _cap; diff --git a/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryCreateAccount.t.sol b/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryCreateAccount.t.sol index 814f5eef..42a49540 100644 --- a/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryCreateAccount.t.sol +++ b/test/foundry/unit-testing/device-wallet/factory/DeviceWalletFactoryCreateAccount.t.sol @@ -352,6 +352,80 @@ contract DeviceWalletFactoryCreateAccountTest is DeviceWalletFactoryFixture { ); } + /// @notice A second wallet standing at a second salt for the same identifier and key holds no + /// powers, so deploying one grieves nobody + /// @dev createAccount writes no registry state and takes no caller, so anyone can stand a twin + /// up against someone else's identifier and key. The key is a constructor argument, so the twin + /// answers to that same key rather than to whoever paid for the deployment, and every gate in + /// the protocol reads the registry, which will not have it. + function test_createAccount_anUnregisteredTwinReachesNothing() public { + vm.prank(user1); + address realWallet = address(deviceWalletFactory.createAccount( + customDeviceUniqueIdentifiers[0], + pubKey1, + 111 + )); + vm.prank(eSIMWalletAdmin); + deviceWalletFactory.postCreateAccount(realWallet, customDeviceUniqueIdentifiers[0], pubKey1, 111); + + vm.prank(user2); + MockDeviceWallet twin = MockDeviceWallet(payable(deviceWalletFactory.createAccount( + customDeviceUniqueIdentifiers[0], + pubKey1, + 222 + ))); + assertTrue(address(twin) != realWallet, "A second salt must reach a second address"); + + assertEq(twin.owner(0), pubKey1[0], "X co-ordinate should have matched the key it was deployed for"); + assertEq(twin.owner(1), pubKey1[1], "Y co-ordinate should have matched the key it was deployed for"); + assertEq(registry.isDeviceWalletValid(address(twin)), false, "The twin must stay unregistered"); + + vm.prank(address(twin)); + vm.expectRevert(Errors.OnlyRegistryOrDeviceWalletFactoryOrDeviceWallet.selector); + eSIMWalletFactory.deployESIMWallet(address(twin), 333); + } + + /// @notice Occupying the address an admin batch is about to deploy to is adopted, not refused + /// @dev The owner key and the identifier are proxy constructor arguments, so CREATE2 moves the + /// address the moment either differs. Code standing at the derived address can therefore only be + /// the wallet the batch was going to deploy itself, which is why adopting it is safe and why + /// pre-deploying it is not a way to block anyone. + function test_deployDeviceWalletForUsers_adoptsAWalletDeployedByAnyoneElse() public { + uint256 salt = 777; + address predicted = deviceWalletFactory.getCounterFactualAddress( + pubKey1, + customDeviceUniqueIdentifiers[0], + salt + ); + + vm.prank(user2); + deviceWalletFactory.createAccount(customDeviceUniqueIdentifiers[0], pubKey1, salt); + assertTrue(predicted.code.length > 0, "The address must be occupied before the batch runs"); + + string[] memory deviceUniqueIdentifiers = new string[](1); + bytes32[2][] memory listOfKeys = new bytes32[2][](1); + uint256[] memory salts = new uint256[](1); + uint256[] memory deposits = new uint256[](1); + + deviceUniqueIdentifiers[0] = customDeviceUniqueIdentifiers[0]; + listOfKeys[0] = pubKey1; + salts[0] = salt; + deposits[0] = 1 ether; + + vm.deal(eSIMWalletAdmin, 1 ether); + vm.prank(eSIMWalletAdmin); + Wallets[] memory wallets = deviceWalletFactory.deployDeviceWalletForUsers{value: 1 ether}( + deviceUniqueIdentifiers, + listOfKeys, + salts, + deposits + ); + + assertEq(wallets[0].deviceWallet, predicted, "The batch must adopt the wallet already standing there"); + assertEq(registry.isDeviceWalletValid(predicted), true, "The adopted wallet must be registered"); + assertEq(predicted.balance, 1 ether, "The deposit must follow the wallet"); + } + /// @notice The same case reached through the P256 key rather than the identifier. A key that /// already resolves to a wallet must keep resolving to it. function test_postCreateAccount_revertsOnRegisteredOwnerKey() public { diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol index 24a92fd7..4e7afdb6 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWallet.t.sol @@ -522,6 +522,44 @@ contract ESIMWalletTest is DeployerBase { assertEq(registry.isESIMWalletValid(address(eSIMWallet1)), address(deviceWallet2), "Registry should have updated the eSIM wallet to the new device wallet"); } + /// @notice Between acceptance and the new owner binding, the registry association still names + /// the old device wallet, and that stale value hands it nothing + /// @dev The association is a registration rather than a permission. Every gate that decides + /// anything about an eSIM wallet reads `ESIMWallet.owner()`, which moved with the + /// acceptance, so the association is free to lag behind it without opening a way back in + /// for the device wallet that let the wallet go. + function test_acceptOwnershipTransfer_theStaleAssociationGrantsTheOldOwnerNothing() public { + test_requestTransferOwnership(); + + vm.prank(address(deviceWallet2)); + eSIMWallet1.acceptOwnershipTransfer(); + + assertEq(registry.isESIMWalletValid(address(eSIMWallet1)), address(deviceWallet), "The association still lags behind the owner"); + assertEq(eSIMWallet1.owner(), address(deviceWallet2), "The owner moved with the acceptance"); + + // Re-binding reads owner(), so the old device wallet cannot take the wallet back + vm.prank(address(deviceWallet)); + vm.expectRevert(abi.encodeWithSelector( + Errors.ESIMWalletNotOwnedByThisDeviceWallet.selector, + address(eSIMWallet1), + address(deviceWallet2) + )); + deviceWallet.addESIMWallet(address(eSIMWallet1), false); + + // The standby flag reads owner() too, so the old device wallet cannot clear the marker + vm.prank(address(deviceWallet)); + vm.expectRevert(abi.encodeWithSelector( + Errors.NotTheESIMWalletOwnerOrItsDeviceWallet.selector, + address(eSIMWallet1) + )); + registry.toggleESIMWalletStandbyStatus(address(eSIMWallet1), false); + + // And the wallet lost its route to the old device wallet's money on the release + vm.prank(address(eSIMWallet1)); + vm.expectRevert(Errors.OnlyAssociatedESIMWallets.selector); + deviceWallet.pullToken(settlementToken, 1); + } + /// @notice A single ownership transfer emits exactly one OwnershipTransferred /// @dev Counts logs rather than using vm.expectEmit, which matches one occurrence and would /// pass just as happily against a duplicate emission. diff --git a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol index 16b4b5e7..3f0f9723 100644 --- a/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol +++ b/test/foundry/unit-testing/esim-wallet/ESIMWalletTokenPurchase.t.sol @@ -277,6 +277,49 @@ contract ESIMWalletTokenPurchaseTest is DeviceWalletFixture { assertEq(eSIMWallet3.getTransactionHistory().length, 1, "The second wallet's purchase must have landed"); } + // --------------------------------------------------------------------------------------------- + // What the ceiling and the standby flag do not bound + // --------------------------------------------------------------------------------------------- + + /// @notice The ceiling bounds one charge, not the total the admin can charge + /// @dev Each call spends up to the ceiling against a payment reference the caller picks, and + /// nothing counts the calls, so the whole device wallet balance is reachable one capped + /// charge at a time. Granting a wallet funds access is an open allowance, not a capped one. + function test_buyDataBundleWithToken_theCeilingDoesNotBoundRepeatedCharges() public { + uint64 cap = 10_000; // $100 per purchase + vm.prank(address(deviceWallet)); + eSIMWallet1.setPriceCapUSDCents(cap); + + uint256 perPurchase = settlementAmount(cap); + uint256 purchases = DEVICE_BALANCE / perPurchase; + + for (uint256 i = 0; i < purchases; ++i) { + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", cap), ASSET_USDC, perPurchase, nextRef()); + } + + assertEq(settlementERC20.balanceOf(address(deviceWallet)), 0, "The whole balance is reachable through capped charges"); + assertEq(settlementERC20.balanceOf(vault), DEVICE_BALANCE, "Every charge should have landed in the vault"); + } + + /// @notice A wallet marked on standby can still be charged + /// @dev The flag says a release is outstanding and gates nothing. `removeESIMWallet` raises it + /// whether or not a transfer follows and only a later bind lowers it, so refusing spend + /// here would leave a plainly removed wallet unable to spend its own balance for good. + function test_buyDataBundleWithToken_isNotHeldByTheStandbyFlag() public { + uint256 needed = settlementAmount(PRICE_CENTS); + fundSettlementToken(address(eSIMWallet1), needed); + + vm.prank(address(deviceWallet)); + deviceWallet.removeESIMWallet(address(eSIMWallet1), false); + assertTrue(registry.isESIMWalletOnStandby(address(eSIMWallet1)), "The release should be outstanding"); + + vm.prank(eSIMWalletAdmin); + eSIMWallet1.buyDataBundleWithToken(bundle("DB_TOKEN", PRICE_CENTS), ASSET_USDC, needed, nextRef()); + + assertEq(settlementERC20.balanceOf(vault), needed, "Standby should not have stopped the purchase"); + } + // --------------------------------------------------------------------------------------------- // Fee-on-transfer assets // --------------------------------------------------------------------------------------------- From 55313ced60cb7ad4d84f616ca1520f730123df36 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 31 Aug 2026 14:38:26 +0530 Subject: [PATCH 62/75] Hold the funded lazy deployment while the protocol is paused 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. --- .gas-snapshot | 616 +++++++++--------- contracts/LazyWalletRegistry.sol | 7 + snapshots/DeviceWalletFactory.Operations.json | 8 +- snapshots/LazyWalletRegistry.Operations.json | 24 +- snapshots/ProtocolAdmin.Operations.json | 4 +- snapshots/Registry.Operations.json | 2 +- .../registry/LazyWalletRegistry.t.sol | 61 ++ 7 files changed, 396 insertions(+), 326 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 69d83235..734f3d3d 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,40 +1,40 @@ -Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 971191) -Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926559) -Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 931953) -Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1021108) -Account4337Test:test_executeBatch_runsEveryCall() (gas: 1003990) -Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977933) -Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 931244) -Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 929326) -Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924408) -Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924768) -Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 963297) -Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 928966) -Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 929060) -Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007795) -Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966732) -Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970416) -Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964520) +Account4337Test:test_addDeposit_creditsTheEntryPointRatherThanTheWallet() (gas: 971195) +Account4337Test:test_executeBatch_acceptsAnEmptyBatch() (gas: 926563) +Account4337Test:test_executeBatch_rejectsAnArbitraryCaller() (gas: 931957) +Account4337Test:test_executeBatch_revertsTheWholeBatchOnOneFailedCall() (gas: 1021112) +Account4337Test:test_executeBatch_runsEveryCall() (gas: 1003994) +Account4337Test:test_execute_passesBackTheTargetsRevertReason() (gas: 977937) +Account4337Test:test_execute_rejectsAnArbitraryCaller() (gas: 931248) +Account4337Test:test_execute_rejectsTheESIMWalletAdmin() (gas: 929330) +Account4337Test:test_isValidSignature_rejectsASignatureTooShortToParse() (gas: 924412) +Account4337Test:test_isValidSignature_rejectsAnUnknownSignatureVersion() (gas: 924772) +Account4337Test:test_validateUserOp_forwardsTheMissingPrefundToTheEntryPoint() (gas: 963301) +Account4337Test:test_validateUserOp_rejectsACallerOtherThanTheEntryPoint() (gas: 928970) +Account4337Test:test_validateUserOp_rejectsAnUnknownSignatureVersion() (gas: 929064) +Account4337Test:test_withdrawDepositTo_movesExactlyTheAmountWithdrawn() (gas: 1007799) +Account4337Test:test_withdrawDepositTo_rejectsACallerOtherThanTheWallet() (gas: 966736) +Account4337Test:test_withdrawDepositTo_rejectsAnAmountAboveTheDeposit() (gas: 970420) +Account4337Test:test_withdrawDepositTo_rejectsTheZeroAddress() (gas: 964524) AdminRotationTest:test_acceptAdminUpdate() (gas: 47231) AdminRotationTest:test_acceptAdminUpdate_afterRevoke() (gas: 49006) AdminRotationTest:test_acceptAdminUpdate_clearsASuspension() (gas: 49109) AdminRotationTest:test_acceptAdminUpdate_currentAdmin() (gas: 42697) AdminRotationTest:test_acceptAdminUpdate_reachesEveryReader() (gas: 68162) AdminRotationTest:test_acceptAdminUpdate_withoutRequest() (gas: 19779) -AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175748) +AdminRotationTest:test_disableAdmin_closesEveryGate() (gas: 1175759) AdminRotationTest:test_disableAdmin_endsThePauseLoopAgainstACompromisedKey() (gas: 64565) AdminRotationTest:test_disableAdmin_rejectsARepeat() (gas: 29532) AdminRotationTest:test_disableAdmin_rejectsAnyoneButTheOwner() (gas: 38707) -AdminRotationTest:test_enableAdmin_leavesAnOutstandingHandoverAlone() (gas: 283089) +AdminRotationTest:test_enableAdmin_leavesAnOutstandingHandoverAlone() (gas: 283096) AdminRotationTest:test_enableAdmin_rejectsAnyoneButTheOwner() (gas: 39370) AdminRotationTest:test_enableAdmin_rejectsWhenNotDisabled() (gas: 21074) -AdminRotationTest:test_enableAdmin_restoresTheGates() (gas: 282241) +AdminRotationTest:test_enableAdmin_restoresTheGates() (gas: 282248) AdminRotationTest:test_requestAdminUpdate() (gas: 37283) AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentLiftsASuspension() (gas: 36181) -AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 285105) +AdminRotationTest:test_requestAdminUpdate_namingTheIncumbentRestoresItsPowers() (gas: 285112) AdminRotationTest:test_requestAdminUpdate_rejectsAnyoneButTheOwner() (gas: 46045) AdminRotationTest:test_requestAdminUpdate_revoke() (gas: 43638) -AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1222154) +AdminRotationTest:test_requestAdminUpdate_stripsTheIncumbentImmediately() (gas: 1222172) AdminRotationTest:test_requestAdminUpdate_zeroAddress() (gas: 19702) AdminStorageLayoutTest:test_layout_aDoneOperationNeverReadsReady() (gas: 8064) AdminStorageLayoutTest:test_layout_accessControlRolesReadSlotZero() (gas: 16215) @@ -46,11 +46,11 @@ AdminStorageLayoutTest:test_layout_theDelayFloorIsNotInStorage() (gas: 8697) AdminStorageLayoutTest:test_layout_theGuardianRoleLivesInTheSameMappingAsTheRest() (gas: 17469) AdminUpgradesTest:test_upgrade_beaconRejectsTheFormerOwnerOfTheFactory() (gas: 2615351) AdminUpgradesTest:test_upgrade_canBeCancelledBeforeTheDelayExpires() (gas: 4309460) -AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3496626) +AdminUpgradesTest:test_upgrade_deviceWalletBeaconReachesExistingWallets() (gas: 3496630) AdminUpgradesTest:test_upgrade_eSIMWalletBeaconReachesExistingWallets() (gas: 2686969) -AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 12989275) -AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3658751) -AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 13038454) +AdminUpgradesTest:test_upgrade_hasNoRouteThatSkipsTheDelay() (gas: 13195298) +AdminUpgradesTest:test_upgrade_lazyWalletRegistryThroughTheDelay() (gas: 3864946) +AdminUpgradesTest:test_upgrade_movesAllFourSingletonsInOneOperation() (gas: 13244481) AdminUpgradesTest:test_upgrade_movesBothBeaconsInOneOperation() (gas: 5630047) AdminUpgradesTest:test_upgrade_registryThroughTheDelay() (gas: 4342644) AdminUpgradesTest:test_upgrade_rejectsAGuardianActingDirectly() (gas: 4282534) @@ -60,127 +60,127 @@ Deployer:test_deviceWalletFactory_ownable2Step() (gas: 44710) Deployer:test_deviceWalletFactory_setUp() (gas: 53722) Deployer:test_eSIMWalletFactory_ownable2Step() (gas: 44690) Deployer:test_eSIMWalletFactory_setUp() (gas: 27320) -Deployer:test_lazyWalletRegistry_setUp() (gas: 21019) -Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45819) +Deployer:test_lazyWalletRegistry_setUp() (gas: 21026) +Deployer:test_lazywalletRegistry_ownable2Step() (gas: 45843) Deployer:test_registry_ownable2step() (gas: 47416) Deployer:test_registry_setUp() (gas: 54959) -DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3752809) -DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3393881) -DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3449877) -DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395395) -DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399624) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3431669) -DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3401176) -DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388583) -DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3404193) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471347) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3394237) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPull() (gas: 3774663) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390277) -DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395376) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3457988) -DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3396911) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3418597) -DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403648) -DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3752215) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4551484) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936843) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960853) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977499) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931871) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930389) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946379) +DeviceWalletESIMWalletsTest:test_addESIMWallet_afterRemoveESIMWallet_andETHCallback() (gas: 3752827) +DeviceWalletESIMWalletsTest:test_addESIMWallet_alreadyOwnedBySelf() (gas: 3393897) +DeviceWalletESIMWalletsTest:test_addESIMWallet_clearsTheStandbyMarkerRaisedByTheRelease() (gas: 3449893) +DeviceWalletESIMWalletsTest:test_addESIMWallet_unauthorised() (gas: 3395411) +DeviceWalletESIMWalletsTest:test_addESIMWallet_withoutTransferringOwnership() (gas: 3399640) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsABindWhileOwnershipTransferIsPending() (gas: 3431685) +DeviceWalletESIMWalletsTest:test_bindESIMWallet_rejectsTheZeroAddress() (gas: 3401192) +DeviceWalletESIMWalletsTest:test_deployESIMWallet() (gas: 3388599) +DeviceWalletESIMWalletsTest:test_removeESIMWallet() (gas: 3404209) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_keepsTheRegistrationAndStillAcceptsHistory() (gas: 3471363) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_noETHToCallBack() (gas: 3394253) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_reentrantCallbackCannotPull() (gas: 3774679) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_siblingCannotRemoveAnother() (gas: 3390293) +DeviceWalletESIMWalletsTest:test_removeESIMWallet_unauthorised() (gas: 3395392) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet() (gas: 3458006) +DeviceWalletESIMWalletsTest:test_setESIMUniqueIdentifierForAnESIMWallet_empty() (gas: 3396927) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_letsTheOutgoingDeviceWalletLowerTheMarker() (gas: 3418613) +DeviceWalletESIMWalletsTest:test_toggleESIMWalletStandbyStatus_rejectsAReleaseFromADeviceWalletThatDoesNotHoldIt() (gas: 3403664) +DeviceWalletESIMWalletsTest:test_transferESIMWallet_frontrun() (gas: 3752233) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers() (gas: 4551504) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_bindsAnESIMWalletToAnAlreadyRegisteredWallet() (gas: 936851) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_depositsAndRefundsExactly() (gas: 960857) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_existingIdentifierRefundsItsDeposit() (gas: 977507) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsAReplayOfTheSameEntry() (gas: 931879) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_rejectsARetryUnderANewSalt() (gas: 930397) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_survivesCreateAccountFrontRun() (gas: 946383) DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_withoutAdminOrRegistry() (gas: 76765) -DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959686) +DeviceWalletFactoryBatchDeployTest:test_deployDeviceWalletForUsers_zeroDepositSkipsEntryPoint() (gas: 959690) DeviceWalletFactoryConfigTest:test_addRegistryAddress_onlyOnce() (gas: 22234) DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutAdmin() (gas: 37790) DeviceWalletFactoryConfigTest:test_addRegistryAddress_withoutOwner() (gas: 21191) -DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3875740) +DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation() (gas: 3875744) DeviceWalletFactoryConfigTest:test_updateDeviceWalletImplementation_admin() (gas: 36155) -DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 566044) -DeviceWalletFactoryCreateAccountTest:test_createAccount_anUnregisteredTwinReachesNothing() (gas: 865059) +DeviceWalletFactoryCreateAccountTest:test_createAccount() (gas: 566048) +DeviceWalletFactoryCreateAccountTest:test_createAccount_anUnregisteredTwinReachesNothing() (gas: 865063) DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller() (gas: 382397) DeviceWalletFactoryCreateAccountTest:test_createAccount_byArbitraryCaller_cannotCallPostCreateAccount() (gas: 363023) -DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579683) +DeviceWalletFactoryCreateAccountTest:test_createAccount_callTwice() (gas: 579687) DeviceWalletFactoryCreateAccountTest:test_createAccount_forwardsValueToAnAlreadyDeployedWallet() (gas: 391140) DeviceWalletFactoryCreateAccountTest:test_createAccount_fundsTheWalletAndNotTheDeposit() (gas: 369864) DeviceWalletFactoryCreateAccountTest:test_createAccount_withoutValueLeavesTheWalletUnfunded() (gas: 351721) -DeviceWalletFactoryCreateAccountTest:test_deployDeviceWalletForUsers_adoptsAWalletDeployedByAnyoneElse() (gas: 940047) -DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928361) -DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885393) -DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886830) +DeviceWalletFactoryCreateAccountTest:test_deployDeviceWalletForUsers_adoptsAWalletDeployedByAnyoneElse() (gas: 940051) +DeviceWalletFactoryCreateAccountTest:test_getCounterFactualAddress() (gas: 928365) +DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredIdentifier() (gas: 885401) +DeviceWalletFactoryCreateAccountTest:test_postCreateAccount_revertsOnRegisteredOwnerKey() (gas: 886838) DeviceWalletFactoryGuardsTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 3556215) DeviceWalletFactoryGuardsTest:test_createAccount_rejectsAnEmptyDeviceIdentifier() (gas: 19711) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsADepositAboveTheETHSent() (gas: 53937) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938219) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936150) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownIdentifierUnderADifferentKey() (gas: 938227) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAKnownKeyUnderADifferentIdentifier() (gas: 936158) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortDepositArray() (gas: 58027) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortKeyArray() (gas: 52070) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAShortSaltArray() (gas: 57168) DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyBatch() (gas: 35714) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyDeviceIdentifier() (gas: 57816) -DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975859) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_rejectsAnEmptyDeviceIdentifier() (gas: 57820) +DeviceWalletFactoryGuardsTest:test_deployDeviceWalletForUsers_revertsWhenTheRefundIsRefused() (gas: 975863) DeviceWalletFactoryGuardsTest:test_eSIMWalletAdmin_isEmptyUntilTheRegistryIsAdded() (gas: 3565235) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroESIMWalletFactory() (gas: 3141656) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 3141332) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3142162) DeviceWalletFactoryGuardsTest:test_initialize_rejectsAZeroVerifier() (gas: 3141855) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539403) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412211) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 918036) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressThatIsNotADeviceWallet() (gas: 88221) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressWithNoCode() (gas: 75397) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_recordsAWalletWithMatchingArguments() (gas: 539407) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAKeyTheWalletDoesNotHold() (gas: 412215) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAWalletAlreadyRecorded() (gas: 918040) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressThatIsNotADeviceWallet() (gas: 88225) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnAddressWithNoCode() (gas: 75401) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnEmptyDeviceIdentifier() (gas: 42131) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnIdentifierTheWalletDoesNotCarry() (gas: 408258) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnIdentifierTheWalletDoesNotCarry() (gas: 408262) DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsAnOffCurveOwnerKey() (gas: 368145) -DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsTheWrongSalt() (gas: 391838) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914908) -DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 918049) +DeviceWalletFactoryGuardsTest:test_postCreateAccount_rejectsTheWrongSalt() (gas: 391842) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheIdentifier() (gas: 914912) +DeviceWalletFactoryGuardsTest:test_preCreateAccountValidation_reportsTheWalletHoldingTheOwnerKey() (gas: 918053) DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheCurrentImplementation() (gas: 31777) DeviceWalletFactoryGuardsTest:test_updateDeviceWalletImplementation_rejectsTheZeroAddress() (gas: 19621) DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnOffCurveOwnerKey() (gas: 41069) DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnOutOfFieldOwnerKey() (gas: 38597) DeviceWalletFactoryOwnerKeysTest:test_createAccount_revertsOnZeroOwnerKeyComponent() (gas: 42931) -DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnOffCurveOwnerKey() (gas: 84774) -DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOwnerKey() (gas: 83875) +DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnOffCurveOwnerKey() (gas: 84778) +DeviceWalletFactoryOwnerKeysTest:test_deployDeviceWalletForUsers_revertsOnZeroOwnerKey() (gas: 83879) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnOffCurveOwnerKey() (gas: 19826) DeviceWalletFactoryOwnerKeysTest:test_preCreateAccountValidation_revertsOnZeroOwnerKey() (gas: 19342) -DeviceWalletFundsAccessTest:test_aFreshESIMWalletStartsWithNoFundsAccess() (gas: 1270323) -DeviceWalletFundsAccessTest:test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() (gas: 3393893) -DeviceWalletFundsAccessTest:test_deployESIMWallet_cannotGrantFundsAccess() (gas: 3685526) -DeviceWalletFundsAccessTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402388) -DeviceWalletFundsAccessTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3438004) -DeviceWalletFundsAccessTest:test_getVaultAddress() (gas: 3395922) -DeviceWalletFundsAccessTest:test_getVaultAddress_followsTheRegistry() (gas: 3418733) -DeviceWalletFundsAccessTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4246822) -DeviceWalletFundsAccessTest:test_theOwnerGrantsAfterBinding() (gas: 4019737) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() (gas: 3664164) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() (gas: 3584942) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() (gas: 3611716) -DeviceWalletFundsAccessTest:test_toggleAccessToFunds_unauthorised() (gas: 3396608) -DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961341) -DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954195) +DeviceWalletFundsAccessTest:test_aFreshESIMWalletStartsWithNoFundsAccess() (gas: 1270327) +DeviceWalletFundsAccessTest:test_addESIMWallet_cannotGrantFundsAccessEvenFromTheWalletItself() (gas: 3393909) +DeviceWalletFundsAccessTest:test_deployESIMWallet_cannotGrantFundsAccess() (gas: 3685542) +DeviceWalletFundsAccessTest:test_deviceWallet_rejectsACallToAFunctionItDoesNotHave() (gas: 3402404) +DeviceWalletFundsAccessTest:test_execute_stillMovesOwnerETHWhilePaused() (gas: 3438020) +DeviceWalletFundsAccessTest:test_getVaultAddress() (gas: 3395938) +DeviceWalletFundsAccessTest:test_getVaultAddress_followsTheRegistry() (gas: 3418749) +DeviceWalletFundsAccessTest:test_theAdminCannotUndoARevocationByDeployingAnotherWallet() (gas: 4246838) +DeviceWalletFundsAccessTest:test_theOwnerGrantsAfterBinding() (gas: 4019753) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_grant_deviceWalletHoldsTheToken() (gas: 3664180) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_deviceWalletHoldsTheToken() (gas: 3584960) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_revoke_eSIMWalletHoldsTheToken() (gas: 3611734) +DeviceWalletFundsAccessTest:test_toggleAccessToFunds_unauthorised() (gas: 3396624) +DeviceWalletGuardsTest:test_assignESIMIdentifier_rejectsAnUnknownESIMWallet() (gas: 961345) +DeviceWalletGuardsTest:test_deployESIMWallet_rejectsACallerOtherThanTheAdmin() (gas: 954199) DeviceWalletGuardsTest:test_init_rejectsAZeroESIMWalletFactory() (gas: 117237) DeviceWalletGuardsTest:test_init_rejectsAZeroRegistry() (gas: 117832) DeviceWalletGuardsTest:test_init_rejectsAnEmptyDeviceIdentifier() (gas: 115147) -DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972415) -DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963517) -DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957100) -DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960889) -DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3412019) -DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4423012) -DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3427884) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3402190) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3413055) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402254) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3401997) -DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402322) -DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128867) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458909) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149525) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132110) -DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120521) -DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3394824) +DeviceWalletGuardsTest:test_removeESIMWallet_completesWhenTheETHCallbackReverts() (gas: 972419) +DeviceWalletGuardsTest:test_removeESIMWallet_refusedRemovalLeavesTheRealBindingIntact() (gas: 963521) +DeviceWalletGuardsTest:test_removeESIMWallet_rejectsAnUnknownESIMWallet() (gas: 957104) +DeviceWalletGuardsTest:test_toggleAccessToFunds_rejectsAnUnknownESIMWallet() (gas: 960893) +DeviceWalletOwnerKeyTest:test_transferOwnership_acceptsARotationOntoTheSameKey() (gas: 3412035) +DeviceWalletOwnerKeyTest:test_transferOwnership_freesTheRetiredKeyForANewWallet() (gas: 4423034) +DeviceWalletOwnerKeyTest:test_transferOwnership_movesTheRegistryBindingToTheNewKey() (gas: 3427900) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsACallerOtherThanTheWalletItself() (gas: 3402206) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAKeyAnotherWalletHolds() (gas: 3413071) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOffCurveKey() (gas: 3402270) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsAnOutOfFieldKey() (gas: 3402013) +DeviceWalletOwnerKeyTest:test_transferOwnership_rejectsTheZeroKey() (gas: 3402338) +DeviceWalletSignaturesTest:test_isValidSignature_acceptsAnAssertionSignedForThisMessage() (gas: 1128873) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsASignatureMadeForAnotherWallet() (gas: 1458915) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsATamperedValidUntil() (gas: 1149531) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionPastItsExpiry() (gas: 1132116) +DeviceWalletSignaturesTest:test_isValidSignature_rejectsAnAssertionSignedForAnotherMessage() (gas: 1120527) +DeviceWalletSignaturesTest:test_validateUserOp_shortSignatureFailsGracefully() (gas: 3394840) DeviceWalletSignaturesTest:test_verifySignature_acceptsACapturedDeviceAssertion() (gas: 30674) DeviceWalletTokensTest:test_pullToken_emitsTokenSent() (gas: 77477) DeviceWalletTokensTest:test_pullToken_movesATokenThatReturnsNothing() (gas: 327509) @@ -198,81 +198,81 @@ ESIMWalletFactoryTest:test_addRegistryAddress_onlyOnce() (gas: 25701) ESIMWalletFactoryTest:test_addRegistryAddress_rejectsTheZeroAddress() (gas: 2412279) ESIMWalletFactoryTest:test_addRegistryAddress_withoutOwner() (gas: 21210) ESIMWalletFactoryTest:test_deployESIMWallet() (gas: 343577) -ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837761) -ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324846) +ESIMWalletFactoryTest:test_deployESIMWallet_allowsDeviceWalletToDeployForItself() (gas: 837765) +ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenDeviceWalletNamesAnother() (gas: 1324854) ESIMWalletFactoryTest:test_deployESIMWallet_revertsWhenTheSameOwnerReusesASalt() (gas: 331736) ESIMWalletFactoryTest:test_deployESIMWallet_sameSaltDifferentOwnersDoNotCollide() (gas: 617297) ESIMWalletFactoryTest:test_deployESIMWallet_unauthorised() (gas: 35389) ESIMWalletFactoryTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 2065372) -ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3626995) +ESIMWalletFactoryTest:test_updateESIMWalletImplementation() (gas: 3627001) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_rejectsTheZeroAddress() (gas: 18268) ESIMWalletFactoryTest:test_updateESIMWalletImplementation_unauthorised() (gas: 21012) ESIMWalletGuardsTest:test_initialize_rejectsAZeroDeviceWallet() (gas: 105666) ESIMWalletGuardsTest:test_initialize_rejectsAZeroFactory() (gas: 104832) -ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979538) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 977993) -ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039218) -ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1237924) -ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2396412) -ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2461083) -ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2430083) -ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2401202) -ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411912) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395955) -ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397718) -ESIMWalletTest:test_acceptOwnershipTransfer_theStaleAssociationGrantsTheOldOwnerNothing() (gas: 2412281) -ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510365) -ESIMWalletTest:test_buyDataBundleWithToken_followsTheRotatedAdmin() (gas: 1775289) -ESIMWalletTest:test_buyDataBundleWithToken_recordsTheHistoryBeforeAnythingMoves() (gas: 3023686) -ESIMWalletTest:test_buyDataBundleWithToken_theWalletCapOverridesTheRegistryDefault() (gas: 1795147) -ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2514016) -ESIMWalletTest:test_owner() (gas: 1505564) -ESIMWalletTest:test_populateHistory() (gas: 1795503) -ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906728) -ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844281) -ESIMWalletTest:test_requestTransferOwnership() (gas: 2405125) -ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425492) -ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3245307) -ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3263989) -ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423513) -ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434851) -ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517162) -ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510528) -ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517201) -ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2476473) -ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508495) -ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533440) -ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511323) -ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508246) -ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510494) -ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511042) -ESIMWalletTest:test_transferOwnership() (gas: 1508412) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_aDifferentWalletCanUseTheSameRawReference() (gas: 501771) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 296013) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 282781) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 292501) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_isNotHeldByTheStandbyFlag() (gas: 323249) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 289540) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 304406) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 296326) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 289767) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1504175) +ESIMWalletGuardsTest:test_populateHistory_rejectsACallerOtherThanTheRegistry() (gas: 979542) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_rejectsAnAmountAboveTheBalance() (gas: 977997) +ESIMWalletGuardsTest:test_sendETHToDeviceWallet_revertsWhenTheDeviceWalletRefusesTheETH() (gas: 1039222) +ESIMWalletGuardsTest:test_setPriceCapUSDCents_clearingItReturnsToTheRegistryDefault() (gas: 1237928) +ESIMWalletTest:test_acceptOwnershipTransfer() (gas: 2396422) +ESIMWalletTest:test_acceptOwnershipTransfer_addESIMWallet() (gas: 2461093) +ESIMWalletTest:test_acceptOwnershipTransfer_afterRevoke() (gas: 2430093) +ESIMWalletTest:test_acceptOwnershipTransfer_clearsTheOutgoingOwnersPriceCeiling() (gas: 2401212) +ESIMWalletTest:test_acceptOwnershipTransfer_currentOwner() (gas: 2411922) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsNoCapUpdateWhenThereWasNoCeiling() (gas: 2395965) +ESIMWalletTest:test_acceptOwnershipTransfer_emitsOwnershipTransferredOnce() (gas: 2397728) +ESIMWalletTest:test_acceptOwnershipTransfer_theStaleAssociationGrantsTheOldOwnerNothing() (gas: 2412291) +ESIMWalletTest:test_acceptOwnershipTransfer_withoutRequest() (gas: 1510371) +ESIMWalletTest:test_buyDataBundleWithToken_followsTheRotatedAdmin() (gas: 1775297) +ESIMWalletTest:test_buyDataBundleWithToken_recordsTheHistoryBeforeAnythingMoves() (gas: 3023694) +ESIMWalletTest:test_buyDataBundleWithToken_theWalletCapOverridesTheRegistryDefault() (gas: 1795155) +ESIMWalletTest:test_buyDataBundle_theNewOwnerIsNotBoundByTheOldOwnersCeiling() (gas: 2514026) +ESIMWalletTest:test_owner() (gas: 1505570) +ESIMWalletTest:test_populateHistory() (gas: 1795509) +ESIMWalletTest:test_populateHistory_appendsTheSecondBatchAfterTheFirst() (gas: 1906734) +ESIMWalletTest:test_populateHistory_reportsTheRunningTotal() (gas: 1844287) +ESIMWalletTest:test_requestTransferOwnership() (gas: 2405135) +ESIMWalletTest:test_requestTransferOwnership_reissuingTheSameRequest() (gas: 2425502) +ESIMWalletTest:test_requestTransferOwnership_retargetDropsTheOldNominee() (gas: 3245321) +ESIMWalletTest:test_requestTransferOwnership_retargetsAnOutstandingRequest() (gas: 3264003) +ESIMWalletTest:test_requestTransferOwnership_revoke() (gas: 2423523) +ESIMWalletTest:test_requestTransferOwnership_selfCancelRestoresTheBinding() (gas: 2434861) +ESIMWalletTest:test_requestTransferOwnership_toRandomAddress() (gas: 1517168) +ESIMWalletTest:test_requestTransferOwnership_withoutOwner() (gas: 1510534) +ESIMWalletTest:test_sendETHToDeviceWallet() (gas: 1517207) +ESIMWalletTest:test_sendETHToDeviceWallet_newDeviceWallet() (gas: 2476483) +ESIMWalletTest:test_sendETHToDeviceWallet_unauthorised() (gas: 1508501) +ESIMWalletTest:test_setESIMUniqueIdentifier() (gas: 1533446) +ESIMWalletTest:test_setESIMUniqueIdentifier_callTwiceFail() (gas: 1511329) +ESIMWalletTest:test_setESIMUniqueIdentifier_rejectsTheOwningDeviceWallet() (gas: 1508252) +ESIMWalletTest:test_setESIMUniqueIdentifier_unauthorised() (gas: 1510500) +ESIMWalletTest:test_setPriceCapUSDCents_rejectsTheAdmin() (gas: 1511048) +ESIMWalletTest:test_transferOwnership() (gas: 1508418) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_aDifferentWalletCanUseTheSameRawReference() (gas: 501775) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsACeilingAboveTheQuote() (gas: 296015) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_acceptsTheOwningDeviceWallet() (gas: 282783) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_emitsDataBundleBoughtWithToken() (gas: 292503) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_isNotHeldByTheStandbyFlag() (gas: 323251) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_overwritesTheSettlementItWasHanded() (gas: 289542) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsOnlyTheShortfall() (gas: 304408) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_pullsTheWholePriceFromTheDeviceWallet() (gas: 296328) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_recordsThePurchaseAsWitnessed() (gas: 289769) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_refusesAReentrantCallFromTheToken() (gas: 1504177) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheRegistryCeiling() (gas: 86821) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsAboveTheWalletsOwnCeiling() (gas: 105010) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsCleanlyOnAFeeOnTransferAsset() (gas: 1107710) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 163367) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsCleanlyOnAFeeOnTransferAsset() (gas: 1107712) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAWithdrawnAsset() (gas: 163369) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForAnUnauthorisedCaller() (gas: 75609) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 145687) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 312600) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsForFiat() (gas: 145689) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAReferenceAlreadySpent() (gas: 312604) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAZeroPrice() (gas: 79659) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsOnAnEmptyBundleID() (gas: 79260) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 235887) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 132866) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 147409) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenAccessToTheDeviceWalletIsRevoked() (gas: 235889) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheAdapterIsUnset() (gas: 132868) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhenTheCeilingIsBelowTheQuote() (gas: 147411) ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_revertsWhileTheProtocolIsPaused() (gas: 88475) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 284566) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 286344) -ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_theCeilingDoesNotBoundRepeatedCharges() (gas: 1532348) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_spendsItsOwnBalanceBeforePulling() (gas: 284568) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_stillWorksWithoutAccessWhenFullyFunded() (gas: 286346) +ESIMWalletTokenPurchaseTest:test_buyDataBundleWithToken_theCeilingDoesNotBoundRepeatedCharges() (gas: 1532364) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_emitsTokenSentToDeviceWallet() (gas: 53602) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsATokenTheProtocolNeverPricedIn() (gas: 687121) ESIMWalletTokenPurchaseTest:test_sendTokenToDeviceWallet_returnsTheBalance() (gas: 53854) @@ -311,105 +311,107 @@ GuardianPowersTest:test_unpauseInstantly_rejectsAnAccountWithoutTheGuardianRole( GuardianPowersTest:test_unpauseInstantly_rejectsTheProposer() (gas: 17244) GuardianPowersTest:test_unpauseInstantly_releasesAPauseWithoutWaiting() (gas: 42192) GuardianPowersTest:test_unpauseInstantly_revertsOnATargetWithNoCode() (gas: 20187) -IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1943721) -IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1105800) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2595133) -IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928713) -IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 987909) +IdentifierCollisionTest:test_aReservedDeviceIdentifierSurvivesAnAdminDeployment() (gas: 1945559) +IdentifierCollisionTest:test_assignESIMIdentifier_cannotClaimAnIdentifierReservedForALazyUser() (gas: 1105954) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromADeviceWallet() (gas: 2595145) +IdentifierCollisionTest:test_assignESIMIdentifier_isRefusedFromAnUnrelatedCaller() (gas: 928717) +IdentifierCollisionTest:test_assignESIMIdentifier_recordsTheClaimAndWritesTheWallet() (gas: 987915) IdentifierCollisionTest:test_assignESIMIdentifier_rejectsAWalletTheProtocolDoesNotKnow() (gas: 33971) -IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1225171) -IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008542) -IdentifierCollisionTest:test_postCreateAccount_cannotClaimAReservedDeviceIdentifier() (gas: 562963) -IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1228998) -IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1829908) +IdentifierCollisionTest:test_lazyDeployment_isNotBlockedByItsOwnReservation() (gas: 1226846) +IdentifierCollisionTest:test_populateHistory_refusesAnESIMIdentifierAlreadyLiveOnchain() (gas: 1008567) +IdentifierCollisionTest:test_postCreateAccount_cannotClaimAReservedDeviceIdentifier() (gas: 563115) +IdentifierCollisionTest:test_theLazyRouteCanClaimTheIdentifierItReserved() (gas: 1230673) +IdentifierCollisionTest:test_twoWalletsCannotCarryTheSameESIMIdentifier() (gas: 1829918) ImplementationLocksTest:test_DeviceWalletFactory_implementationCannotBeInitialized() (gas: 3065558) ImplementationLocksTest:test_DeviceWallet_orphanedProxyCannotBeClaimed() (gas: 3357746) ImplementationLocksTest:test_ESIMWalletFactory_implementationCannotBeInitialized() (gas: 1991966) ImplementationLocksTest:test_ESIMWallet_implementationIsLockedAtTheMaximumVersion() (gas: 2499458) -ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3591688) +ImplementationLocksTest:test_LazyWalletRegistry_implementationCannotBeInitialized() (gas: 3797891) ImplementationLocksTest:test_Registry_implementationCannotBeInitialized() (gas: 4281181) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2178302) -LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2168367) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_probedWalletsAreStillBound() (gas: 2180052) +LazySaltCollisionTest:test_deployMoreLazyESIMWallets_survivesAnOccupiedSalt() (gas: 2170117) LazySaltCollisionTest:test_getCounterFactualAddress_matchesTheDeployedAddress() (gas: 325495) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortDataBundleArray() (gas: 35457) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortESIMIdentifierArray() (gas: 34648) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyDeviceIdentifier() (gas: 35399) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyESIMIdentifier() (gas: 40731) -LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsUnpairedESIMsAndBundles() (gas: 40471) -LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsABatchOutsideTheCap() (gas: 215151) -LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsADepositThatDoesNotMatchTheETHSent() (gas: 208055) -LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsASaltWithNoRoomForTheESIMWallets() (gas: 203819) -LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsADeviceItNeverDeployed() (gas: 198334) -LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsANonAdminCaller() (gas: 34223) -LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroRegistry() (gas: 3744186) -LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3744686) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyESIMIdentifier() (gas: 33278) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyNewDeviceIdentifier() (gas: 33394) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfADifferentLength() (gas: 196802) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfTheSameLength() (gas: 198014) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsSwitchingToTheSameDevice() (gas: 197302) -LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenTheIdentifierIsNotFound() (gas: 84432) -LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3229590) -LazyWalletRegistryTest:test_batchPopulateHistory_acceptsAnIdentifierAtTheLimit() (gas: 204715) -LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4422634) -LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6268537) -LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2940807) -LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3288078) -LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3321417) -LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1607341) -LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongDeviceIdentifier() (gas: 46482) -LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongESIMIdentifier() (gas: 60738) -LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 235103) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6230718) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1673912) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6095001) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2203528) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27260955) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12140539) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6062542) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutAdmin() (gas: 44658) -LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutESIMIdentifier() (gas: 51638) -LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6077820) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3531113) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_refusesADeviceWithNoFirstBatch() (gas: 461277) -LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2373493) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6232317) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3234532) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4427752) -LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_unregisteredIdentfier() (gas: 17775) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2032818) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2519811) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1346400) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1348111) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnESIMItNeverDeployed() (gas: 329181) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1346223) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1551439) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6764527) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2771899) -LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2047111) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3333574) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1602422) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6256250) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6253333) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_unregistered() (gas: 37057) -LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_withoutAdmin() (gas: 32465) -NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157788) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortDataBundleArray() (gas: 35458) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAShortESIMIdentifierArray() (gas: 34655) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyDeviceIdentifier() (gas: 35386) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsAnEmptyESIMIdentifier() (gas: 40749) +LazyWalletRegistryGuardsTest:test_batchPopulateHistory_rejectsUnpairedESIMsAndBundles() (gas: 40477) +LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsABatchOutsideTheCap() (gas: 218207) +LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsADepositThatDoesNotMatchTheETHSent() (gas: 209661) +LazyWalletRegistryGuardsTest:test_deployLazyWalletAndSetESIMIdentifier_rejectsASaltWithNoRoomForTheESIMWallets() (gas: 205419) +LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsADeviceItNeverDeployed() (gas: 198484) +LazyWalletRegistryGuardsTest:test_deployMoreESIMWalletsForLazyDevice_rejectsANonAdminCaller() (gas: 34225) +LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroRegistry() (gas: 3939756) +LazyWalletRegistryGuardsTest:test_initialize_rejectsAZeroUpgradeManager() (gas: 3940256) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyESIMIdentifier() (gas: 33267) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnEmptyNewDeviceIdentifier() (gas: 33383) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfADifferentLength() (gas: 196940) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsAnOldDeviceOfTheSameLength() (gas: 198151) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_rejectsSwitchingToTheSameDevice() (gas: 197439) +LazyWalletRegistryGuardsTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenTheIdentifierIsNotFound() (gas: 84429) +LazyWalletRegistryTest:test_batchPopulateHistory() (gas: 3231627) +LazyWalletRegistryTest:test_batchPopulateHistory_acceptsAnIdentifierAtTheLimit() (gas: 204909) +LazyWalletRegistryTest:test_batchPopulateHistory_addNewData() (gas: 4426894) +LazyWalletRegistryTest:test_batchPopulateHistory_afterDeployment() (gas: 6272431) +LazyWalletRegistryTest:test_batchPopulateHistory_duplicateData() (gas: 2942979) +LazyWalletRegistryTest:test_batchPopulateHistory_followsTheRotatedAdmin() (gas: 3290086) +LazyWalletRegistryTest:test_batchPopulateHistory_incorrectIdentifier() (gas: 3323486) +LazyWalletRegistryTest:test_batchPopulateHistory_refusedWhileTheDeviceIsStillDeploying() (gas: 1609231) +LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongDeviceIdentifier() (gas: 46469) +LazyWalletRegistryTest:test_batchPopulateHistory_rejectsAnOverLongESIMIdentifier() (gas: 60781) +LazyWalletRegistryTest:test_batchPopulateHistory_withoutAdmin() (gas: 235262) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier() (gas: 6234502) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_carriesNoHistory() (gas: 1676717) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_fundedDeploySurvivesAFrontRun() (gas: 6098785) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_isRefusedWhilePaused() (gas: 3264899) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_leavesTheDeviceUsableMidDeployment() (gas: 2205570) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_reachesFortyFiveESIMsOverBatches() (gas: 27266969) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_staysCheapAtAFullBatch() (gas: 12144630) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_unfundedDeploySurvivesAFrontRun() (gas: 6066326) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutAdmin() (gas: 44710) +LazyWalletRegistryTest:test_deployLazyWalletAndSetESIMIdentifier_withoutESIMIdentifier() (gas: 53132) +LazyWalletRegistryTest:test_deployLazyWallet_spendsItsWholeDeposit() (gas: 6081604) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_clampsToWhatIsLeft() (gas: 3533214) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_refusesADeviceWithNoFirstBatch() (gas: 461591) +LazyWalletRegistryTest:test_deployMoreESIMWalletsForLazyDevice_revertsOnceEveryWalletExists() (gas: 2375422) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed() (gas: 6236101) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier() (gas: 3236569) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_registeredIdentfier_addNewData() (gas: 4432034) +LazyWalletRegistryTest:test_isDeviceIdentifierAlreadyUsed_unregisteredIdentfier() (gas: 17797) +LazyWalletRegistryTest:test_lazyFollowUpCalls_stillRunWhilePaused() (gas: 4836313) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_copiesTheWholeHistoryInOrder() (gas: 2035548) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_ignoresAWalletClaimingTheSameIdentifier() (gas: 2522050) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsABatchAboveTheCap() (gas: 1348423) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsACallerOtherThanTheAdmin() (gas: 1350134) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnESIMItNeverDeployed() (gas: 329679) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_rejectsAnEmptyBatch() (gas: 1348246) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_revertsOnceTheHistoryIsCopied() (gas: 1553472) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_staysCheapAtAFullBatch() (gas: 6774124) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_survivesAnOwnershipTransfer() (gas: 2774468) +LazyWalletRegistryTest:test_setHistoryForLazyWallet_worksWhileTheDeviceIsStillDeploying() (gas: 2049543) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier() (gas: 3335637) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_refusedWhileTheDeviceIsStillDeploying() (gas: 1604313) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenNewDeviceDeployed() (gas: 6260032) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_revertsWhenOldDeviceDeployed() (gas: 6257080) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_unregistered() (gas: 37069) +LazyWalletRegistryTest:test_switchESIMIdentifierToNewDeviceIdentifier_withoutAdmin() (gas: 32454) +NemesisIdentifierSquatPoC:test_NM007_aDeviceWalletCannotBurnAnIdentifierItHasNoClaimTo() (gas: 157790) NemesisIdentifierSquatPoC:test_NM007_extraESIMWalletsGiveNoRouteIn() (gas: 361617) -NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143208) +NemesisIdentifierSquatPoC:test_NM007_theAdminCannotAssignOneIdentifierTwice() (gas: 143210) NemesisIdentifierSquatPoC:test_NM007_theOldClaimSelectorIsUnreachable() (gas: 13870) NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() (gas: 55549) NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 49500) NemesisPausePoC:test_NM005_suspendingTheAdminRemovesThePauseLever() (gas: 49382) NemesisPausePoC:test_NM005_theProtocolStaysUnpausedWhileThePauseIsUnreachable() (gas: 31162) OwnershipHandoverTest:test_accept_doesNothingForAnEmptyBatch() (gas: 2491228) -OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2675003) -OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671903) +OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2675016) +OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671916) OwnershipHandoverTest:test_accept_rejectsATargetThatOfferedNothing() (gas: 2500075) -OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647697) +OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647710) OwnershipHandoverTest:test_handover_cannotEndWithNoOwnerAtAll() (gas: 74874) -OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292519) -OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2699384) -OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218404) +OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292525) +OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2699402) +OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218411) OwnershipHandoverTest:test_handover_rejectsATransferProposedByAnyoneButTheAdminContract() (gas: 32998) OwnershipHandoverTest:test_pause_canAlsoBeReleasedThroughTheDelayIfNobodyIsInAHurry() (gas: 82980) OwnershipHandoverTest:test_pause_staysWithTheAdminKeyWhileTheReleaseMovesToTheContract() (gas: 45797) @@ -475,11 +477,11 @@ PaymentAdapterTest:test_updateAsset_withdrawsACurrency() (gas: 34405) PaymentAdapterTest:test_upgradeManager_matchesTheOwner() (gas: 13961) PaymentAdapterTest:test_upgrade_keepsTheSpentReferences() (gas: 1700526) PaymentAdapterTest:test_upgrade_rejectsAnyoneButTheOwner() (gas: 1664768) -PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011334) -PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1213632) -PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982762) -PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1132596) -PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814265) +PriceCapSlotMigrationTest:test_migration_aLeftoverCeilingHidesThePaymentAdapterNotSetError() (gas: 1011338) +PriceCapSlotMigrationTest:test_migration_anUnsetCeilingAcceptsAnyPrice() (gas: 1213636) +PriceCapSlotMigrationTest:test_migration_bothCeilingsReadZero() (gas: 982766) +PriceCapSlotMigrationTest:test_migration_settingTheAdapterRecoversFromTheLeftover() (gas: 1132600) +PriceCapSlotMigrationTest:test_migration_settingTheRegistryCeilingCoversEveryWallet() (gas: 814268) PriceCapSlotMigrationTest:test_migration_theOldCeilingSlotIsReadAsTheAdapter() (gas: 11352) ProtocolAdminTest:test_cancel_leavesThePayloadSchedulableAgain() (gas: 96360) ProtocolAdminTest:test_cancel_rejectsAnAccountHoldingNeitherRole() (gas: 56421) @@ -504,8 +506,8 @@ ProtocolAdminTest:test_execute_allowsAnyoneOnceTheDelayHasPassed() (gas: 83527) ProtocolAdminTest:test_execute_rejectsAnOperationStillInsideItsDelay() (gas: 56256) ProtocolAdminTest:test_execute_rejectsAnOperationThatWasNeverScheduled() (gas: 18891) ProtocolAdminTest:test_getMinDelay_startsAtTheConstructorValue() (gas: 9359) -ProtocolAdminTest:test_ownership_movesTheUpgradeAuthorityWithIt() (gas: 29476) -ProtocolAdminTest:test_ownership_reachesAllFourContracts() (gas: 53707) +ProtocolAdminTest:test_ownership_movesTheUpgradeAuthorityWithIt() (gas: 29481) +ProtocolAdminTest:test_ownership_reachesAllFourContracts() (gas: 53712) ProtocolAdminTest:test_roles_cannotBeGrantedDirectlyByAnyone() (gas: 20652) ProtocolAdminTest:test_roles_rotateWithoutTouchingTheProtocolContracts() (gas: 171502) ProtocolAdminTest:test_schedule_rejectsAnAccountWithoutTheProposerRole() (gas: 17735) @@ -515,12 +517,12 @@ ProtocolAdminTest:test_updateDelay_cannotBringTheDelayBelowTheFloor() (gas: 6904 ProtocolAdminTest:test_updateDelay_rejectsACallerThatIsNotTheContractItself() (gas: 12158) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheAdmin() (gas: 25405) RegistryGuardsTest:test_addOrUpdateLazyWalletRegistryAddress_rejectsTheZeroAddress() (gas: 18743) -RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295407) -RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272251) +RegistryGuardsTest:test_assignESIMIdentifier_cannotReachAnImpostor() (gas: 1295411) +RegistryGuardsTest:test_bindESIMWallet_acceptsAWalletTheFactoryDeployed() (gas: 1272255) RegistryGuardsTest:test_bindESIMWallet_rejectsACallerThatIsNotADeviceWallet() (gas: 20718) -RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297677) +RegistryGuardsTest:test_bindESIMWallet_rejectsAnAddressTheFactoryNeverDeployed() (gas: 1297681) RegistryGuardsTest:test_deployLazyWallet_rejectsACallerOtherThanTheLazyWalletRegistry() (gas: 30823) -RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927110) +RegistryGuardsTest:test_deployLazyWallet_rejectsADeviceIdentifierThatAlreadyHasAWallet() (gas: 927114) RegistryGuardsTest:test_deployMoreLazyESIMWallets_rejectsACallerOtherThanTheLazyRegistry() (gas: 23540) RegistryGuardsTest:test_initialize_rejectsAZeroAdmin() (gas: 4402027) RegistryGuardsTest:test_initialize_rejectsAZeroEntryPoint() (gas: 4401164) @@ -537,9 +539,9 @@ RegistryInitializeTest:test_initialize_revertsWhenDeviceWalletFactoryIsZero() (g RegistryInitializeTest:test_initialize_revertsWhenESIMWalletFactoryIsZero() (gas: 4401164) RegistryInitializeTest:test_initialize_revertsWhenPriceCapIsZero() (gas: 4402702) RegistryInitializeTest:test_initialize_revertsWhenVaultIsZero() (gas: 4403301) -RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1771762) -RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922418) -RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770299) +RegistryOwnerGuardsTest:test_bindESIMWallet_rejectsAFormerDeviceWallet() (gas: 1771770) +RegistryOwnerGuardsTest:test_removeESIMWallet_stillRaisesTheStandbyMarker() (gas: 922422) +RegistryOwnerGuardsTest:test_toggleESIMWalletStandbyStatus_rejectsAFormerDeviceWallet() (gas: 1770307) RegistryTest:test_pause_onlyTheAdminCanTripIt() (gas: 47066) RegistryTest:test_pause_survivesAnAdminRotation() (gas: 55263) RegistryTest:test_requireNotPaused_revertsOnlyWhilePaused() (gas: 28433) @@ -552,8 +554,8 @@ RegistryTest:test_updateVaultAddress_rejectsTheCurrentAddress() (gas: 23282) RegistryTest:test_updateVaultAddress_rejectsTheZeroAddress() (gas: 26224) RenounceOwnershipTest:test_renounceOwnership_revertsOnDeviceWalletFactory() (gas: 30236) RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletFactory() (gas: 29394) -RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 924007) -RenounceOwnershipTest:test_renounceOwnership_revertsOnLazyWalletRegistry() (gas: 22029) +RenounceOwnershipTest:test_renounceOwnership_revertsOnESIMWalletViaDeviceWallet() (gas: 924011) +RenounceOwnershipTest:test_renounceOwnership_revertsOnLazyWalletRegistry() (gas: 22041) RenounceOwnershipTest:test_renounceOwnership_revertsOnRegistry() (gas: 22063) RoleRecoveryTest:test_grantRole_keepsALaterGuardianExecutingOnceExecutionIsClosed() (gas: 236681) RoleRecoveryTest:test_grantRole_pairsExecutionWithAGuardianGrantedLater() (gas: 122563) @@ -568,58 +570,58 @@ RoleRecoveryTest:test_recovery_aKeyholderCanStepDownWithoutWaitingForAnyone() (g RoleRecoveryTest:test_recovery_addingTheCancelPowerBackTakesTheFullDelay() (gas: 93088) RoleRecoveryTest:test_recovery_anInstantRevocationBeatsAScheduledGrant() (gas: 94377) RoleRecoveryTest:test_recovery_hasNoRouteAtAllWithASingleProposer() (gas: 2448798) -RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 145227) +RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 145237) RoleRecoveryTest:test_recovery_nobodyCanStepDownOnAnotherAccountsBehalf() (gas: 19003) RoleRecoveryTest:test_revokeRole_evictsAGuardianCompletelyInOneBatch() (gas: 92280) RoleRecoveryTest:test_revokeRole_leavesAnEvictedGuardianAbleToExecute() (gas: 76667) -SettledPurchaseTest:test_buyDataBundleWithToken_acceptsOnceHistoryIsCopied() (gas: 6412579) -SettledPurchaseTest:test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() (gas: 1171827) -SettledPurchaseTest:test_buyDataBundleWithToken_rejectsWhileHistoryIsOutstanding() (gas: 6169097) -SettledPurchaseTest:test_consumePaymentReference_anUnrelatedWalletCannotBlockAnothersSettlement() (gas: 2159021) +SettledPurchaseTest:test_buyDataBundleWithToken_acceptsOnceHistoryIsCopied() (gas: 6416469) +SettledPurchaseTest:test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() (gas: 1171831) +SettledPurchaseTest:test_buyDataBundleWithToken_rejectsWhileHistoryIsOutstanding() (gas: 6172956) +SettledPurchaseTest:test_consumePaymentReference_anUnrelatedWalletCannotBlockAnothersSettlement() (gas: 2159029) SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19369) -SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1128978) -SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6292392) -SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1109145) -SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1073678) -SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1128586) -SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1124960) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002343) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1054134) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1197414) -SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1114520) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037795) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002544) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1003143) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 998190) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021416) +SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1128982) +SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6296284) +SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1109149) +SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1073682) +SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1128590) +SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1124964) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002347) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1054138) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1197418) +SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1114524) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037799) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002548) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1003147) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 998194) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021420) SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49252) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005239) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6083494) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009977) -SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1103030) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005243) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6087353) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009981) +SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1103034) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24311) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20846) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20664) -SettledPurchaseTest:test_setPaymentAdapter_rotationDoesNotReviveASpentReference() (gas: 2993580) +SettledPurchaseTest:test_setPaymentAdapter_rotationDoesNotReviveASpentReference() (gas: 2993584) SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29838) StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21373) -StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544338) +StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544342) StorageLayoutTest:test_layout_eSIMWalletFactorySlotsAreUnchanged() (gas: 16047) -StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 855086) -StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34461) +StorageLayoutTest:test_layout_eSIMWalletSlotsAreUnchanged() (gas: 855090) +StorageLayoutTest:test_layout_lazyWalletRegistrySlotsAreUnchanged() (gas: 34485) StorageLayoutTest:test_layout_paymentAdapterSlotsAreUnchanged() (gas: 18652) StorageLayoutTest:test_layout_registrySlotsAreUnchanged() (gas: 57251) UpgradeAuthorityTest:test_upgradeManager_acceptsAContractAsTheUpgradeAuthority() (gas: 4460664) -UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWalletRegistry() (gas: 38767) +UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheLazyWalletRegistry() (gas: 38785) UpgradeAuthorityTest:test_upgradeManager_followsAnOwnershipTransferOnTheRegistry() (gas: 39845) -UpgradeAuthorityTest:test_upgradeManager_matchesTheOwner() (gas: 29385) +UpgradeAuthorityTest:test_upgradeManager_matchesTheOwner() (gas: 29395) UpgradeAuthorityTest:test_upgradeManager_theRetiredOwnerCannotUpgrade() (gas: 4302092) -UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975868) -UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967360) -UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976381) -UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938395) -UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 976044) -UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 947303) +UserOpValidationTest:test_handleOps_acceptsAnOperationSignedByTheOwner() (gas: 975872) +UserOpValidationTest:test_handleOps_rejectsASignatureMadeForAnotherOperation() (gas: 967364) +UserOpValidationTest:test_handleOps_rejectsAnExpiredOperation() (gas: 976385) +UserOpValidationTest:test_handleOps_rejectsAnUnparseableSignature() (gas: 938399) +UserOpValidationTest:test_validateUserOp_acceptsANonZeroValidUntil() (gas: 976048) +UserOpValidationTest:test_validateUserOp_rejectsAZeroValidUntil() (gas: 947307) WebAuthnTest:test_verifySignature_rejectsARegistrationCeremonyType() (gas: 20365) WebAuthnTest:test_verifySignature_rejectsAbsentUserPresentFlag() (gas: 29039) WebAuthnTest:test_verifySignature_rejectsAbsentUserVerifiedFlagWhenRequired() (gas: 28673) diff --git a/contracts/LazyWalletRegistry.sol b/contracts/LazyWalletRegistry.sol index 7e16aa90..a72bfc65 100644 --- a/contracts/LazyWalletRegistry.sol +++ b/contracts/LazyWalletRegistry.sol @@ -257,6 +257,12 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra /// bought. Switching an eSIM to another device is refused for the whole time the rest are /// outstanding, which `switchESIMIdentifierToNewDeviceIdentifier` already does by refusing /// any device that has a wallet. + /// + /// Refused while the protocol is paused. This is the one lazy route that moves ETH, so it + /// is held for the same reason the purchase and pull paths are. Its two siblings, + /// `deployMoreESIMWalletsForLazyDevice` and `setHistoryForLazyWallet`, move none and run + /// through a pause deliberately, so a stopped protocol can still finish a device already + /// half deployed. /// @param _deviceOwnerPublicKey P256 public key of the device owner /// @param _deviceUniqueIdentifier Unique device identifier associated with the device /// @param _salt Salt the whole deployment derives its eSIM wallet addresses from @@ -276,6 +282,7 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra address[] memory eSIMWallets, uint256 remaining ) { + registry.requireNotPaused(); if(_depositAmount != msg.value) revert Errors.DepositDoesNotMatchValue(_depositAmount, msg.value); if(registry.isDeviceIdentifierAlreadyUsed(_deviceUniqueIdentifier)) { revert Errors.LazyWalletAlreadyDeployed(_deviceUniqueIdentifier); diff --git a/snapshots/DeviceWalletFactory.Operations.json b/snapshots/DeviceWalletFactory.Operations.json index 17c6a617..98798b68 100644 --- a/snapshots/DeviceWalletFactory.Operations.json +++ b/snapshots/DeviceWalletFactory.Operations.json @@ -1,8 +1,8 @@ { "createAccount: first wallet, cold factory storage": "326339", "createAccount: second wallet, warm factory storage": "308839", - "deployDeviceWalletForUsers: batch of 1": "881880", - "deployDeviceWalletForUsers: batch of 1, funded": "889697", - "deployDeviceWalletForUsers: batch of 5": "4147772", - "postCreateAccount: registering a wallet": "173975" + "deployDeviceWalletForUsers: batch of 1": "881884", + "deployDeviceWalletForUsers: batch of 1, funded": "889701", + "deployDeviceWalletForUsers: batch of 5": "4147792", + "postCreateAccount: registering a wallet": "173979" } \ No newline at end of file diff --git a/snapshots/LazyWalletRegistry.Operations.json b/snapshots/LazyWalletRegistry.Operations.json index 4391d0bc..9114bba5 100644 --- a/snapshots/LazyWalletRegistry.Operations.json +++ b/snapshots/LazyWalletRegistry.Operations.json @@ -1,14 +1,14 @@ { - "batchPopulateHistory: 1 device, 5 eSIMs": "668758", - "batchPopulateHistory: 5 devices, 22 eSIMs": "2596984", - "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1013302", - "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5004093", - "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9489643", - "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2763403", - "deployMoreESIMWalletsForLazyDevice: batch of 1": "462841", - "deployMoreESIMWalletsForLazyDevice: batch of 18": "8065123", - "deployMoreESIMWalletsForLazyDevice: batch of 5": "2250014", - "setHistoryForLazyWallet: 1 entry": "107228", - "setHistoryForLazyWallet: 10 entries": "493975", - "setHistoryForLazyWallet: 39 entries": "1881589" + "batchPopulateHistory: 1 device, 5 eSIMs": "669190", + "batchPopulateHistory: 5 devices, 22 eSIMs": "2599140", + "deployLazyWalletAndSetESIMIdentifier: batch of 1": "1014829", + "deployLazyWalletAndSetESIMIdentifier: batch of 10": "5006115", + "deployLazyWalletAndSetESIMIdentifier: batch of 20": "9492215", + "deployLazyWalletAndSetESIMIdentifier: batch of 5": "2765150", + "deployMoreESIMWalletsForLazyDevice: batch of 1": "462845", + "deployMoreESIMWalletsForLazyDevice: batch of 18": "8065161", + "deployMoreESIMWalletsForLazyDevice: batch of 5": "2250026", + "setHistoryForLazyWallet: 1 entry": "107261", + "setHistoryForLazyWallet: 10 entries": "494035", + "setHistoryForLazyWallet: 39 entries": "1881736" } \ No newline at end of file diff --git a/snapshots/ProtocolAdmin.Operations.json b/snapshots/ProtocolAdmin.Operations.json index 7c83e86d..4ea4cc7f 100644 --- a/snapshots/ProtocolAdmin.Operations.json +++ b/snapshots/ProtocolAdmin.Operations.json @@ -1,10 +1,10 @@ { - "acceptOwnershipBatch: four contracts": "36630", + "acceptOwnershipBatch: four contracts": "36637", "cancel": "4694", "execute: one call, delay served": "23558", "executeBatch: both wallet beacons": "69122", "executeBatch: four calls, delay served": "45642", - "executeBatch: four proxy upgrades": "87471", + "executeBatch: four proxy upgrades": "87475", "revokeCancellersInstantly: one account": "11893", "revokeCancellersInstantly: three accounts": "27789", "schedule: one call": "32998", diff --git a/snapshots/Registry.Operations.json b/snapshots/Registry.Operations.json index 2ea458c1..39bb3548 100644 --- a/snapshots/Registry.Operations.json +++ b/snapshots/Registry.Operations.json @@ -1,6 +1,6 @@ { "acceptAdminUpdate": "4964", - "assignESIMIdentifier: first time": "64808", + "assignESIMIdentifier: first time": "64810", "pause": "13963", "recordSettledPurchase: first for the wallet": "122013", "recordSettledPurchase: second for the same wallet": "87113", diff --git a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol index c4e1a977..a89a9a6d 100644 --- a/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol +++ b/test/foundry/unit-testing/registry/LazyWalletRegistry.t.sol @@ -414,6 +414,67 @@ contract LazyWalletRegistryTest is DeployerBase { ); } + /// @notice The pause reaches the one lazy route that moves ETH + /// @dev It forwards the admin's deposit into a device wallet it creates in the same call, which + /// is the same kind of movement `buyDataBundleWithToken` and `pullToken` are held for. + /// Before the check it ran through a pause and the deposit landed. + function test_deployLazyWalletAndSetESIMIdentifier_isRefusedWhilePaused() public { + test_batchPopulateHistory(); + + vm.prank(eSIMWalletAdmin); + registry.pause(); + + vm.deal(eSIMWalletAdmin, 2 ether); + vm.prank(eSIMWalletAdmin); + vm.expectRevert(Errors.ProtocolPaused.selector); + lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier{value: 2 ether}( + pubKey1, + customDeviceUniqueIdentifiers[0], + 5501, + 2 ether, + FULL_BATCH + ); + + assertEq(eSIMWalletAdmin.balance, 2 ether, "The deposit must stay with the caller"); + assertEq( + registry.uniqueIdentifierToDeviceWallet(customDeviceUniqueIdentifiers[0]), + address(0), + "No device wallet must have been created" + ); + } + + /// @notice A device already half deployed can still be finished while the protocol is paused + /// @dev Deliberate, and pinned here so the pause is not extended to these two later. Neither + /// moves ETH, and a device left with some of its eSIM wallets and none of its history is + /// worse for the user than one completed under a pause that stops every path to money. + function test_lazyFollowUpCalls_stillRunWhilePaused() public { + test_batchPopulateHistory(); + + string memory deviceIdentifier = customDeviceUniqueIdentifiers[0]; + + vm.prank(eSIMWalletAdmin); + (, address[] memory firstBatch, uint256 remaining) = lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier( + pubKey1, + deviceIdentifier, + 5601, + 0, + 1 + ); + assertGt(remaining, 0, "The device must still be waiting on eSIM wallets"); + + vm.prank(eSIMWalletAdmin); + registry.pause(); + + vm.prank(eSIMWalletAdmin); + (address[] memory secondBatch,) = lazyWalletRegistry.deployMoreESIMWalletsForLazyDevice(deviceIdentifier, 1); + assertEq(secondBatch.length, 1, "The next batch must still deploy"); + + vm.prank(eSIMWalletAdmin); + (uint256 copied,) = lazyWalletRegistry.setHistoryForLazyWallet(customESIMUniqueIdentifiers[0][0], 10); + assertGt(copied, 0, "History must still reach the first wallet"); + assertGt(firstBatch.length, 0, "The first batch must have deployed something to copy into"); + } + function test_batchPopulateHistory_afterDeployment() public { test_deployLazyWalletAndSetESIMIdentifier(); From 81c18ac115b29faf2814b62df116415dd5672a9f Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 31 Aug 2026 17:47:45 +0530 Subject: [PATCH 63/75] Say what the ownership handover event does and does not prove 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. --- .gas-snapshot | 23 +++++++------ contracts/admin/ProtocolAdmin.sol | 8 +++++ .../admin/OwnershipHandover.t.sol | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 734f3d3d..96d7281e 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -403,18 +403,19 @@ NemesisIdentifierSquatPoC:test_NM007_theOwnerCannotWriteTheWalletFieldItself() ( NemesisPausePoC:test_NM005_anOutstandingNominationAlsoRemovesIt() (gas: 49500) NemesisPausePoC:test_NM005_suspendingTheAdminRemovesThePauseLever() (gas: 49382) NemesisPausePoC:test_NM005_theProtocolStaysUnpausedWhileThePauseIsUnreachable() (gas: 31162) -OwnershipHandoverTest:test_accept_doesNothingForAnEmptyBatch() (gas: 2491228) -OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2675016) -OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671916) -OwnershipHandoverTest:test_accept_rejectsATargetThatOfferedNothing() (gas: 2500075) -OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647710) -OwnershipHandoverTest:test_handover_cannotEndWithNoOwnerAtAll() (gas: 74874) -OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292525) -OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2699402) -OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218411) +OwnershipHandoverTest:test_accept_doesNothingForAnEmptyBatch() (gas: 2491250) +OwnershipHandoverTest:test_accept_emitsForATargetTheProtocolNeverOwned() (gas: 2593078) +OwnershipHandoverTest:test_accept_emitsOncePerTarget() (gas: 2675094) +OwnershipHandoverTest:test_accept_isOpenToAnyoneOnceTheOfferIsMade() (gas: 2671938) +OwnershipHandoverTest:test_accept_rejectsATargetThatOfferedNothing() (gas: 2500097) +OwnershipHandoverTest:test_accept_takesNothingWhenOneTargetInTheBatchOfferedNothing() (gas: 2647770) +OwnershipHandoverTest:test_handover_cannotEndWithNoOwnerAtAll() (gas: 74896) +OwnershipHandoverTest:test_handover_leavesOwnershipInPlaceUntilTheDestinationAccepts() (gas: 292547) +OwnershipHandoverTest:test_handover_movesToAReplacementAdminContract() (gas: 2699424) +OwnershipHandoverTest:test_handover_movesToAnAccountHoldingTheOwnerSlotDirectly() (gas: 218428) OwnershipHandoverTest:test_handover_rejectsATransferProposedByAnyoneButTheAdminContract() (gas: 32998) -OwnershipHandoverTest:test_pause_canAlsoBeReleasedThroughTheDelayIfNobodyIsInAHurry() (gas: 82980) -OwnershipHandoverTest:test_pause_staysWithTheAdminKeyWhileTheReleaseMovesToTheContract() (gas: 45797) +OwnershipHandoverTest:test_pause_canAlsoBeReleasedThroughTheDelayIfNobodyIsInAHurry() (gas: 83002) +OwnershipHandoverTest:test_pause_staysWithTheAdminKeyWhileTheReleaseMovesToTheContract() (gas: 45819) P256VerificationTest:test_verifySignature_acceptsARealAssertion() (gas: 32871) P256VerificationTest:test_verifySignature_rejectsAnAssertionMadeForAnotherChallenge() (gas: 24383) P256VerificationTest:test_verify_bothPathsAcceptAValidSignature() (gas: 236037) diff --git a/contracts/admin/ProtocolAdmin.sol b/contracts/admin/ProtocolAdmin.sol index fa5affcd..f4acb38a 100644 --- a/contracts/admin/ProtocolAdmin.sol +++ b/contracts/admin/ProtocolAdmin.sol @@ -309,6 +309,14 @@ contract ProtocolAdmin is TimelockController { /// Each event is emitted ahead of the call it describes, so a target that emits its own /// events on handover cannot interleave them out of order. A failing handover takes the /// whole batch down with it, so no event here can outlive the call it announced. + /// + /// `OwnershipAccepted` names an address the caller chose, and anyone can call this with a + /// contract they wrote that answers this address to `pendingOwner()` and returns quietly + /// from `acceptOwnership()`. Both answers come from the target, so no check here can tell + /// a protocol contract from one written to look like it. Read the event as a claim about + /// an address, and filter it against the contracts this one is meant to own. It costs the + /// protocol nothing beyond the log line: this contract holds no funds, and the powers it + /// does hold are gated on roles a target cannot obtain by being called. /// @param targets Contracts whose `pendingOwner` is this address function acceptOwnershipBatch(address[] calldata targets) external { for(uint256 i = 0; i < targets.length; ++i) { diff --git a/test/foundry/unit-testing/admin/OwnershipHandover.t.sol b/test/foundry/unit-testing/admin/OwnershipHandover.t.sol index 06483039..c89cbad8 100644 --- a/test/foundry/unit-testing/admin/OwnershipHandover.t.sol +++ b/test/foundry/unit-testing/admin/OwnershipHandover.t.sol @@ -211,6 +211,28 @@ contract OwnershipHandoverTest is AdminBase { assertEq(registry.owner(), address(protocolAdmin)); } + /// @notice `OwnershipAccepted` names whatever address the caller passed in + /// @dev Both answers the batch checks come from the target, so a contract written to give them + /// passes. Nothing onchain can tell it from a protocol contract, which is why the event is + /// a claim about an address rather than proof the protocol took anything. Anyone reading + /// the log has to filter it against the contracts this one is meant to own. + function test_accept_emitsForATargetTheProtocolNeverOwned() public { + ProtocolAdmin next = _deployReplacement(7 days); + ImpostorTarget impostor = new ImpostorTarget(address(next)); + + address[] memory targets = new address[](1); + targets[0] = address(impostor); + + vm.expectEmit(true, false, false, false, address(next)); + emit ProtocolAdmin.OwnershipAccepted(address(impostor)); + + vm.prank(outsider); + next.acceptOwnershipBatch(targets); + + assertEq(registry.owner(), address(protocolAdmin), "The real contracts must be untouched"); + assertEq(next.getMinDelay(), 7 days, "and the caller must have gained nothing"); + } + /// @notice Offers all four contracts to one address, in a single operation function _offerAllFourTo(address _destination) private { address[] memory targets = _ownedContracts(); @@ -230,3 +252,15 @@ contract OwnershipHandoverTest is AdminBase { protocolAdmin.executeBatch(targets, values, payloads, bytes32(0), bytes32(0)); } } + +/// @notice Answers the two questions `acceptOwnershipBatch` asks, while owing the protocol nothing +contract ImpostorTarget { + address public pendingOwner; + + constructor(address _pendingOwner) { + pendingOwner = _pendingOwner; + } + + // solhint-disable-next-line no-empty-blocks + function acceptOwnership() external {} +} From 6c26c1abbea97d1442f4035b67d904fa9b900aff Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 31 Aug 2026 17:47:53 +0530 Subject: [PATCH 64/75] Say why the history backfill is not held to the price ceiling 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. --- .gas-snapshot | 53 ++++++++++--------- contracts/esim-wallet/ESIMWallet.sol | 6 +++ .../registry/SettledPurchase.t.sol | 53 +++++++++++++++++++ 3 files changed, 86 insertions(+), 26 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index 96d7281e..0bf8ffb5 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -575,35 +575,36 @@ RoleRecoveryTest:test_recovery_leavesTheProtocolContractsUntouched() (gas: 14523 RoleRecoveryTest:test_recovery_nobodyCanStepDownOnAnotherAccountsBehalf() (gas: 19003) RoleRecoveryTest:test_revokeRole_evictsAGuardianCompletelyInOneBatch() (gas: 92280) RoleRecoveryTest:test_revokeRole_leavesAnEvictedGuardianAbleToExecute() (gas: 76667) -SettledPurchaseTest:test_buyDataBundleWithToken_acceptsOnceHistoryIsCopied() (gas: 6416469) -SettledPurchaseTest:test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() (gas: 1171831) -SettledPurchaseTest:test_buyDataBundleWithToken_rejectsWhileHistoryIsOutstanding() (gas: 6172956) -SettledPurchaseTest:test_consumePaymentReference_anUnrelatedWalletCannotBlockAnothersSettlement() (gas: 2159029) +SettledPurchaseTest:test_buyDataBundleWithToken_acceptsOnceHistoryIsCopied() (gas: 6416565) +SettledPurchaseTest:test_buyDataBundleWithToken_rejectsAReferenceSpentBySettlement() (gas: 1171826) +SettledPurchaseTest:test_buyDataBundleWithToken_rejectsWhileHistoryIsOutstanding() (gas: 6173051) +SettledPurchaseTest:test_consumePaymentReference_anUnrelatedWalletCannotBlockAnothersSettlement() (gas: 2159053) SettledPurchaseTest:test_consumePaymentReference_rejectsAnyoneButAnESIMWallet() (gas: 19369) -SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1128982) -SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6296284) -SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1109149) -SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1073682) -SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1128590) -SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1124964) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002347) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1054138) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1197418) -SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1114524) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037799) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002548) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1003147) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 998194) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021420) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49252) -SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005243) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6087353) -SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1009981) -SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1103034) +SettledPurchaseTest:test_recordSettledPurchase_acceptsAWalletWithNoIdentifier() (gas: 1128945) +SettledPurchaseTest:test_recordSettledPurchase_acceptsOnceHistoryIsCopied() (gas: 6296379) +SettledPurchaseTest:test_recordSettledPurchase_appendsToTheHistory() (gas: 1109112) +SettledPurchaseTest:test_recordSettledPurchase_followsTheWalletsOwnCeiling() (gas: 1073709) +SettledPurchaseTest:test_recordSettledPurchase_movesNoETH() (gas: 1128553) +SettledPurchaseTest:test_recordSettledPurchase_recordsAnExternalWallet() (gas: 1124937) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAClaimOfAnOnchainPayment() (gas: 1002320) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAPriceAboveTheCeiling() (gas: 1054240) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAReferenceSpentByAPurchase() (gas: 1197476) +SettledPurchaseTest:test_recordSettledPurchase_rejectsARetryOfAPurchaseThatLanded() (gas: 1114519) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAWithdrawnCurrency() (gas: 1037804) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAZeroPrice() (gas: 1002552) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyBundleID() (gas: 1003151) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnEmptyReference() (gas: 998199) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownCurrency() (gas: 1021447) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnUnknownWallet() (gas: 49284) +SettledPurchaseTest:test_recordSettledPurchase_rejectsAnyoneButTheAdmin() (gas: 1005270) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhileHistoryIsOutstanding() (gas: 6087471) +SettledPurchaseTest:test_recordSettledPurchase_rejectsWhilePaused() (gas: 1010008) +SettledPurchaseTest:test_recordSettledPurchase_spendsTheReference() (gas: 1102997) +SettledPurchaseTest:test_setHistoryForLazyWallet_copiesAPriceAboveTheCeiling() (gas: 1403748) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAddressAlreadySet() (gas: 24311) SettledPurchaseTest:test_setPaymentAdapter_rejectsTheAdmin() (gas: 20846) -SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20664) -SettledPurchaseTest:test_setPaymentAdapter_rotationDoesNotReviveASpentReference() (gas: 2993584) +SettledPurchaseTest:test_setPaymentAdapter_rejectsTheZeroAddress() (gas: 20686) +SettledPurchaseTest:test_setPaymentAdapter_rotationDoesNotReviveASpentReference() (gas: 2993578) SettledPurchaseTest:test_setPaymentAdapter_storesTheAddress() (gas: 29838) StorageLayoutTest:test_layout_deviceWalletFactorySlotsAreUnchanged() (gas: 21373) StorageLayoutTest:test_layout_deviceWalletSlotsAreUnchanged() (gas: 544342) diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 1a813f85..14f35658 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -206,6 +206,12 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// wallet registry /// @dev The registry carries the cursor that says how much of an eSIM's history has already been /// copied, so this function appends whatever it is handed and does not police repeats. + /// + /// Not held to the price ceiling, unlike `recordSettledPurchase`. These entries are a + /// record of what the user already paid before any of this existed, so there is nothing + /// here for a ceiling to bound: the ceiling limits what the admin can charge, and no + /// charge happens on this path. Refusing an entry priced above today's ceiling would only + /// stop true history from being written. /// @param _dataBundleDetails One batch of data bundle purchase details from before the wallet /// was deployed /// @return True once the batch has been appended diff --git a/test/foundry/unit-testing/registry/SettledPurchase.t.sol b/test/foundry/unit-testing/registry/SettledPurchase.t.sol index 08991d75..75ac6b63 100644 --- a/test/foundry/unit-testing/registry/SettledPurchase.t.sol +++ b/test/foundry/unit-testing/registry/SettledPurchase.t.sol @@ -320,6 +320,59 @@ contract SettledPurchaseTest is DeployerBase { ); } + /// @notice The backfill is not held to the price ceiling, and deliberately so + /// @dev The ceiling bounds what the admin can charge, and this path charges nothing: the + /// entries record what the user paid before any of this existed. Capping it would only + /// stop true history from being written once the ceiling moved. Pinned because the + /// asymmetry with `recordSettledPurchase` reads like an oversight and is not one. + function test_setHistoryForLazyWallet_copiesAPriceAboveTheCeiling() public { + uint64 aboveTheCap = defaultPriceCapUSDCents + 1; + (string memory eSIMIdentifier, MockESIMWallet lazyWallet) = _lazyDeployPricedAt(aboveTheCap); + + vm.prank(eSIMWalletAdmin); + (uint256 copied, uint256 remaining) = lazyWalletRegistry.setHistoryForLazyWallet(eSIMIdentifier, FULL_BATCH); + + assertEq(copied, 1, "The entry must copy through"); + assertEq(remaining, 0, "and nothing must be left waiting"); + + (, uint64 price,) = lazyWallet.transactionHistory(0); + assertEq(price, aboveTheCap, "The recorded price must be what was actually paid"); + + // The same price is still refused where money would actually move + vm.prank(eSIMWalletAdmin); + vm.expectRevert(abi.encodeWithSelector( + Errors.DataBundlePriceAboveCap.selector, aboveTheCap, defaultPriceCapUSDCents + )); + registry.recordSettledPurchase( + address(lazyWallet), bundle("DB_CHARGE", aboveTheCap), ASSET_USDC, 1e6, nextRef() + ); + } + + /// @notice Binds one eSIM with a single purchase at the given price and deploys its wallet + /// @return The eSIM identifier the history is stored under, and the wallet holding it + function _lazyDeployPricedAt(uint64 _priceUSDCents) internal returns (string memory, MockESIMWallet) { + string memory device = "Device_Ceiling"; + string memory eSIM = "eSIM_Ceiling_1"; + + string[] memory devices = new string[](1); + devices[0] = device; + + string[][] memory eSIMs = new string[][](1); + eSIMs[0] = new string[](1); + eSIMs[0][0] = eSIM; + + DataBundleDetails[][] memory bundles = new DataBundleDetails[][](1); + bundles[0] = new DataBundleDetails[](1); + bundles[0][0] = bundle("DB_CEILING", _priceUSDCents); + + vm.startPrank(eSIMWalletAdmin); + lazyWalletRegistry.batchPopulateHistory(devices, eSIMs, bundles); + lazyWalletRegistry.deployLazyWalletAndSetESIMIdentifier(pubKey2, device, 7701, 0, FULL_BATCH); + vm.stopPrank(); + + return (eSIM, MockESIMWallet(payable(lazyWalletRegistry.lazyDeployedESIMWallet(eSIM)))); + } + // --------------------------------------------------------------------------------------------- // Payment references // --------------------------------------------------------------------------------------------- From 07a64af7f5ad309914be8b470607877f35542632 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Mon, 31 Aug 2026 23:19:34 +0530 Subject: [PATCH 65/75] Add Echidna and Medusa stateful fuzz suite 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. --- .gitignore | 6 + PROPERTIES.md | 145 ++++ echidna.yaml | 18 + foundry.toml | 21 + medusa.json | 93 +++ test/fizz/Actor.sol | 63 ++ test/fizz/Base.sol | 629 ++++++++++++++++++ test/fizz/FoundryTester.sol | 161 +++++ test/fizz/FuzzTester.sol | 11 + test/fizz/Properties.sol | 356 ++++++++++ test/fizz/README.md | 105 +++ test/fizz/Snapshots.sol | 48 ++ .../handlers/DeviceWalletFactoryHandler.sol | 161 +++++ test/fizz/handlers/DeviceWalletHandler.sol | 180 +++++ .../handlers/ESIMWalletFactoryHandler.sol | 71 ++ test/fizz/handlers/ESIMWalletHandler.sol | 302 +++++++++ test/fizz/handlers/Handlers.sol | 27 + .../handlers/LazyWalletRegistryHandler.sol | 203 ++++++ test/fizz/handlers/PaymentAdapterHandler.sol | 212 ++++++ test/fizz/handlers/RegistryHandler.sol | 241 +++++++ test/fizz/utils/Clamp.sol | 223 +++++++ test/fizz/utils/DecimalPrinter.sol | 63 ++ test/fizz/utils/Deployer.sol | 17 + test/fizz/utils/EnumerableSet.sol | 206 ++++++ test/fizz/utils/Hevm.sol | 62 ++ test/fizz/utils/Logger.sol | 619 +++++++++++++++++ test/fizz/utils/Math.sol | 52 ++ test/fizz/utils/MockERC20.sol | 279 ++++++++ test/fizz/utils/PropertiesAsserts.sol | 238 +++++++ test/fizz/utils/StringUtils.sol | 85 +++ 30 files changed, 4897 insertions(+) create mode 100644 PROPERTIES.md create mode 100644 echidna.yaml create mode 100644 medusa.json create mode 100644 test/fizz/Actor.sol create mode 100644 test/fizz/Base.sol create mode 100644 test/fizz/FoundryTester.sol create mode 100644 test/fizz/FuzzTester.sol create mode 100644 test/fizz/Properties.sol create mode 100644 test/fizz/README.md create mode 100644 test/fizz/Snapshots.sol create mode 100644 test/fizz/handlers/DeviceWalletFactoryHandler.sol create mode 100644 test/fizz/handlers/DeviceWalletHandler.sol create mode 100644 test/fizz/handlers/ESIMWalletFactoryHandler.sol create mode 100644 test/fizz/handlers/ESIMWalletHandler.sol create mode 100644 test/fizz/handlers/Handlers.sol create mode 100644 test/fizz/handlers/LazyWalletRegistryHandler.sol create mode 100644 test/fizz/handlers/PaymentAdapterHandler.sol create mode 100644 test/fizz/handlers/RegistryHandler.sol create mode 100644 test/fizz/utils/Clamp.sol create mode 100644 test/fizz/utils/DecimalPrinter.sol create mode 100644 test/fizz/utils/Deployer.sol create mode 100644 test/fizz/utils/EnumerableSet.sol create mode 100644 test/fizz/utils/Hevm.sol create mode 100644 test/fizz/utils/Logger.sol create mode 100644 test/fizz/utils/Math.sol create mode 100644 test/fizz/utils/MockERC20.sol create mode 100644 test/fizz/utils/PropertiesAsserts.sol create mode 100644 test/fizz/utils/StringUtils.sol diff --git a/.gitignore b/.gitignore index c5decb33..689f214a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ artifacts/ cache/ out/ +out-fuzz/ forge-cache/ # Written by every `forge script --broadcast` run. The deployment record kept on purpose is @@ -23,3 +24,8 @@ lcov.info # Editor settings are per developer. The remappings inside are generated from remappings.txt # anyway, so a stale copy here is worse than no copy. .vscode/ + +# What the fizz skill generates: contract metadata, entry-point selection, cost estimates, +# coverage targets, campaign reports. Regenerated by the skill, kept local rather than committed. +fizz_data/ +crytic-export/ diff --git a/PROPERTIES.md b/PROPERTIES.md new file mode 100644 index 00000000..f90f0506 --- /dev/null +++ b/PROPERTIES.md @@ -0,0 +1,145 @@ +# Properties + +What the Echidna and Medusa campaign in `test/fizz/` asserts about the protocol. + +Each entry carries a **Guarantee**: `SHOULD-HOLD` means docs, a single-writer guard or an exact +identity say it must be true, and a violation is a bug. `EXPLORATORY` means it was inferred, and a +violation is a lead for a person to judge rather than a confirmed defect. + +Run them with `medusa fuzz` or `echidna . --contract FuzzTester --config echidna.yaml`. + +## Global + +Checked after every call in a sequence. These iterate the wallets the campaign has built, so they +stay O(n) in the population and never O(n squared). + +- [x] **GL-01 — an eSIM wallet's purchase history is append-only, entry for entry.** + Once an entry is seen at index `i`, its id, price and settlement never change again, and the list + never shortens. SHOULD-HOLD: all three writers push and nothing indexes or deletes. This one is + here because the repo's own notes record a mutation that made a purchase overwrite entry zero and + nothing in the suite caught it, unit tests included. + +- [x] **GL-02 — the history copy cursor never runs backwards or past the end.** + `historyEntriesCopied[id]` never decreases, and never exceeds the stored history length reached + through the live device redirect. SHOULD-HOLD: I-8, single writer bounded by `outstanding`. + +- [x] **GL-03 — the deployment cursor never runs backwards or past the end.** + `eSIMWalletsDeployed[deviceId]` never decreases and never exceeds that device's associated + identifier count. SHOULD-HOLD: I-9, both writers clamped by `_boundedBatchSize`. + +- [x] **GL-04 — a device's identifier list freezes once it has a wallet.** + After `isDeviceIdentifierAlreadyUsed` first reads true, the associated identifier list never + changes length again. SHOULD-HOLD: the only two writers refuse a deployed device. This is the + mechanism GL-03 depends on, checked separately so a break localises. + +- [x] **GL-05 — an eSIM wallet's registration is never revoked.** + Once `isESIMWalletValid[w]` is non-zero it never returns to zero; only the named holder changes. + SHOULD-HOLD: I-6, `bindESIMWallet` is the sole writer and no path zeroes it. + +- [x] **GL-06 — a spent payment reference never un-spends.** + For any `(wallet, reference)` the campaign has spent, `usedPaymentReferences` stays true, across + an adapter rotation. SHOULD-HOLD: I-4 as corrected, one guarded writer on `Registry`. + +- [x] **GL-07 — one P256 key names at most one device wallet.** + For every device wallet, the key index resolves its current key back to that same wallet. + SHOULD-HOLD: I-5, rotation deletes the old hash before writing the new one. + +- [x] **GL-08 — the registry's copy of a wallet's owner key matches the wallet's own.** + SHOULD-HOLD: rotation writes both in one transaction, so they cannot split. + +- [x] **GL-09 — a device identifier names one wallet, and that wallet agrees.** + SHOULD-HOLD: `_updateDeviceWalletInfo` is the sole writer and refuses an identifier already taken. + Reachable because both deploy routes draw from a shared pool of contested identifiers. + +- [x] **GL-10 — an eSIM identifier is claimed once and never reassigned.** + SHOULD-HOLD: `_claimESIMIdentifier` refuses a second claim. + +- [x] **GL-11 — the ceiling always binds, and nothing was ever written over it.** + The registry default is never zero, since zero reads as "no ceiling" everywhere a cap is consumed. + No purchase was ever accepted priced above the ceiling in force at the moment it was written. + SHOULD-HOLD: I-1, I-11, E-1. The per-entry half is checked at write time rather than against + today's cap, because the owner may lower the cap afterwards and that does not make an earlier + purchase illegal. + +- [x] **GL-12 — settlement token is never created or destroyed.** + `totalSupply` equals the sum of every balance the harness can hold it in: the fuzzer itself, the + actors, every device wallet, every eSIM wallet, both adapters and both vaults. SHOULD-HOLD: the + mock is a plain ERC-20 with no fee or rebase, so this is an accounting identity as long as no + value escapes the accounted set. + +- [x] **GL-13 — only the path that moved the money may say it did.** + The count of `Settlement.DeviceWallet` entries in a wallet's history equals the number of + successful `buyDataBundleWithToken` calls against it. SHOULD-HOLD: G-31, G-43, E-1. Every other + writer refuses that tag. + +- [x] **GL-14 — the currency table stays inside its own bounds.** + Every registered asset has decimals in `[2, 36]`, and a symbol once registered never returns to + unregistered. SHOULD-HOLD: I-2, I-3, `_writeAsset` is the sole writer. + +## Specific + +Asserted inside the handler that makes the call, where a before-and-after comparison is the point. + +- [x] **SP-01 — a purchase moves value between four addresses and creates none.** + Across one `buyDataBundleWithToken`, the combined balance of the device wallet, the eSIM wallet, + the adapter and the vault is unchanged, and the adapter's own balance ends exactly where it + started. SHOULD-HOLD: `spent + refunded == amountIn` by construction, so nothing rests on the + adapter. + +- [x] **SP-02 — what the adapter spends matches an independently written formula.** + The settled amount equals `price * 10**decimals / 100` computed in the harness rather than read + back from the adapter. SHOULD-HOLD: catches a reordering of the multiply and divide that a + self-consistency check cannot. + +- [x] **SP-03 — the quote is exact, monotonic, and agrees with settlement.** + `amount * 100 == price * 10**decimals` with no remainder, a higher price never quotes lower, and + `settle` spends exactly what `quote` returned for the same inputs. SHOULD-HOLD: decimals are + bounded at or above 2, so the division by 100 never truncates. X-1 covers the third part. + +- [x] **SP-04 — a wallet lands where the factory said it would.** + Both factories' counterfactual address equals the address actually deployed for the same inputs. + SHOULD-HOLD: the prediction and the deployment encode the same salt and init code. + +- [x] **SP-05 — a handover does not carry the old owner's ceiling.** + After `acceptOwnershipTransfer`, `priceCapUSDCents` reads zero, so the incoming owner starts on + the registry default. SHOULD-HOLD: `_secureTransferOwnership` resets it. Worth asserting because + the outgoing owner controls that cap right up to the moment it hands the wallet over. + +- [x] **SP-06 — accepting ownership does not rewrite the registry association.** + `acceptOwnershipTransfer` leaves `isESIMWalletValid` naming whoever last bound the wallet. + SHOULD-HOLD: X-2. The divergence is deliberate, which is why authorization reads live `owner()`. + +- [x] **SP-07 — raising or clearing standby never touches the registration.** + A call that changes `isESIMWalletOnStandby` leaves `isESIMWalletValid` alone. SHOULD-HOLD: the + two mappings are independent. This is a direct regression net for a defect this repo already + shipped once, where the flag was derived from the association. The inverse property, that one + implies the other, would be wrong. + +- [x] **SP-08 — releasing and re-adding a wallet never hands back spend rights.** + A removal clears both the validity flag and funds access; a later re-add restores validity and + clears standby but leaves funds access false. SHOULD-HOLD: G-21 refuses a grant at bind time, so + access can only come from a separate owner-signed call. + +- [x] **SP-09 — a deployed but unregistered device wallet can do nothing.** + Between `createAccount` and `postCreateAccount` the wallet holds code, is not valid, does not + claim its identifier, and cannot deploy an eSIM wallet. SHOULD-HOLD: `createAccount` writes no + external storage. + +- [x] **SP-10 — a pause stops every path that moves value.** + While paused, `pullToken`, `buyDataBundleWithToken`, `recordSettledPurchase` and the lazy funded + deployment all revert. SHOULD-HOLD: each carries the check. The last one only recently gained it. + The two lazy siblings that move no ETH are deliberately not in this list. + +## Not asserted, and why + +- **Anything needing a WebAuthn signature.** `validateUserOp`, `isValidSignature` and the challenge + binding need a P256 assertion produced through `vm.ffi`, which the fuzzers cannot call. Owner-gated + calls are reached by impersonating the wallet instead. The signature path is covered by the + Foundry differential and shape suites and by a fork test through the deployed EntryPoint. +- **Native ETH conservation.** Several handlers top up a caller with `vm.deal` before a payable + call, because a pranked call is debited from the pranked account. That manufactures ETH by + cheatcode, so a sum-conservation property over ETH would be false by construction and would say + nothing about the protocol. +- **Round trips over the conversion.** There is no units-back-to-cents function, so there is no + inverse to compose with. +- **Anything about shares, liquidity, liquidation or TVL.** None of it exists here. diff --git a/echidna.yaml b/echidna.yaml new file mode 100644 index 00000000..f6f3ef8d --- /dev/null +++ b/echidna.yaml @@ -0,0 +1,18 @@ +testMode: "assertion" +prefix: "property_" +seqLen: 100 +testLimit: 50000 +balanceContract: 0xd3c21bcecceda1000000 +coverage: true +corpusDir: "fizz_data/corpus_echidna" +cryticArgs: ["--foundry-compile-all"] +contractAddr: "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496" +deployer: "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38" +# output format (comment out for TUI) +format: "text" +# Hide solc stderr output and additional information during the testing. +quiet: false +stopOnFail: false +disableSlither: false + +# https://secure-contracts.com/program-analysis/echidna/configuration.html diff --git a/foundry.toml b/foundry.toml index 14a63c1b..ff97e4f1 100644 --- a/foundry.toml +++ b/foundry.toml @@ -64,4 +64,25 @@ fail_on_revert = false test = 'test/scripts' threads = 1 +# The Echidna and Medusa harness. +# FOUNDRY_PROFILE=fuzz forge build +# +# The Yul optimizer merges branches, so a campaign run against optimized bytecode reports maybe ten +# percent less coverage than the source actually reaches. Turning the optimizer off recovers most of +# that. Turning via-IR off would recover all of it and does not compile: the suite is stack-too-deep +# without it. +# +# Its own `out` because the two profiles disagree on optimizer settings, and sharing one directory +# makes every profile switch a full rebuild. Its own `test` for the same reason `scripts` has one, +# to keep an ordinary `forge test` from picking the harness up. +[profile.fuzz] +via_ir = true +optimizer = false +out = 'out-fuzz' +test = 'test/fizz' +# The fuzzers match deployed bytecode back to a source file to attribute coverage, and the metadata +# hash is what makes that match unambiguous. Kept here only; the default profile still drops it, +# which is what holds the byte-for-byte agreement with hardhat. +bytecode_hash = 'ipfs' + # See more config options https://book.getfoundry.sh/reference/config.html diff --git a/medusa.json b/medusa.json new file mode 100644 index 00000000..23f99209 --- /dev/null +++ b/medusa.json @@ -0,0 +1,93 @@ +{ + "fuzzing": { + "workers": 10, + "workerResetLimit": 50, + "timeout": 0, + "testLimit": 500000, + "callSequenceLength": 100, + "corpusDirectory": "fizz_data/corpus_medusa", + "coverageEnabled": true, + "deploymentOrder": [ + "FuzzTester" + ], + "targetContracts": [ + "FuzzTester" + ], + "targetContractsBalances": [ + "0xd3c21bcecceda1000000" + ], + "constructorArgs": {}, + "deployerAddress": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "senderAddresses": [ + "0x10000", + "0x20000", + "0x30000" + ], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 500000000, + "transactionGasLimit": 50000000, + "testing": { + "stopOnFailedTest": false, + "stopOnFailedContractMatching": false, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "testViewMethods": true, + "panicCodeConfig": { + "failOnCompilerInsertedPanic": false, + "failOnAssertion": true, + "failOnArithmeticUnderflow": false, + "failOnDivideByZero": false, + "failOnEnumTypeConversionOutOfBounds": false, + "failOnIncorrectStorageAccess": false, + "failOnPopEmptyArray": false, + "failOnOutOfBoundsArrayAccess": false, + "failOnAllocateTooMuchMemory": false, + "failOnCallUninitializedVariable": false + } + }, + "propertyTesting": { + "enabled": true, + "testPrefixes": [ + "property_" + ] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": [ + "optimize_" + ] + } + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": false + } + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": ".", + "solcVersion": "", + "exportDirectory": "fizz_data/crytic-export", + "args": [ + "--foundry-compile-all" + ] + } + }, + "slither": { + "enabled": false, + "cachePath": "fizz_data/slither_results.json" + }, + "logging": { + "level": "info", + "logDirectory": "fizz_data/logs_medusa", + "noColor": false + } + } diff --git a/test/fizz/Actor.sol b/test/fizz/Actor.sol new file mode 100644 index 00000000..9522d173 --- /dev/null +++ b/test/fizz/Actor.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +contract ForceSendETH { + constructor(address dst) payable { + // Use assembly to stay compatible across Solidity versions where + // selfdestruct is deprecated or removed + assembly { + selfdestruct(dst) + } + } +} + +/// @notice Represents an actor interacting with the system +contract Actor { + constructor() payable { + + } + + receive() external payable {} + + function forceSendETH(address recipient, uint256 amount) public { + new ForceSendETH{value: amount}(recipient); + } + + // ――――――――――――――――――― Flash loan borrower ―――――――――――――――――――― + + function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee, bytes calldata data) + external + returns (bytes32) + { + // Implement here flash loan logic, if needed + return keccak256("ERC3156FlashBorrower.onFlashLoan"); + } + + // ――――――――――――――――――――― ERC-721 receiver ――――――――――――――――――――― + + function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) + external + returns (bytes4) + { + return this.onERC721Received.selector; + } + + // ―――――――――――――――――――― ERC-1155 receiver ――――――――――――――――――――― + + function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data) + external + returns (bytes4) + { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } +} diff --git a/test/fizz/Base.sol b/test/fizz/Base.sol new file mode 100644 index 00000000..6d22556e --- /dev/null +++ b/test/fizz/Base.sol @@ -0,0 +1,629 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IEntryPoint} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; +import {FCL_Elliptic_ZZ} from "FreshCryptoLib/FCL_elliptic.sol"; + +import {Asset} from "contracts/payments/PaymentAdapter.sol"; +import {DataBundleDetails, Settlement, Wallets} from "contracts/CustomStructs.sol"; +import {P256Verifier} from "contracts/P256Verifier.sol"; +import {DeviceWalletFactory} from "contracts/device-wallet/DeviceWalletFactory.sol"; +import {ESIMWalletFactory} from "contracts/esim-wallet/ESIMWalletFactory.sol"; +import {PaymentAdapter} from "contracts/payments/PaymentAdapter.sol"; + +import {MockDeviceWallet} from "test/utils/mocks/MockDeviceWallet.sol"; +import {MockEntryPoint} from "test/utils/mocks/MockEntryPoint.sol"; +import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; +import {MockLazyWalletRegistry} from "test/utils/mocks/MockLazyWalletRegistry.sol"; +import {MockRegistry} from "test/utils/mocks/MockRegistry.sol"; +import {MockERC20} from "test/utils/mocks/tokens/MockERC20.sol"; + +import {Actor} from "./Actor.sol"; +import {Clamp} from "./utils/Clamp.sol"; +import {DecimalPrinter} from "./utils/DecimalPrinter.sol"; +import {Deployer} from "./utils/Deployer.sol"; +import {vm} from "./utils/Hevm.sol"; +import {Logger} from "./utils/Logger.sol"; +import {Math} from "./utils/Math.sol"; +import {StringUtils} from "./utils/StringUtils.sol"; +import {EnumerableSet} from "./utils/EnumerableSet.sol"; + +/// @notice Base contract with state variables and setup functions +/// @dev The `Mock*` contracts are the repo's own view-exposing subclasses of the real ones, not +/// substitutes for them. They add getters for `transactionHistory` and the string-keyed lazy +/// mappings, which have no public accessor and which the properties have to read. +/// `MockEntryPoint` is the one genuine stand-in, because the real singleton is not deployed in +/// a fuzz environment and no property here depends on its accounting. +abstract contract Base is StringUtils, Clamp, Deployer, Math { + using DecimalPrinter for uint256; + + string[] internal ACTOR_LABELS = ["Alice", "Bob", "Charlie"]; + uint256 internal constant BLOCK_INTERVAL = 12 seconds; + uint256 internal constant INITIAL_ETH_BALANCE = 1_000 ether; + + // ―――――――――――――――――――――――― Protocol constants ――――――――――――――――――――――― + + uint8 internal constant SETTLEMENT_DECIMALS = 6; + bytes32 internal constant ASSET_USDC = bytes32("USDC"); + bytes32 internal constant ASSET_USD = bytes32("USD"); + bytes32 internal constant ASSET_ETH = bytes32("ETH"); + + /// @dev $1000. Matches the unit suite so a price that trips the ceiling here trips it there. + uint64 internal constant DEFAULT_PRICE_CAP_CENTS = 100_000; + + /// @dev Enough settlement token that no run ends because a wallet went broke, and small enough + /// that the balance still moves visibly when a purchase lands. + uint256 internal constant INITIAL_TOKEN_BALANCE = 1_000_000 * 10 ** 6; + + /// @dev How many device wallets the run starts with. Handlers deploy more; this is only so the + /// first call already has somewhere to land instead of spending the early sequence + /// building a population. + uint256 internal constant BOOTSTRAP_DEVICE_WALLETS = 3; + + /// @notice How many identifiers the two deployment routes are made to contend over + /// @dev Small on purpose. Each route generates identifiers in its own namespace, so without a + /// shared pool a cross-route property would hold over a state no sequence ever reaches. + uint256 internal constant CONTESTED_IDENTIFIERS = 8; + + // ―――――――――――――――――――――――――― Ghosts ―――――――――――――――――――――――――― + + /// @dev Only what a property cannot read back off the protocol. Everything else is read live, + /// because a mirror that drifts is a false alarm rather than a finding. + struct Ghosts { + // GL-01: what each eSIM wallet's history looked like the last time it was read. An entry + // once seen must still hash the same, which is the only way a rewritten entry shows up. + mapping(address eSIMWallet => uint256 length) historyLength; + mapping(address eSIMWallet => mapping(uint256 index => bytes32 digest)) historyEntry; + // GL-02, GL-03: last reading of each cursor, so a decrease is visible + mapping(string identifier => uint256 copied) lastCopied; + mapping(string identifier => uint256 deployed) lastDeployed; + // GL-04: the identifier list length at the moment the device first had a wallet + mapping(string identifier => bool frozen) listFrozen; + mapping(string identifier => uint256 length) frozenLength; + // GL-05, GL-14: latches that must never fall back + mapping(address wallet => bool seen) everRegisteredESIM; + mapping(address wallet => bool seen) everValidDevice; + mapping(address wallet => bool seen) everDeployedESIM; + mapping(address adapter => mapping(bytes32 symbol => bool seen)) everRegisteredAsset; + // GL-06: every reference the campaign has spent, so the latch can be re-read + bytes32[] spentReferences; + mapping(bytes32 scopedReference => bool recorded) referenceRecorded; + // GL-13: purchases that actually moved money, counted at the one call site that can + mapping(address eSIMWallet => uint256 count) witnessedPurchases; + // Set by a handler when a call the protocol should have refused went through anyway. + // Held in ghost state rather than asserted inline: an assertion inside a handler body + // reverts the call, and a reverted call is discarded rather than reported. + bool unauthorizedBind; + bool unauthorizedStandby; + bool unauthorizedPull; + bool unauthorizedForeignDeploy; + bool pausedCallSucceeded; + // Set when a purchase went through priced above the ceiling in force at that moment. Read + // at write time because a later reduction of the cap does not make an earlier purchase + // illegal, and comparing history against today's cap would report every one of them. + bool purchaseAboveCap; + } + + Ghosts internal ghosts; + + // ―――――――――――――――――――――――――― Actors ―――――――――――――――――――――――――― + + address[] internal actors; + address internal actor; + address internal admin; + + /// @dev The roles the protocol actually distinguishes. Device wallets are contracts rather than + /// keys, so they are tracked separately in `deviceWallets` below. + address internal upgradeManager; + address internal adminSuccessor; + address internal vault; + + modifier asActor() { + vm.startPrank(actor); + _; + vm.stopPrank(); + } + + /// @dev Reads the admin out of the registry rather than using the address set at deployment. + /// Every admin check in the protocol resolves through that one field, so a handler holding + /// its own copy would go on impersonating an address the protocol already demoted, and + /// every admin path would sit dead for the rest of the run after one rotation. + modifier asAdmin() { + vm.startPrank(registry.eSIMWalletAdmin()); + _; + vm.stopPrank(); + } + + modifier asOwner() { + vm.startPrank(upgradeManager); + _; + vm.stopPrank(); + } + + // ―――――――――――――――――――――――― Contracts ――――――――――――――――――――――――― + + MockEntryPoint internal entryPoint; + P256Verifier internal p256Verifier; + MockRegistry internal registry; + MockLazyWalletRegistry internal lazyWalletRegistry; + DeviceWalletFactory internal deviceWalletFactory; + ESIMWalletFactory internal eSIMWalletFactory; + PaymentAdapter internal paymentAdapter; + MockERC20 internal settlementERC20; + address internal settlementToken; + + /// @dev Valid alternatives the config handlers swap to, rather than an address off the fuzzer. + /// A random pointer here would brick every later purchase and the run would spend the rest + /// of its budget reverting. Rotating the adapter is also the one shape that shows whether + /// spent payment references survive a swap, which they only do because the store moved to + /// the registry. + PaymentAdapter internal spareAdapter; + address internal spareVault; + address internal spareDeviceWalletImpl; + address internal spareESIMWalletImpl; + + // ―――――――――――――――――――― Deployed entity registry ―――――――――――――――――――― + + /// @dev What the campaign has built. Handlers index into these rather than inventing addresses, + /// because every gated call needs a caller the protocol already recognises. + address[] internal deviceWallets; + address[] internal eSIMWallets; + + /// @dev Identifiers already handed out, so a handler wanting a fresh one does not have to + /// guess. Reused deliberately when a test of the duplicate guards is what is wanted. + mapping(string => bool) internal deviceIdentifierUsed; + + uint256 internal saltNonce; + + // ―――――――――――――――――――――――――― Setup ――――――――――――――――――――――――――― + + function setup() internal { + upgradeManager = address(uint160(uint256(keccak256("fizz.upgradeManager")))); + admin = address(uint160(uint256(keccak256("fizz.eSIMWalletAdmin")))); + adminSuccessor = address(uint160(uint256(keccak256("fizz.adminSuccessor")))); + vault = address(uint160(uint256(keccak256("fizz.vault")))); + + vm.label(upgradeManager, "UpgradeManager"); + vm.label(admin, "ESIMWalletAdmin"); + vm.label(adminSuccessor, "AdminSuccessor"); + vm.label(vault, "Vault"); + + deployProtocol(); + setupActors(); + bootstrapWallets(); + } + + /// @notice Deploys the five singletons behind proxies and wires them to each other + /// @dev Deployment order follows the one the deploy scripts use: both factories first, because + /// the registry initializer takes their addresses, then the registry, then the two + /// contracts that take the registry's. + function deployProtocol() internal { + entryPoint = new MockEntryPoint(); + p256Verifier = new P256Verifier(); + + MockESIMWallet eSIMWalletImpl = new MockESIMWallet(); + ESIMWalletFactory eSIMWalletFactoryImpl = new ESIMWalletFactory(); + eSIMWalletFactory = ESIMWalletFactory( + address( + new ERC1967Proxy( + address(eSIMWalletFactoryImpl), + abi.encodeCall(eSIMWalletFactoryImpl.initialize, (address(eSIMWalletImpl), upgradeManager)) + ) + ) + ); + + MockDeviceWallet deviceWalletImpl = new MockDeviceWallet(IEntryPoint(address(entryPoint)), p256Verifier); + DeviceWalletFactory deviceWalletFactoryImpl = new DeviceWalletFactory(); + deviceWalletFactory = DeviceWalletFactory( + address( + new ERC1967Proxy( + address(deviceWalletFactoryImpl), + abi.encodeCall( + deviceWalletFactoryImpl.initialize, + ( + address(deviceWalletImpl), + upgradeManager, + address(eSIMWalletFactory), + IEntryPoint(address(entryPoint)), + p256Verifier + ) + ) + ) + ) + ); + + MockRegistry registryImpl = new MockRegistry(); + registry = MockRegistry( + address( + new ERC1967Proxy( + address(registryImpl), + abi.encodeCall( + registryImpl.initialize, + ( + admin, + vault, + upgradeManager, + address(deviceWalletFactory), + address(eSIMWalletFactory), + IEntryPoint(address(entryPoint)), + DEFAULT_PRICE_CAP_CENTS + ) + ) + ) + ) + ); + + MockLazyWalletRegistry lazyImpl = new MockLazyWalletRegistry(); + lazyWalletRegistry = MockLazyWalletRegistry( + address( + new ERC1967Proxy( + address(lazyImpl), + abi.encodeCall(lazyImpl.initialize, (address(registry), upgradeManager)) + ) + ) + ); + + settlementERC20 = new MockERC20("USD Coin", "USDC", SETTLEMENT_DECIMALS); + settlementToken = address(settlementERC20); + + PaymentAdapter paymentAdapterImpl = new PaymentAdapter(); + paymentAdapter = PaymentAdapter( + address( + new ERC1967Proxy( + address(paymentAdapterImpl), + abi.encodeCall(paymentAdapterImpl.initialize, (address(registry), settlementToken, upgradeManager)) + ) + ) + ); + + spareAdapter = PaymentAdapter( + address( + new ERC1967Proxy( + address(paymentAdapterImpl), + abi.encodeCall(paymentAdapterImpl.initialize, (address(registry), settlementToken, upgradeManager)) + ) + ) + ); + spareVault = address(uint160(uint256(keccak256("fizz.spareVault")))); + spareDeviceWalletImpl = address(new MockDeviceWallet(IEntryPoint(address(entryPoint)), p256Verifier)); + spareESIMWalletImpl = address(new MockESIMWallet()); + + vm.startPrank(upgradeManager); + registry.addOrUpdateLazyWalletRegistryAddress(address(lazyWalletRegistry)); + registry.setPaymentAdapter(address(paymentAdapter)); + deviceWalletFactory.addRegistryAddress(address(registry)); + eSIMWalletFactory.addRegistryAddress(address(registry)); + registerDefaultAssets(); + registerDefaultAssetsOn(spareAdapter); + vm.stopPrank(); + + vm.label(address(registry), "Registry"); + vm.label(address(lazyWalletRegistry), "LazyWalletRegistry"); + vm.label(address(deviceWalletFactory), "DeviceWalletFactory"); + vm.label(address(eSIMWalletFactory), "ESIMWalletFactory"); + vm.label(address(paymentAdapter), "PaymentAdapter"); + vm.label(settlementToken, "USDC"); + } + + /// @notice Registers the three currencies the harness prices in + /// @dev Only USDC can actually move: USD has no token address, and ETH is allowed but is not + /// priced in dollars, which is the case `quote` has to refuse. Keeping all three registered + /// means the fuzzer reaches both refusals without needing to guess an unregistered symbol. + function registerDefaultAssets() internal { + registerDefaultAssetsOn(paymentAdapter); + } + + function registerDefaultAssetsOn(PaymentAdapter adapter) internal { + adapter.registerAsset( + ASSET_USDC, + Asset({allowed: true, isDollarUnit: true, decimals: SETTLEMENT_DECIMALS, token: settlementToken}) + ); + adapter.registerAsset(ASSET_USD, Asset({allowed: true, isDollarUnit: true, decimals: 2, token: address(0)})); + adapter.registerAsset(ASSET_ETH, Asset({allowed: true, isDollarUnit: false, decimals: 18, token: address(0)})); + } + + /// @notice Whichever adapter the registry currently points at + /// @dev Read live for the same reason the admin is: the config handlers rotate it, and a + /// handler holding its own copy would keep funding an adapter nothing settles through. + function _activeAdapter() internal view returns (PaymentAdapter) { + return PaymentAdapter(registry.paymentAdapter()); + } + + function setupActors() internal { + for (uint256 i; i < ACTOR_LABELS.length; i++) { + address _actor = address(new Actor{value: INITIAL_ETH_BALANCE}()); + actors.push(_actor); + vm.label(_actor, ACTOR_LABELS[i]); + settlementERC20.mint(_actor, INITIAL_TOKEN_BALANCE); + } + actor = actors[0]; + + vm.deal(admin, INITIAL_ETH_BALANCE); + vm.deal(upgradeManager, INITIAL_ETH_BALANCE); + vm.deal(adminSuccessor, INITIAL_ETH_BALANCE); + } + + /// @notice Deploys the starting population of device wallets, each with one eSIM wallet + /// @dev Through the admin batch route, which is the same path a handler uses, so the starting + /// state is one the protocol could have reached on its own. Funds access is granted to the + /// eSIM wallet here because it is owner-signed in production and no fuzzer input can + /// produce that signature; without it every purchase needing a pull would revert. + function bootstrapWallets() internal { + for (uint256 i; i < BOOTSTRAP_DEVICE_WALLETS; ++i) { + string[] memory identifiers = new string[](1); + bytes32[2][] memory keys = new bytes32[2][](1); + uint256[] memory salts = new uint256[](1); + uint256[] memory deposits = new uint256[](1); + + identifiers[0] = string(abi.encodePacked("BOOT", toString(i))); + keys[0] = _ownerKey(i + 1); + salts[0] = ++saltNonce; + deposits[0] = 10 ether; + + vm.deal(admin, admin.balance + 10 ether); + vm.prank(admin); + Wallets[] memory deployed = + deviceWalletFactory.deployDeviceWalletForUsers{value: 10 ether}(identifiers, keys, salts, deposits); + + deviceIdentifierUsed[identifiers[0]] = true; + _trackDeviceWallet(deployed[0].deviceWallet); + _trackESIMWallet(deployed[0].eSIMWallet); + + settlementERC20.mint(deployed[0].deviceWallet, INITIAL_TOKEN_BALANCE); + settlementERC20.mint(deployed[0].eSIMWallet, INITIAL_TOKEN_BALANCE); + + vm.prank(deployed[0].deviceWallet); + MockDeviceWallet(payable(deployed[0].deviceWallet)).toggleAccessToFunds(deployed[0].eSIMWallet, true); + } + } + + // ――――――――――――――――――――――― Entity tracking ――――――――――――――――――――――― + + /// @dev Only what the factory derived and deployed, for the same reason as `_trackESIMWallet`. + function _trackDeviceWallet(address wallet) internal { + if (wallet == address(0) || wallet.code.length == 0) return; + deviceWallets.push(wallet); + vm.label(wallet, string(abi.encodePacked("DeviceWallet", toString(deviceWallets.length)))); + } + + /// @dev Only what the factory says it deployed. The unclamped handlers take an address off the + /// fuzzer, so without this an address that merely returned something gets into the list the + /// global properties walk, and every one of them then reports on a contract that is not an + /// eSIM wallet at all. + function _trackESIMWallet(address wallet) internal { + if (wallet == address(0)) return; + if (!eSIMWalletFactory.isESIMWalletDeployed(wallet)) return; + eSIMWallets.push(wallet); + vm.label(wallet, string(abi.encodePacked("ESIMWallet", toString(eSIMWallets.length)))); + } + + /// @notice Picks a device wallet the campaign has deployed, or zero when none exists yet + function _pickDeviceWallet(uint256 seed) internal view returns (address) { + if (deviceWallets.length == 0) return address(0); + return deviceWallets[seed % deviceWallets.length]; + } + + /// @notice Picks an eSIM wallet the campaign has deployed, or zero when none exists yet + function _pickESIMWallet(uint256 seed) internal view returns (address) { + if (eSIMWallets.length == 0) return address(0); + return eSIMWallets[seed % eSIMWallets.length]; + } + + /// @notice An eSIM wallet together with whichever device wallet currently owns it + /// @dev Ownership moves during a run, so the pair has to be read rather than remembered. A + /// handler pranking the wallet that used to own one is refused on every gated call. + function _pickOwnedPair(uint256 seed) internal view returns (address eSIMWallet, address deviceWallet) { + uint256 count = eSIMWallets.length; + if (count == 0) return (address(0), address(0)); + + for (uint256 i; i < count; ++i) { + address candidate = eSIMWallets[(seed + i) % count]; + address owner = MockESIMWallet(payable(candidate)).owner(); + if (registry.isDeviceWalletValid(owner)) return (candidate, owner); + } + return (address(0), address(0)); + } + + // ――――――――――――――――――――――― Identifier helpers ――――――――――――――――――――――― + + /// @notice A device identifier for the ordinary deploy route + function _deviceIdentifier(uint256 seed) internal pure returns (string memory) { + return string(abi.encodePacked("D", toString(seed % 1_000_000))); + } + + /// @notice A device identifier for the lazy route, in its own namespace + function _lazyDeviceIdentifier(uint256 seed) internal pure returns (string memory) { + return string(abi.encodePacked("L", toString(seed % 1_000_000))); + } + + /// @notice An eSIM identifier for the lazy route + function _lazyESIMIdentifier(uint256 seed) internal pure returns (string memory) { + return string(abi.encodePacked("E", toString(seed % 1_000_000))); + } + + /// @notice An eSIM identifier for the ordinary route + function _ordinaryESIMIdentifier(uint256 seed) internal pure returns (string memory) { + return string(abi.encodePacked("O", toString(seed % 1_000_000))); + } + + /// @notice A device identifier both routes draw from + function _contestedDeviceIdentifier(uint256 seed) internal pure returns (string memory) { + return string(abi.encodePacked("CD", toString(seed % CONTESTED_IDENTIFIERS))); + } + + /// @notice An eSIM identifier both routes draw from + function _contestedESIMIdentifier(uint256 seed) internal pure returns (string memory) { + return string(abi.encodePacked("CE", toString(seed % CONTESTED_IDENTIFIERS))); + } + + /// @notice How many lazy eSIM identifiers the handlers draw from + uint256 internal constant LAZY_ESIM_IDENTIFIERS = 12; + + /// @notice How many lazy device identifiers the handlers draw from + uint256 internal constant LAZY_DEVICE_IDENTIFIERS = 6; + + /// @notice Every device identifier the lazy route can reach + /// @dev Built rather than tracked. The handlers draw from two fixed namespaces, so the whole + /// key space is known ahead of time and a property can walk it without the campaign having + /// to remember which ones it used. The string-keyed mappings have no other way to be + /// enumerated. + function _allLazyDeviceIdentifiers() internal pure returns (string[] memory identifiers) { + identifiers = new string[](LAZY_DEVICE_IDENTIFIERS + CONTESTED_IDENTIFIERS); + for (uint256 i; i < LAZY_DEVICE_IDENTIFIERS; ++i) { + identifiers[i] = _lazyDeviceIdentifier(i); + } + for (uint256 i; i < CONTESTED_IDENTIFIERS; ++i) { + identifiers[LAZY_DEVICE_IDENTIFIERS + i] = _contestedDeviceIdentifier(i); + } + } + + /// @notice Every eSIM identifier the lazy route can reach + function _allLazyESIMIdentifiers() internal pure returns (string[] memory identifiers) { + identifiers = new string[](LAZY_ESIM_IDENTIFIERS + CONTESTED_IDENTIFIERS); + for (uint256 i; i < LAZY_ESIM_IDENTIFIERS; ++i) { + identifiers[i] = _lazyESIMIdentifier(i); + } + for (uint256 i; i < CONTESTED_IDENTIFIERS; ++i) { + identifiers[LAZY_ESIM_IDENTIFIERS + i] = _contestedESIMIdentifier(i); + } + } + + /// @notice Every currency symbol the handlers can register + function _allSymbols() internal pure returns (bytes32[] memory symbols) { + symbols = new bytes32[](9); + symbols[0] = ASSET_USDC; + symbols[1] = ASSET_USD; + symbols[2] = ASSET_ETH; + for (uint256 i; i < 6; ++i) { + symbols[3 + i] = bytes32(i + 1); + } + } + + // ――――――――――――――――――――――――― Helpers ―――――――――――――――――――――――――― + + /// @notice A P256 public key that is genuinely on the curve + /// @dev Every deploy path rejects an off-curve key, so a random pair would spend the whole run + /// being refused before reaching any state. Walking x upward until the curve equation has + /// a square root finds one in two tries on average. + function _ownerKey(uint256 seed) internal view returns (bytes32[2] memory) { + uint256 x = uint256(keccak256(abi.encode("fizz.ownerKey", seed))); + for (uint256 i; i < 32; ++i) { + uint256 y = FCL_Elliptic_ZZ.ec_Decompress(x, seed & 1); + if (FCL_Elliptic_ZZ.ecAff_isOnCurve(x, y)) return [bytes32(x), bytes32(y)]; + unchecked { + ++x; + } + } + revert("no on-curve key found"); + } + + /// @notice What a price in cents costs in the settlement token's own units + /// @dev Worked out here rather than read from `quote`, so a property comparing the two compares + /// the adapter against a second calculation instead of against itself. + function _settlementAmount(uint64 priceUSDCents) internal pure returns (uint256) { + return (uint256(priceUSDCents) * 10 ** SETTLEMENT_DECIMALS) / 100; + } + + /// @notice One data bundle, priced under whichever ceiling currently applies + function _bundle(uint256 idSeed, uint64 priceUSDCents) internal pure returns (DataBundleDetails memory) { + return DataBundleDetails({ + id: keccak256(abi.encode("bundle", idSeed)), + priceUSDCents: priceUSDCents, + settlement: Settlement.Fiat + }); + } + + /// @notice A payment reference derived from a seed + /// @dev Drawn from a small space on purpose. References are spent once, so a reference the run + /// never repeats would leave the replay guard untested. + function _paymentReference(uint256 seed) internal pure returns (bytes32) { + return keccak256(abi.encode("ref", seed % 64)); + } + + /// @notice An eSIM wallet's owner, or zero when the address is not one + /// @dev The unclamped handlers take an address straight off the fuzzer, and calling `owner()` on + /// something that is not a wallet reverts before the handler reaches the call it was + /// written to make. + function _safeESIMOwner(address wallet) internal view returns (address) { + if (wallet.code.length == 0) return address(0); + try MockESIMWallet(payable(wallet)).owner() returns (address owner) { + return owner; + } catch { + return address(0); + } + } + + /// @notice Whichever ceiling currently applies to a wallet, its own or the registry default + /// @dev Tolerates an address that is not a wallet, because the unclamped handlers take one + /// straight off the fuzzer and read this before the call that would have refused it. + function _effectiveCap(address eSIMWallet) internal view returns (uint256) { + if (eSIMWallet.code.length == 0) return registry.defaultPriceCapUSDCents(); + + try MockESIMWallet(payable(eSIMWallet)).priceCapUSDCents() returns (uint64 own) { + return own == 0 ? registry.defaultPriceCapUSDCents() : own; + } catch { + return registry.defaultPriceCapUSDCents(); + } + } + + /// @notice Records a payment reference the campaign has spent, so a property can re-read it + function _recordSpentReference(address eSIMWallet, bytes32 paymentRef) internal { + bytes32 scoped = keccak256(abi.encode(eSIMWallet, paymentRef)); + if (ghosts.referenceRecorded[scoped]) return; + if (!registry.usedPaymentReferences(scoped)) return; + + ghosts.referenceRecorded[scoped] = true; + ghosts.spentReferences.push(scoped); + } + + /// @notice Caps a bound at what the account making the call can actually pay + /// @dev The value on a pranked call comes out of the pranked account, not out of the harness, + /// so a ceiling taken from this contract's balance would run every call out of funds. + function _spendable(address account, uint256 ceiling) internal view returns (uint256) { + uint256 balance = account.balance; + return balance < ceiling ? balance : ceiling; + } + + // Maps an arbitrary address to an actor address + function toActor(address addy) internal view returns (address) { + return actors[uint256(uint160(addy)) % actors.length]; + } + + // Maps an arbitrary address to an actor address that is different from the current actor + function toActorNotCurrent(address addy) internal view returns (address) { + address _actor = actors[uint256(uint160(addy)) % actors.length]; + if (_actor == actor) { + _actor = actors[(uint256(uint160(addy)) + 1) % actors.length]; + } + return _actor; + } + + // Sums the native token balances of all actors + function sumActorsBalances() internal view returns (uint256 sumOfBalances) { + for (uint256 i; i < actors.length; i++) { + sumOfBalances += actors[i].balance; + } + } + + // Sums the ERC-20 token balances of all actors for a given token + function sumActorsERC20Balances(address _token) internal view returns (uint256 sumOfBalances) { + for (uint256 i; i < actors.length; i++) { + bytes memory data = abi.encodeWithSignature("balanceOf(address)", actors[i]); + (bool success, bytes memory result) = _token.staticcall(data); + require(success, "sumActorsERC20Balances: failed to get balance"); + sumOfBalances += abi.decode(result, (uint256)); + } + } + + function skipBlocks(uint256 blocks) internal { + vm.roll(block.number + blocks); + vm.warp(block.timestamp + blocks * BLOCK_INTERVAL); + } + + function skipTime(uint256 time) internal { + uint256 blocks = (time + BLOCK_INTERVAL - 1) / BLOCK_INTERVAL; + vm.roll(block.number + blocks); + vm.warp(block.timestamp + time); + } +} diff --git a/test/fizz/FoundryTester.sol b/test/fizz/FoundryTester.sol new file mode 100644 index 00000000..1f82ee37 --- /dev/null +++ b/test/fizz/FoundryTester.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; +import {Handlers} from "./handlers/Handlers.sol"; + +/// @notice Contract to be used for quick testing with Foundry +contract FoundryTester is Test, Handlers { + function setUp() public { + setup(); + } + + // forge test --match-test test_sequence -vvv + function test_sequence() public { + // Add here call sequence to Handler's functions to reproduce failing property + } + + /// @notice Every clamped handler, once, on the state `setup()` leaves behind + /// @dev A handler that reverts on its first call is one the campaign never reaches past, and a + /// fuzzer reports that as low coverage rather than as an error. Running each once here is + /// what turns a mis-pranked caller into a failing test instead of a silent gap. + function test_smoke_everyHandlerReachesTheProtocol() public { + assertEq(deviceWallets.length, BOOTSTRAP_DEVICE_WALLETS, "bootstrap device wallets"); + assertEq(eSIMWallets.length, BOOTSTRAP_DEVICE_WALLETS, "bootstrap eSIM wallets"); + + eSIMWallet_buyDataBundleWithToken_clamped(0, 1, 500, 1, false); + eSIMWallet_buyDataBundleWithToken_clamped(1, 2, 500, 2, true); + eSIMWallet_buyDataBundleWithToken_atCap(0, 3); + eSIMWallet_buyDataBundleWithToken_dustPrice(1, 4); + eSIMWallet_buyDataBundleWithToken_needsPull(2, 700, 5); + + assertGt(settlementERC20.balanceOf(vault), 0, "vault must have been paid"); + + deviceWallet_pullToken_clamped(0, 1e6); + deviceWallet_deployESIMWallet_clamped(0, 3); + deviceWallet_toggleAccessToFunds_clamped(1, true); + + registry_recordSettledPurchase_clamped(0, 9, 400, 1e6, 9, true); + registry_assignESIMIdentifier_clamped(0, 1, false); + registry_bindESIMWallet_clamped(1); + registry_consumePaymentReference_clamped(2, 20); + + lazyWalletRegistry_batchPopulateHistory_clamped(1, 1, 3, 250, false); + lazyWalletRegistry_deployLazyWalletAndSetESIMIdentifier_clamped(1, 77, 1 ether, 5, false); + lazyWalletRegistry_setHistoryForLazyWallet_clamped(1, 2, false); + + deviceWalletFactory_createAccount_clamped(41, 42, 0.1 ether, false); + assertTrue(lastCounterfactualWallet != address(0), "createAccount must deploy"); + deviceWalletFactory_secondary(0, 0); + deviceWalletFactory_deployDeviceWalletForUsers_clamped(51, 52, 2, 0.1 ether, false); + + eSIMWalletFactory_deployESIMWallet_clamped(0, 1); + paymentAdapter_settle_funded(0, 300); + + eSIMWallet_requestTransferOwnership_clamped(0, 1); + eSIMWallet_acceptOwnershipTransfer_clamped(0); + + // Every deployment route must have added to the population the campaign draws from + assertGt(deviceWallets.length, BOOTSTRAP_DEVICE_WALLETS, "device wallet population grew"); + assertGt(eSIMWallets.length, BOOTSTRAP_DEVICE_WALLETS, "eSIM wallet population grew"); + } + + /// @notice Every global property holds on the state a run of the handlers leaves behind + /// @dev A property that compiles proves nothing. This drives the protocol first, so each one is + /// evaluated against a population that has actually moved rather than against `setup()`. + function test_smoke_everyGlobalPropertyHolds() public { + test_smoke_everyHandlerReachesTheProtocol(); + + assertTrue(property_historyIsAppendOnly(), "GL-01"); + assertTrue(property_historyCursorStaysInBounds(), "GL-02"); + assertTrue(property_deploymentCursorStaysInBounds(), "GL-03/04"); + assertTrue(property_latchesNeverFallBack(), "GL-05/14"); + assertTrue(property_spentReferencesStaySpent(), "GL-06"); + assertTrue(property_ownerKeysAreUniqueAndAgree(), "GL-07/08"); + assertTrue(property_identifiersAreClaimedOnce(), "GL-09/10"); + assertTrue(property_theCeilingAlwaysBinds(), "GL-11"); + assertTrue(property_settlementTokenIsConserved(), "GL-12"); + assertTrue(property_onlyThePurchasePathClaimsSettlement(), "GL-13"); + assertTrue(property_noUnauthorizedCallSucceeded(), "unauthorized call"); + + // Running them twice matters: the append-only and latch properties compare against what the + // first pass recorded, so a first pass alone would only ever be populating them. + assertTrue(property_historyIsAppendOnly(), "GL-01 second pass"); + assertTrue(property_latchesNeverFallBack(), "GL-05/14 second pass"); + assertTrue(property_deploymentCursorStaysInBounds(), "GL-03/04 second pass"); + } + + /// @notice The properties must survive the calls that are supposed to be refused + /// @dev Each call here reverts, which is the point, so each goes through `_expectRefused`. The + /// fuzzers discard a reverted call on their own; Foundry does not. + function test_smoke_refusedCallsLeaveNoTrace() public { + _expectRefused(abi.encodeCall(this.registry_recordSettledPurchase_assertsDeviceWallet, (0, 5))); + _expectRefused(abi.encodeCall(this.registry_recordSettledPurchase_overCap, (0, 6))); + _expectRefused(abi.encodeCall(this.registry_bindESIMWallet_asStranger, (0, 1))); + _expectRefused(abi.encodeCall(this.eSIMWalletFactory_deployESIMWallet_forStranger, (0, 1, 2))); + _expectRefused(abi.encodeCall(this.eSIMWallet_buyDataBundleWithToken_unsettleableAsset, (0, 7, true))); + _expectRefused(abi.encodeCall(this.eSIMWallet_buyDataBundleWithToken_unsettleableAsset, (0, 8, false))); + + assertTrue(property_noUnauthorizedCallSucceeded(), "a refused call went through"); + assertTrue(property_onlyThePurchasePathClaimsSettlement(), "GL-13"); + assertTrue(property_theCeilingAlwaysBinds(), "GL-11"); + } + + /// @notice Nothing that moves value runs while the protocol is paused + function test_smoke_pauseStopsEveryValueMovingPath() public { + registry_secondary(0, 0); + assertTrue(registry.paused(), "pause must apply"); + + _expectRefused(abi.encodeCall(this.eSIMWallet_buyDataBundleWithToken_clamped, (0, 1, 100, 40, false))); + _expectRefused(abi.encodeCall(this.deviceWallet_pullToken_clamped, (0, 1e6))); + _expectRefused(abi.encodeCall(this.registry_recordSettledPurchase_clamped, (0, 2, 100, 0, 41, true))); + _expectRefused( + abi.encodeCall(this.lazyWalletRegistry_deployLazyWalletAndSetESIMIdentifier_clamped, (2, 88, 1 ether, 5, false)) + ); + + assertTrue(property_noUnauthorizedCallSucceeded(), "a guarded path ran while paused"); + } + + /// @notice Runs a handler that is expected to revert, and fails if it did not + /// @dev Through an external self-call so the revert unwinds only the handler. A handler that + /// quietly returned instead of reverting would otherwise read as a pass. + function _expectRefused(bytes memory call) private { + (bool ok,) = address(this).call(call); + assertFalse(ok, "a call the protocol should have refused went through"); + } + + /// @notice The config surface, which every other handler reads its permissions from + function test_smoke_configHandlersApplyAndRevert() public { + registry_secondary(0, 0); // pause + assertTrue(registry.paused(), "pause must apply"); + + registry_secondary(1, 0); // unpause + assertFalse(registry.paused(), "unpause must apply"); + + registry_secondary(3, 1); // rotate onto the spare adapter + assertEq(registry.paymentAdapter(), address(spareAdapter), "adapter must rotate"); + + registry_secondary(3, 0); // and back + assertEq(registry.paymentAdapter(), address(paymentAdapter), "adapter must rotate back"); + + registry_secondary(2, 5_000); // price cap + assertEq(registry.defaultPriceCapUSDCents(), 5_000, "cap must apply"); + + registry_secondary(7, 0); // nominate a successor + registry_secondary(8, 0); // and let it accept + assertEq(registry.eSIMWalletAdmin(), adminSuccessor, "admin must rotate"); + + // The admin handlers read the role live, so a purchase still works after the rotation + eSIMWallet_buyDataBundleWithToken_clamped(0, 1, 100, 31, true); + + deviceWalletFactory_secondary(1, 0); // swap the device wallet beacon + eSIMWalletFactory_secondary(0, 0); // swap the eSIM wallet beacon + paymentAdapter_secondary(0, 1, 6); // register a currency + } + + // ── Violation Repros (auto-generated by Step 11) ────────────────── + // Each test_repro_* function below replays a shrunk fuzzer call + // sequence that violated a property. Run all with: + // forge test --match-contract FoundryTester -vvv +} diff --git a/test/fizz/FuzzTester.sol b/test/fizz/FuzzTester.sol new file mode 100644 index 00000000..15e5e2a7 --- /dev/null +++ b/test/fizz/FuzzTester.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import {Handlers} from "./handlers/Handlers.sol"; + +/// @notice Entry point for fuzzing tests +contract FuzzTester is Handlers { + constructor() payable { + setup(); + } +} diff --git a/test/fizz/Properties.sol b/test/fizz/Properties.sol new file mode 100644 index 00000000..a7eac74a --- /dev/null +++ b/test/fizz/Properties.sol @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import {DataBundleDetails, Settlement} from "contracts/CustomStructs.sol"; +import {PaymentAdapter} from "contracts/payments/PaymentAdapter.sol"; +import {MockDeviceWallet} from "test/utils/mocks/MockDeviceWallet.sol"; +import {MockESIMWallet} from "test/utils/mocks/MockESIMWallet.sol"; + +import {Snapshots} from "./Snapshots.sol"; +import {PropertiesAsserts} from "./utils/PropertiesAsserts.sol"; + +/// @notice Contains the functions that check the properties (invariants) +/// @dev The global properties walk the wallets the campaign has built, so each is linear in the +/// population rather than quadratic. The one genuinely cross-product check, that a device +/// wallet and the registry agree on who holds an eSIM wallet, is written from the eSIM +/// wallet's side, where the registry already names the single device wallet to ask. +abstract contract Properties is PropertiesAsserts, Snapshots { + + // ―――――――――――――――――――― Global properties ――――――――――――――――――――― + // These properties must always hold after any function call. + // They MUST BE PUBLIC so that fuzzers can find and call them. + + /// @notice GL-01: an eSIM wallet's purchase history is append-only, entry for entry + /// @dev The repo's own notes record a mutation that made a purchase overwrite entry zero, which + /// nothing in the suite caught. A length check alone would still miss it, so every entry + /// already seen is re-hashed against what it hashed to last time. + function property_historyIsAppendOnly() public returns (bool) { + for (uint256 i; i < eSIMWallets.length; ++i) { + address wallet = eSIMWallets[i]; + DataBundleDetails[] memory entries = MockESIMWallet(payable(wallet)).getTransactionHistory(); + + uint256 known = ghosts.historyLength[wallet]; + if (entries.length < known) return false; + + for (uint256 j; j < entries.length; ++j) { + bytes32 digest = keccak256( + abi.encode(entries[j].id, entries[j].priceUSDCents, entries[j].settlement) + ); + if (j < known) { + if (ghosts.historyEntry[wallet][j] != digest) return false; + } else { + ghosts.historyEntry[wallet][j] = digest; + } + } + ghosts.historyLength[wallet] = entries.length; + } + return true; + } + + /// @notice GL-02: the history copy cursor never runs backwards or past the end + function property_historyCursorStaysInBounds() public returns (bool) { + string[] memory identifiers = _allLazyESIMIdentifiers(); + + for (uint256 i; i < identifiers.length; ++i) { + string memory identifier = identifiers[i]; + uint256 copied = lazyWalletRegistry.historyEntriesCopied(identifier); + + if (copied < ghosts.lastCopied[identifier]) return false; + ghosts.lastCopied[identifier] = copied; + + // Read through the live redirect: a switch moves the entries to another device, and the + // cursor follows the identifier rather than the device it used to sit under. + string memory device = lazyWalletRegistry.eSIMIdentifierToDeviceIdentifier(identifier); + if (bytes(device).length == 0) { + if (copied != 0) return false; + continue; + } + if (copied > lazyWalletRegistry.getDeviceIdentifierToESIMDetails(device, identifier).length) { + return false; + } + } + return true; + } + + /// @notice GL-03 and GL-04: the deployment cursor stays in bounds, and the list it is measured + /// against stops changing once the device has a wallet + /// @dev The two are checked together because the bound is only safe while the freeze holds. + /// Checking the freeze separately is what tells the two apart when one breaks. + function property_deploymentCursorStaysInBounds() public returns (bool) { + string[] memory identifiers = _allLazyDeviceIdentifiers(); + + for (uint256 i; i < identifiers.length; ++i) { + string memory identifier = identifiers[i]; + uint256 deployed = lazyWalletRegistry.eSIMWalletsDeployed(identifier); + uint256 associated = + lazyWalletRegistry.getESIMIdentifiersAssociatedWithDeviceIdentifier(identifier).length; + + if (deployed < ghosts.lastDeployed[identifier]) return false; + ghosts.lastDeployed[identifier] = deployed; + + if (deployed > associated) return false; + + if (registry.isDeviceIdentifierAlreadyUsed(identifier)) { + if (!ghosts.listFrozen[identifier]) { + ghosts.listFrozen[identifier] = true; + ghosts.frozenLength[identifier] = associated; + } else if (ghosts.frozenLength[identifier] != associated) { + return false; + } + } + } + return true; + } + + /// @notice GL-05 and GL-14: every latch in the protocol still holds + /// @dev All one-way, all cheap to read, so they ride together rather than as five properties + /// the fuzzer has to call separately after every call. + function property_latchesNeverFallBack() public returns (bool) { + for (uint256 i; i < eSIMWallets.length; ++i) { + address wallet = eSIMWallets[i]; + + if (registry.isESIMWalletValid(wallet) != address(0)) ghosts.everRegisteredESIM[wallet] = true; + else if (ghosts.everRegisteredESIM[wallet]) return false; + + if (eSIMWalletFactory.isESIMWalletDeployed(wallet)) ghosts.everDeployedESIM[wallet] = true; + else if (ghosts.everDeployedESIM[wallet]) return false; + } + + for (uint256 i; i < deviceWallets.length; ++i) { + address wallet = deviceWallets[i]; + if (registry.isDeviceWalletValid(wallet)) ghosts.everValidDevice[wallet] = true; + else if (ghosts.everValidDevice[wallet]) return false; + } + + return _assetTableHolds(); + } + + /// @notice GL-06: a spent payment reference never un-spends + function property_spentReferencesStaySpent() public view returns (bool) { + for (uint256 i; i < ghosts.spentReferences.length; ++i) { + if (!registry.usedPaymentReferences(ghosts.spentReferences[i])) return false; + } + return true; + } + + /// @notice GL-07 and GL-08: one P256 key names one device wallet, and both copies of the key agree + function property_ownerKeysAreUniqueAndAgree() public view returns (bool) { + for (uint256 i; i < deviceWallets.length; ++i) { + address wallet = deviceWallets[i]; + if (!registry.isDeviceWalletValid(wallet)) continue; + + bytes32[2] memory registryKey = registry.getDeviceWalletToOwner(wallet); + bytes32[2] memory walletKey = MockDeviceWallet(payable(wallet)).getOwner(); + + if (registryKey[0] != walletKey[0] || registryKey[1] != walletKey[1]) return false; + if (registry.registeredP256Keys(keccak256(abi.encode(registryKey[0], registryKey[1]))) != wallet) { + return false; + } + } + return true; + } + + /// @notice GL-09 and GL-10: an identifier names one wallet, and that wallet names it back + function property_identifiersAreClaimedOnce() public view returns (bool) { + for (uint256 i; i < deviceWallets.length; ++i) { + address wallet = deviceWallets[i]; + if (!registry.isDeviceWalletValid(wallet)) continue; + + string memory identifier = MockDeviceWallet(payable(wallet)).deviceUniqueIdentifier(); + if (registry.uniqueIdentifierToDeviceWallet(identifier) != wallet) return false; + } + + for (uint256 i; i < eSIMWallets.length; ++i) { + address wallet = eSIMWallets[i]; + string memory identifier = MockESIMWallet(payable(wallet)).eSIMUniqueIdentifier(); + if (bytes(identifier).length == 0) continue; + + if (registry.eSIMWalletForIdentifier(identifier) != wallet) return false; + } + return true; + } + + /// @notice GL-11: the ceiling always means something, and nothing was ever written over it + /// @dev The cap is checked when an entry is written, and the owner may lower it afterwards, so + /// comparing old entries against today's cap would report every legal purchase made before + /// a reduction. The per-entry check is done at write time in the handlers instead, and its + /// result is carried here. What is genuinely a standing property is the other half: the + /// registry default is never zero, because zero reads as "no ceiling" everywhere a cap is + /// consumed, which would quietly remove the bound the admin is held to. + function property_theCeilingAlwaysBinds() public view returns (bool) { + if (registry.defaultPriceCapUSDCents() == 0) return false; + return !ghosts.purchaseAboveCap; + } + + /// @notice GL-12: settlement token is never created or destroyed + function property_settlementTokenIsConserved() public view returns (bool) { + uint256 tracked = settlementERC20.balanceOf(address(this)) + sumActorsERC20Balances(settlementToken) + + settlementERC20.balanceOf(address(paymentAdapter)) + settlementERC20.balanceOf(address(spareAdapter)) + + settlementERC20.balanceOf(vault) + settlementERC20.balanceOf(spareVault); + + for (uint256 i; i < deviceWallets.length; ++i) { + tracked += settlementERC20.balanceOf(deviceWallets[i]); + } + for (uint256 i; i < eSIMWallets.length; ++i) { + tracked += settlementERC20.balanceOf(eSIMWallets[i]); + } + + return settlementERC20.totalSupply() == tracked; + } + + /// @notice GL-13: only the path that moved the money may say it did + function property_onlyThePurchasePathClaimsSettlement() public view returns (bool) { + for (uint256 i; i < eSIMWallets.length; ++i) { + address wallet = eSIMWallets[i]; + DataBundleDetails[] memory entries = MockESIMWallet(payable(wallet)).getTransactionHistory(); + + uint256 claimed; + for (uint256 j; j < entries.length; ++j) { + if (entries[j].settlement == Settlement.DeviceWallet) ++claimed; + } + if (claimed != ghosts.witnessedPurchases[wallet]) return false; + } + return true; + } + + /// @notice A call the protocol should have refused went through + /// @dev Every flag here is written by a handler that watched a gated call succeed for a caller + /// the guard names. Kept out of the handler bodies because an assertion there reverts the + /// call, and a reverted call is thrown away rather than reported. + function property_noUnauthorizedCallSucceeded() public view returns (bool) { + return !ghosts.unauthorizedBind && !ghosts.unauthorizedStandby && !ghosts.unauthorizedPull + && !ghosts.unauthorizedForeignDeploy && !ghosts.pausedCallSucceeded; + } + + /// @notice Every global property, as one assertion + /// @dev Medusa reads the `property_` functions above directly. Echidna running in assertion + /// mode does not: it looks for a failed assert rather than a false return, so without this + /// the whole global set would be invisible to it and only the inline checks inside the + /// handlers would carry the run. Both engines call this, which costs Medusa a second pass + /// over state it has already read and buys a second, independent explorer over all of it. + function checkAllGlobalProperties() public { + t(property_historyIsAppendOnly(), "GL-01 purchase history was rewritten"); + t(property_historyCursorStaysInBounds(), "GL-02 history cursor left its bounds"); + t(property_deploymentCursorStaysInBounds(), "GL-03/04 deployment cursor left its bounds"); + t(property_latchesNeverFallBack(), "GL-05/14 a latch fell back"); + t(property_spentReferencesStaySpent(), "GL-06 a spent payment reference un-spent"); + t(property_ownerKeysAreUniqueAndAgree(), "GL-07/08 owner keys disagree or are shared"); + t(property_identifiersAreClaimedOnce(), "GL-09/10 an identifier names the wrong wallet"); + t(property_theCeilingAlwaysBinds(), "GL-11 the price ceiling stopped binding"); + t(property_settlementTokenIsConserved(), "GL-12 settlement token was created or destroyed"); + t(property_onlyThePurchasePathClaimsSettlement(), "GL-13 something else claimed settlement"); + t(property_noUnauthorizedCallSucceeded(), "a call the protocol should have refused ran"); + } + + // ――――――――――――――――――― Specific properties ―――――――――――――――――――― + // These properties must hold after specific function calls. + // They MUST BE INTERNAL and called at the end of the relevant handlers. + + /// @notice SP-01: a purchase moves value between four addresses and creates none, and leaves + /// nothing resting on the adapter + function _prop_purchaseConservesValue() internal { + eq(stateAfter.purchaseSideTotal, stateBefore.purchaseSideTotal, "SP-01 purchase created or destroyed value"); + eq(stateAfter.adapterBalance, stateBefore.adapterBalance, "SP-01 tokens rested on the adapter"); + } + + /// @notice SP-02: what the adapter spent matches a formula written here rather than read back + function _prop_settlementMatchesReference(uint64 priceUSDCents, uint256 spent) internal { + eq(spent, _settlementAmount(priceUSDCents), "SP-02 settled amount diverged from the reference conversion"); + } + + /// @notice SP-03: the quote is exact and monotonic + /// @dev Decimals are held at or above two, so `10 ** decimals` is always a multiple of a + /// hundred and the division cannot drop a remainder. That makes this an equality rather + /// than a bound. + function _prop_quoteIsExact(uint64 priceUSDCents, uint8 decimals, uint256 amount) internal { + eq(amount * 100, uint256(priceUSDCents) * 10 ** decimals, "SP-03 quote lost precision"); + } + + function _prop_quoteIsMonotonic(uint256 lowQuote, uint256 highQuote, bool pricesDiffer) internal { + gte(highQuote, lowQuote, "SP-03 quote fell as the price rose"); + if (pricesDiffer) gt(highQuote, lowQuote, "SP-03 quote did not move for a higher price"); + } + + /// @notice SP-04: a wallet lands where the factory said it would + function _prop_addressMatchesPrediction(address predicted, address deployed) internal { + t(predicted == deployed, "SP-04 deployed address diverged from the prediction"); + } + + /// @notice SP-05: a handover does not carry the old owner's ceiling + /// @dev The outgoing owner sets that cap and controls it right up to the moment it hands the + /// wallet over, so an incoming owner inheriting it would be inheriting a ceiling it never + /// agreed to. + function _prop_handoverClearsThePriceCap(address eSIMWallet) internal { + eq( + uint256(MockESIMWallet(payable(eSIMWallet)).priceCapUSDCents()), + 0, + "SP-05 the old owner's price ceiling survived the handover" + ); + } + + /// @notice SP-06: accepting ownership does not rewrite the registry association + /// @dev The divergence is deliberate. Authorization reads the wallet's live owner, which is why + /// the association is allowed to go on naming whoever last bound it. + function _prop_handoverLeavesTheAssociation(address eSIMWallet, address associationBefore) internal { + t( + registry.isESIMWalletValid(eSIMWallet) == associationBefore, + "SP-06 accepting ownership moved the association" + ); + } + + /// @notice SP-07: raising or clearing standby never touches the registration + /// @dev The two mappings are independent. Deriving one from the other is a defect this repo has + /// already shipped once, so the inverse of this property would be wrong. + function _prop_standbyLeavesTheRegistration(address eSIMWallet, address registrationBefore) internal { + t( + registry.isESIMWalletValid(eSIMWallet) == registrationBefore, + "SP-07 a standby change rewrote the registration" + ); + } + + /// @notice SP-08: a removal strips both flags, and a re-add never hands spend rights back + function _prop_removalStripsAccess(address deviceWallet, address eSIMWallet) internal { + t(!MockDeviceWallet(payable(deviceWallet)).isValidESIMWallet(eSIMWallet), "SP-08 removal left the wallet valid"); + t(!MockDeviceWallet(payable(deviceWallet)).canPullFunds(eSIMWallet), "SP-08 removal left funds access"); + } + + function _prop_bindingGrantsNoAccess(address deviceWallet, address eSIMWallet) internal { + t( + !MockDeviceWallet(payable(deviceWallet)).canPullFunds(eSIMWallet), + "SP-08 binding handed back funds access without an owner signature" + ); + } + + /// @notice SP-09: a deployed but unregistered device wallet can do nothing + function _prop_unregisteredWalletIsInert(address wallet, string memory identifier) internal { + t(!registry.isDeviceWalletValid(wallet), "SP-09 an unregistered wallet reads as valid"); + t( + registry.uniqueIdentifierToDeviceWallet(identifier) != wallet, + "SP-09 an unregistered wallet already claims its identifier" + ); + } + + // ――――――――――――――――――――――――― Helpers ―――――――――――――――――――――――――― + + /// @notice Every registered currency is inside the decimals window, and none has un-registered + function _assetTableHolds() private returns (bool) { + bytes32[] memory symbols = _allSymbols(); + address[2] memory adapters = [address(paymentAdapter), address(spareAdapter)]; + + for (uint256 a; a < adapters.length; ++a) { + for (uint256 s; s < symbols.length; ++s) { + // The plain getter, not `resolveAsset`, which reverts on a currency the owner has + // withdrawn. A withdrawn currency is still registered and still has to be in bounds. + (,, uint8 decimals,) = PaymentAdapter(adapters[a]).assets(symbols[s]); + + if (decimals == 0) { + if (ghosts.everRegisteredAsset[adapters[a]][symbols[s]]) return false; + continue; + } + ghosts.everRegisteredAsset[adapters[a]][symbols[s]] = true; + if (decimals < 2 || decimals > 36) return false; + } + } + return true; + } +} diff --git a/test/fizz/README.md b/test/fizz/README.md new file mode 100644 index 00000000..852dcb07 --- /dev/null +++ b/test/fizz/README.md @@ -0,0 +1,105 @@ +# The Echidna and Medusa harness + +A second fuzzing engine over the same protocol the Foundry invariant campaigns already cover. It is +independent of `test/foundry/`: its own deployment, its own handlers, its own properties. Nothing +here runs during an ordinary `forge test`. + +## Running it + +Everything needs the `fuzz` profile, which is what puts `test/fizz` on the test path. + +```bash +FOUNDRY_PROFILE=fuzz forge build + +# The campaigns. Run one at a time; both at once will thrash the machine. +medusa fuzz +echidna . --contract FuzzTester --config echidna.yaml + +# Debugging a handler or replaying a violation +FOUNDRY_PROFILE=fuzz forge test --match-contract FoundryTester -vvv +``` + +`PROPERTIES.md` at the repo root is the English spec: what each property claims and whether a +violation is a confirmed bug or a lead. `fizz_data/coverage-targets.md` records per-contract +coverage and the deliberate gaps. + +## Why the fuzz profile exists + +Three settings differ from the default, and each is here for a measured reason. + +**`optimizer = false`.** The Yul optimizer merges branches, so a campaign against optimized bytecode +reports roughly ten percent less coverage than the source actually reaches. Turning via-IR off would +recover all of it and does not compile: the suite is stack-too-deep without it. + +**`bytecode_hash = 'ipfs'`.** The fuzzers match deployed runtime bytecode back to a source file to +attribute coverage, and the metadata trailer is what makes that match unambiguous. The default +profile drops it, which is what holds the byte-for-byte agreement with hardhat, and which also made +the first two campaigns report `Registry` at 0.7% and `DeviceWallet` at 0.0% while a smoke test +proved purchases were running through both. + +**Its own `out` and `test` paths.** The two profiles disagree on optimizer settings, so sharing one +artifact directory turns every profile switch into a full rebuild. The separate test path keeps a +plain `forge test` from picking the harness up, the same way `[profile.scripts]` does. + +## Layout + +| File | What it holds | +|---|---| +| `Base.sol` | The deployment, the actor and wallet registries, ghost state, and the identifier namespaces | +| `Snapshots.sol` | Before and after balances for the purchase path, the only property that needs them | +| `Properties.sol` | The properties, global and specific | +| `handlers/` | One file per contract, clamped and unclamped | +| `FuzzTester.sol` | What Echidna and Medusa target | +| `FoundryTester.sol` | Smoke tests, and where a violation gets replayed | + +## How the harness drives a gated protocol + +Almost every entry point here is gated, so the handlers resolve the caller out of live state rather +than taking one from the fuzzer. Four things make that work, and each was needed before coverage +moved at all. + +**Owner keys have to be on the curve.** Every deploy path rejects a key that could never verify a +signature, so a random pair spends the whole run being refused. `_ownerKey` walks x upward until the +curve equation has a square root, which finds one in about two tries. + +**The admin is read live, never cached.** Every admin check in the protocol resolves through +`Registry.eSIMWalletAdmin()`, and the config handlers rotate it. A handler holding its own copy would +go on impersonating an address the protocol already demoted, and every admin path would sit dead for +the rest of the run. + +**Ownership is re-read on every use.** An eSIM wallet changes hands during a run, so a handler +pranking the device wallet that used to hold one is refused on every call. `_pickOwnedPair` resolves +the current pair instead of remembering one. + +**Config handlers swap between valid targets.** A fuzzed address in `setPaymentAdapter` or either +beacon would leave the rest of the run reverting against something that was never deployed. A second +adapter, a second vault and spare implementations exist so the rotation is real without being fatal. +Rotating the adapter is also the one shape that shows whether spent payment references survive a +swap, which they only do because the store moved to the registry. + +## What it deliberately does not reach + +**Anything needing a WebAuthn signature.** The assertion has to be signed by a P256 key with the +challenge bound to the operation, which the repo produces through `vm.ffi` and a Node signer. The +fuzzers cannot call it. Owner-gated functions are reached by impersonating the wallet, which is what +a signed `execute` amounts to onchain. The signature path itself is covered by the Foundry +differential and shape suites and by a fork test through the deployed EntryPoint. + +**`ProtocolAdmin`.** It owns none of the singletons in this deployment, so nothing it does is +visible in the state the properties read, and reaching it needs schedule, delay, execute in order. +It carries ten Certora rules and its own Foundry campaign. + +**UUPS upgrades and singleton ownership transfer.** Either would leave the rest of a sequence running +against something the harness never deployed. Storage-layout safety across an upgrade is pinned by +`test/foundry/unit-testing/upgrade/StorageLayout.t.sol`, which is the check that matters there. Both +beacon swaps are exercised, between real implementations. + +## Two things worth knowing before changing it + +**A prank only survives to the next call.** Resolving the adapter with `_activeAdapter()` inside a +pranked call expression spends the prank on `registry.paymentAdapter()`, and the call that was meant +to be pranked runs as the harness. Hoist the lookup above the prank. + +**A pranked call is debited from the pranked account, not from the harness.** Any handler sending +value has to fund the account it is impersonating first, and has to do it before clamping the amount +rather than after, or the ceiling reads zero and the payable branch never runs. diff --git a/test/fizz/Snapshots.sol b/test/fizz/Snapshots.sol new file mode 100644 index 00000000..3dd440f4 --- /dev/null +++ b/test/fizz/Snapshots.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import {Base} from "./Base.sol"; + +/// @notice Used to take snapshots of the state before and after a function call +/// @dev Only the purchase path needs a before and after reading. Everything else a property checks +/// is either readable at any moment or carried in ghost state, and a snapshot field that no +/// property compares is a field that can go stale without anything noticing. +abstract contract Snapshots is Base { + struct State { + /// @dev The four addresses a purchase can move settlement token between. Summed rather than + /// kept apart because the property is about the total, and which leg moved how much is + /// the adapter's business rather than something to pin here. + uint256 purchaseSideTotal; + uint256 adapterBalance; + } + + State internal stateBefore; + State internal stateAfter; + + /// @dev The addresses the next snapshot pair is about, set by the handler before it calls. + address internal snapshotDeviceWallet; + address internal snapshotESIMWallet; + + function _takeSnapshot(State storage state) private { + address adapter = registry.paymentAdapter(); + address vaultNow = registry.vault(); + + state.adapterBalance = adapter == address(0) ? 0 : settlementERC20.balanceOf(adapter); + state.purchaseSideTotal = state.adapterBalance + settlementERC20.balanceOf(vaultNow) + + settlementERC20.balanceOf(snapshotDeviceWallet) + settlementERC20.balanceOf(snapshotESIMWallet); + } + + /// @notice Names the pair the next before and after reading covers + function _snapshotPair(address deviceWallet, address eSIMWallet) internal { + snapshotDeviceWallet = deviceWallet; + snapshotESIMWallet = eSIMWallet; + } + + function snapshotBefore() internal { + _takeSnapshot(stateBefore); + } + + function snapshotAfter() internal { + _takeSnapshot(stateAfter); + } +} diff --git a/test/fizz/handlers/DeviceWalletFactoryHandler.sol b/test/fizz/handlers/DeviceWalletFactoryHandler.sol new file mode 100644 index 00000000..2a4bfc5a --- /dev/null +++ b/test/fizz/handlers/DeviceWalletFactoryHandler.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {Properties} from "../Properties.sol"; + +/// @notice Handles the interaction with DeviceWalletFactory +/// @dev `createAccount` is one of the three permissionless entry points in the protocol, so it is +/// the only handler here that is not pranked as a privileged role. It deploys live code at a +/// CREATE2 address without registering it, and `postCreateAccount` is what registers it +/// afterwards; running the two apart is how the gap between them gets exercised. +abstract contract DeviceWalletFactoryHandler is Properties { + + /// @dev What the last `createAccount` produced, so the dispatcher can register it in a later + /// call. Registration re-derives the address from the same three inputs, so they have to + /// be carried across rather than re-fuzzed. + address internal lastCounterfactualWallet; + string internal lastCounterfactualIdentifier; + bytes32[2] internal lastCounterfactualKey; + uint256 internal lastCounterfactualSalt; + + // ――――――――――――――――――――――――― Clamped ―――――――――――――――――――――――――― + + /// @notice Anyone deploying a device wallet at its deterministic address + /// @dev Left unregistered on purpose. Only `postCreateAccount` writes the registry, and a + /// wallet between the two is the state worth holding: it has code and the protocol does + /// not know it. + function deviceWalletFactory_createAccount_clamped( + uint256 identifierSeed, + uint256 keySeed, + uint256 value, + bool contested + ) public { + string memory identifier = + contested ? _contestedDeviceIdentifier(identifierSeed) : _deviceIdentifier(identifierSeed); + + vm.deal(actor, actor.balance + 1 ether); + value = clampBetween(value, 0, _spendable(actor, 1 ether)); + + deviceWalletFactory_createAccount(actor, identifier, _ownerKey(keySeed), ++saltNonce, value); + } + + /// @notice Deploying twice at the same address + /// @dev The second call forwards its value to the wallet already standing there rather than + /// deploying again, and that ETH must stay refundable. Reached only by repeating the exact + /// three inputs, which a random draw never does. + function deviceWalletFactory_createAccount_repeat(uint256 value) public { + if (lastCounterfactualWallet == address(0)) return; + + vm.deal(actor, actor.balance + 1 ether); + value = clampBetween(value, 0, _spendable(actor, 1 ether)); + + deviceWalletFactory_createAccount( + actor, lastCounterfactualIdentifier, lastCounterfactualKey, lastCounterfactualSalt, value + ); + } + + /// @notice The admin batch route, which deploys a device wallet and one eSIM wallet together + function deviceWalletFactory_deployDeviceWalletForUsers_clamped( + uint256 identifierSeed, + uint256 keySeed, + uint8 batchSize, + uint256 deposit, + bool contested + ) public { + uint256 count = clampBetween(uint256(batchSize), 1, 3); + + string[] memory identifiers = new string[](count); + bytes32[2][] memory keys = new bytes32[2][](count); + uint256[] memory salts = new uint256[](count); + uint256[] memory deposits = new uint256[](count); + + address caller = registry.eSIMWalletAdmin(); + vm.deal(caller, caller.balance + 10 ether); + uint256 each = clampBetween(deposit, 0, 1 ether); + + for (uint256 i; i < count; ++i) { + identifiers[i] = contested + ? _contestedDeviceIdentifier(identifierSeed + i) + : _deviceIdentifier(identifierSeed + i); + keys[i] = _ownerKey(keySeed + i); + salts[i] = ++saltNonce; + deposits[i] = each; + } + + deviceWalletFactory_deployDeviceWalletForUsers(identifiers, keys, salts, deposits, each * count); + } + + function deviceWalletFactory_secondary(uint8 selector, uint256 arg) public { + selector = uint8(selector % 2); + if (selector == 0) _deviceWalletFactory_postCreateAccount(); + else _deviceWalletFactory_updateDeviceWalletImplementation(arg); + } + + // ―――――――――――――――――――――――― Unclamped ――――――――――――――――――――――――― + + function deviceWalletFactory_createAccount( + address caller, + string memory _deviceUniqueIdentifier, + bytes32[2] memory _deviceWalletOwnerKey, + uint256 _salt, + uint256 value + ) public { + address predicted = + deviceWalletFactory.getCounterFactualAddress(_deviceWalletOwnerKey, _deviceUniqueIdentifier, _salt); + bool alreadyRegistered = deviceWalletFactory.deviceWalletInfoAdded(predicted); + + vm.prank(caller); + address deployed = address( + deviceWalletFactory.createAccount{value: value}(_deviceUniqueIdentifier, _deviceWalletOwnerKey, _salt) + ); + + _prop_addressMatchesPrediction(predicted, deployed); + + // Only meaningful while nothing has registered the address yet. Once `postCreateAccount` + // has run, the wallet is supposed to read as valid and claim its identifier. + if (!alreadyRegistered) _prop_unregisteredWalletIsInert(deployed, _deviceUniqueIdentifier); + + lastCounterfactualWallet = deployed; + lastCounterfactualIdentifier = _deviceUniqueIdentifier; + lastCounterfactualKey = _deviceWalletOwnerKey; + lastCounterfactualSalt = _salt; + } + + function deviceWalletFactory_deployDeviceWalletForUsers( + string[] memory _deviceUniqueIdentifiers, + bytes32[2][] memory _deviceWalletOwnersKey, + uint256[] memory _salts, + uint256[] memory _depositAmounts, + uint256 value + ) public asAdmin { + Wallets[] memory deployed = deviceWalletFactory.deployDeviceWalletForUsers{value: value}( + _deviceUniqueIdentifiers, _deviceWalletOwnersKey, _salts, _depositAmounts + ); + + for (uint256 i; i < deployed.length; ++i) { + _trackDeviceWallet(deployed[i].deviceWallet); + _trackESIMWallet(deployed[i].eSIMWallet); + } + } + + /// @notice Registers a wallet an earlier `createAccount` left unregistered + function _deviceWalletFactory_postCreateAccount() internal asAdmin { + if (lastCounterfactualWallet == address(0)) return; + + deviceWalletFactory.postCreateAccount( + lastCounterfactualWallet, lastCounterfactualIdentifier, lastCounterfactualKey, lastCounterfactualSalt + ); + _trackDeviceWallet(lastCounterfactualWallet); + lastCounterfactualWallet = address(0); + } + + /// @notice Swaps the implementation behind every device wallet at once + /// @dev Between two real implementations. A fuzzed address here would leave every wallet in the + /// run calling into nothing, and the beacon has no per-wallet opt-out to undo it with. + function _deviceWalletFactory_updateDeviceWalletImplementation(uint256 which) internal asOwner { + deviceWalletFactory.updateDeviceWalletImplementation( + which % 2 == 0 ? spareDeviceWalletImpl : deviceWalletFactory.beacon().implementation() + ); + } +} diff --git a/test/fizz/handlers/DeviceWalletHandler.sol b/test/fizz/handlers/DeviceWalletHandler.sol new file mode 100644 index 00000000..05e06d34 --- /dev/null +++ b/test/fizz/handlers/DeviceWalletHandler.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {Properties} from "../Properties.sol"; + +/// @notice Handles the interaction with DeviceWallet +/// @dev Three different callers reach this contract and they are not interchangeable. +/// `toggleAccessToFunds` and `transferOwnership` are `onlySelf`, so they are pranked as the +/// wallet itself, which is what an owner-signed `execute` amounts to onchain. `pullToken` is +/// pranked as an eSIM wallet the device wallet holds. `deployESIMWallet` is the admin's. +abstract contract DeviceWalletHandler is Properties { + + // ――――――――――――――――――――――――― Clamped ―――――――――――――――――――――――――― + + /// @notice An eSIM wallet drawing on its device wallet's token balance + function deviceWallet_pullToken_clamped(uint256 walletSeed, uint256 amount) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + uint256 held = settlementERC20.balanceOf(deviceWallet); + if (held == 0) return; + + amount = clampBetween(amount, 1, held); + deviceWallet_pullToken(eSIMWallet, deviceWallet, settlementToken, amount); + } + + /// @notice A pull for the device wallet's entire token balance + /// @dev The grant is per wallet with no amount on it, so what one eSIM wallet may take is + /// everything. Worth reaching deliberately rather than waiting for a random draw to sum + /// to the balance. + function deviceWallet_pullToken_fullBalance(uint256 walletSeed) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + uint256 held = settlementERC20.balanceOf(deviceWallet); + if (held == 0) return; + + deviceWallet_pullToken(eSIMWallet, deviceWallet, settlementToken, held); + } + + /// @notice The admin deploying another eSIM wallet under an existing device wallet + function deviceWallet_deployESIMWallet_clamped(uint256 walletSeed, uint256 salt) public { + address deviceWallet = _pickDeviceWallet(walletSeed); + if (deviceWallet == address(0)) return; + + deviceWallet_deployESIMWallet(deviceWallet, false, ++saltNonce + (salt % 8)); + } + + /// @notice Grants or revokes an eSIM wallet's right to spend its device wallet's money + function deviceWallet_toggleAccessToFunds_clamped(uint256 walletSeed, bool hasAccess) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + deviceWallet_toggleAccessToFunds(deviceWallet, eSIMWallet, hasAccess); + } + + /// @notice Releases an eSIM wallet, which is what raises its standby flag + function deviceWallet_removeESIMWallet_clamped(uint256 walletSeed, bool callBackETH) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + deviceWallet_removeESIMWallet(deviceWallet, deviceWallet, eSIMWallet, callBackETH); + } + + /// @notice Re-adds an eSIM wallet that already names this device wallet as its owner + /// @dev The binding guard reads the eSIM wallet's live `owner()`, so this only goes through + /// after a handover has moved it. That ordering is the point. + function deviceWallet_addESIMWallet_clamped(uint256 walletSeed, bool hasAccess) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + address owner = MockESIMWallet(payable(eSIMWallet)).owner(); + if (owner == address(0)) return; + + deviceWallet_addESIMWallet(owner, owner, eSIMWallet, hasAccess); + } + + /// @notice ETH arriving at a device wallet from outside any protocol path + function deviceWallet_donateETH(uint256 walletSeed, uint256 amount) public { + address deviceWallet = _pickDeviceWallet(walletSeed); + if (deviceWallet == address(0) || actor.balance == 0) return; + + amount = clampBetween(amount, 1, actor.balance); + Actor(payable(actor)).forceSendETH(deviceWallet, amount); + } + + /// @notice Settlement token arriving at a device wallet from outside any protocol path + function deviceWallet_donateERC20(uint256 walletSeed, uint256 amount) public { + address deviceWallet = _pickDeviceWallet(walletSeed); + if (deviceWallet == address(0)) return; + + uint256 balance = settlementERC20.balanceOf(actor); + if (balance == 0) return; + + amount = clampBetween(amount, 1, balance); + vm.prank(actor); + settlementERC20.transfer(deviceWallet, amount); + } + + function deviceWallet_secondary(uint8 selector, uint256 walletSeed, uint256 arg) public { + selector = uint8(selector % 2); + if (selector == 0) _deviceWallet_transferOwnership(walletSeed, arg); + else _deviceWallet_pullTokenAsStranger(walletSeed, arg); + } + + // ―――――――――――――――――――――――― Unclamped ――――――――――――――――――――――――― + + /// @dev The association and the funds grant are separate guards, and a caller failing the first + /// never reaches the second, so both are read before the call rather than inferred after. + function deviceWallet_pullToken(address caller, address deviceWallet, address _token, uint256 _amount) public { + bool associated = MockDeviceWallet(payable(deviceWallet)).isValidESIMWallet(caller); + bool granted = MockDeviceWallet(payable(deviceWallet)).canPullFunds(caller); + bool wasPaused = registry.paused(); + + vm.prank(caller); + MockDeviceWallet(payable(deviceWallet)).pullToken(_token, _amount); + + if (!associated || !granted) ghosts.unauthorizedPull = true; + if (wasPaused) ghosts.pausedCallSucceeded = true; + } + + function deviceWallet_deployESIMWallet(address deviceWallet, bool _hasAccessToFunds, uint256 _salt) public { + vm.prank(registry.eSIMWalletAdmin()); + address deployed = MockDeviceWallet(payable(deviceWallet)).deployESIMWallet(_hasAccessToFunds, _salt); + _trackESIMWallet(deployed); + } + + function deviceWallet_toggleAccessToFunds(address deviceWallet, address _eSIMWallet, bool _hasAccess) public { + vm.prank(deviceWallet); + MockDeviceWallet(payable(deviceWallet)).toggleAccessToFunds(_eSIMWallet, _hasAccess); + } + + function deviceWallet_removeESIMWallet( + address caller, + address deviceWallet, + address _eSIMWallet, + bool _callBackETH + ) public { + vm.prank(caller); + MockDeviceWallet(payable(deviceWallet)).removeESIMWallet(_eSIMWallet, _callBackETH); + + _prop_removalStripsAccess(deviceWallet, _eSIMWallet); + } + + function deviceWallet_addESIMWallet( + address caller, + address deviceWallet, + address _eSIMWallet, + bool _hasAccess + ) public { + vm.prank(caller); + MockDeviceWallet(payable(deviceWallet)).addESIMWallet(_eSIMWallet, _hasAccess); + + _prop_bindingGrantsNoAccess(deviceWallet, _eSIMWallet); + } + + /// @notice Rotates a device wallet's P256 key + /// @dev The key has to be on the curve or the wallet is bricked for good, and the deploy paths + /// refuse an off-curve one, so this path has to as well. + function _deviceWallet_transferOwnership(uint256 walletSeed, uint256 keySeed) internal { + address deviceWallet = _pickDeviceWallet(walletSeed); + if (deviceWallet == address(0)) return; + + vm.prank(deviceWallet); + MockDeviceWallet(payable(deviceWallet)).transferOwnership(_ownerKey(keySeed)); + } + + /// @notice An eSIM wallet reaching for a device wallet that does not hold it + /// @dev The association check and the funds-access check are separate guards, and a caller that + /// fails the first never reaches the second. This is the call that fails the first. + function _deviceWallet_pullTokenAsStranger(uint256 walletSeed, uint256 amount) internal { + address deviceWallet = _pickDeviceWallet(walletSeed); + address stranger = _pickESIMWallet(walletSeed + 1); + if (deviceWallet == address(0) || stranger == address(0)) return; + if (MockDeviceWallet(payable(deviceWallet)).isValidESIMWallet(stranger)) return; + + deviceWallet_pullToken(stranger, deviceWallet, settlementToken, clampBetween(amount, 1, 1e12)); + } +} diff --git a/test/fizz/handlers/ESIMWalletFactoryHandler.sol b/test/fizz/handlers/ESIMWalletFactoryHandler.sol new file mode 100644 index 00000000..4bdc45cb --- /dev/null +++ b/test/fizz/handlers/ESIMWalletFactoryHandler.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {Properties} from "../Properties.sol"; + +/// @notice Handles the interaction with ESIMWalletFactory +/// @dev A device wallet calling here directly may only name itself. The stranger handler below is +/// the call that rule exists to refuse: without it a device wallet could take the CREATE2 +/// address another one would get for the same salt, leaving that deployment to fail with +/// nothing to say why. +abstract contract ESIMWalletFactoryHandler is Properties { + + // ――――――――――――――――――――――――― Clamped ―――――――――――――――――――――――――― + + /// @notice A device wallet deploying an eSIM wallet for itself + function eSIMWalletFactory_deployESIMWallet_clamped(uint256 walletSeed, uint256 salt) public { + address deviceWallet = _pickDeviceWallet(walletSeed); + if (deviceWallet == address(0)) return; + + eSIMWalletFactory_deployESIMWallet(deviceWallet, deviceWallet, ++saltNonce + (salt % 8)); + } + + /// @notice A device wallet naming a different one as the owner of what it deploys + function eSIMWalletFactory_deployESIMWallet_forStranger(uint256 walletSeed, uint256 targetSeed, uint256 salt) + public + { + address caller = _pickDeviceWallet(walletSeed); + address target = _pickDeviceWallet(targetSeed); + if (caller == address(0) || target == address(0) || caller == target) return; + + eSIMWalletFactory_deployESIMWallet(caller, target, ++saltNonce + (salt % 8)); + } + + /// @notice A salt some wallet already stands at + /// @dev CREATE2 reverts with no data on a reused slot, so the factory checks first and names + /// the salt back. Reaching it needs the salt repeated deliberately. + function eSIMWalletFactory_deployESIMWallet_reusedSalt(uint256 walletSeed) public { + address deviceWallet = _pickDeviceWallet(walletSeed); + if (deviceWallet == address(0) || saltNonce == 0) return; + + eSIMWalletFactory_deployESIMWallet(deviceWallet, deviceWallet, saltNonce); + } + + function eSIMWalletFactory_secondary(uint8 selector, uint256 arg) public { + selector = uint8(selector % 1); + if (selector == 0) _eSIMWalletFactory_updateESIMWalletImplementation(arg); + } + + // ―――――――――――――――――――――――― Unclamped ――――――――――――――――――――――――― + + function eSIMWalletFactory_deployESIMWallet(address caller, address _deviceWalletAddress, uint256 _salt) public { + bool callerIsDeviceWallet = registry.isDeviceWalletValid(caller); + address predicted = eSIMWalletFactory.getCounterFactualAddress(_deviceWalletAddress, _salt); + + vm.prank(caller); + address deployed = eSIMWalletFactory.deployESIMWallet(_deviceWalletAddress, _salt); + + if (callerIsDeviceWallet && _deviceWalletAddress != caller) ghosts.unauthorizedForeignDeploy = true; + _prop_addressMatchesPrediction(predicted, deployed); + _trackESIMWallet(deployed); + } + + /// @notice Swaps the implementation behind every eSIM wallet at once + /// @dev Between two real implementations, for the same reason the device wallet beacon is. + function _eSIMWalletFactory_updateESIMWalletImplementation(uint256 which) internal asOwner { + eSIMWalletFactory.updateESIMWalletImplementation( + which % 2 == 0 ? spareESIMWalletImpl : eSIMWalletFactory.beacon().implementation() + ); + } +} diff --git a/test/fizz/handlers/ESIMWalletHandler.sol b/test/fizz/handlers/ESIMWalletHandler.sol new file mode 100644 index 00000000..7733b744 --- /dev/null +++ b/test/fizz/handlers/ESIMWalletHandler.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {Properties} from "../Properties.sol"; + +/// @notice Handles the interaction with ESIMWallet +/// @dev Every entry point here is gated on the caller being the owning device wallet, the admin, or +/// the nominated new owner, so each handler resolves that address out of live state rather than +/// taking one from the fuzzer. A handler pranking the wallet that used to own an eSIM wallet is +/// refused on every call, which is why `_pickOwnedPair` re-reads ownership on each use. +/// +/// `populateHistory`, `recordSettledPurchase` and `setESIMUniqueIdentifier` have no handler. +/// All three are `onlyRegistry` with no guard of their own, so pranking the registry to call +/// them directly would reach the same code while skipping the registry-side bookkeeping the +/// properties check against. They are exercised through their real callers instead. +abstract contract ESIMWalletHandler is Properties { + + // ――――――――――――――――――――――――― Clamped ―――――――――――――――――――――――――― + + /// @notice A purchase priced under the ceiling, paid in the one currency that can move + function eSIMWallet_buyDataBundleWithToken_clamped( + uint256 walletSeed, + uint256 bundleSeed, + uint64 priceUSDCents, + uint256 refSeed, + bool asAdminCaller + ) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, _effectiveCap(eSIMWallet))); + + uint256 amountIn = _settlementAmount(priceUSDCents); + _fundForPurchase(eSIMWallet, deviceWallet, amountIn); + + eSIMWallet_buyDataBundleWithToken( + asAdminCaller ? registry.eSIMWalletAdmin() : deviceWallet, + eSIMWallet, + _bundle(bundleSeed, priceUSDCents), + ASSET_USDC, + amountIn, + _paymentReference(refSeed) + ); + } + + /// @notice The smallest price the ceiling allows + /// @dev `_quote` divides by 100 to turn cents into whole dollars before scaling, so a price + /// small enough relative to the asset's decimals truncates to zero units. At six decimals + /// one cent is still 10,000 units, but the two-decimal currency registered alongside it + /// makes the boundary reachable, and a decimals change would move it here first. + function eSIMWallet_buyDataBundleWithToken_dustPrice(uint256 walletSeed, uint256 refSeed) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + _fundForPurchase(eSIMWallet, deviceWallet, _settlementAmount(1)); + + eSIMWallet_buyDataBundleWithToken( + deviceWallet, eSIMWallet, _bundle(refSeed, 1), ASSET_USDC, type(uint256).max, _paymentReference(refSeed) + ); + } + + /// @notice A purchase priced exactly at the ceiling, which must be accepted + /// @dev The guard is `>`, so the ceiling itself is legal. A run that only ever priced below it + /// would never tell the two apart. + function eSIMWallet_buyDataBundleWithToken_atCap(uint256 walletSeed, uint256 refSeed) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + uint64 cap = uint64(_effectiveCap(eSIMWallet)); + uint256 amountIn = _settlementAmount(cap); + _fundForPurchase(eSIMWallet, deviceWallet, amountIn); + + eSIMWallet_buyDataBundleWithToken( + deviceWallet, eSIMWallet, _bundle(refSeed, cap), ASSET_USDC, amountIn, _paymentReference(refSeed) + ); + } + + /// @notice A purchase the wallet cannot cover, so the shortfall has to come from the device wallet + /// @dev The pull is where a wallet without `canPullFunds` is refused and where a fee-on-transfer + /// asset under-delivers, and neither is reachable while the wallet already holds enough. + function eSIMWallet_buyDataBundleWithToken_needsPull( + uint256 walletSeed, + uint64 priceUSDCents, + uint256 refSeed + ) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, _effectiveCap(eSIMWallet))); + + // Strip the wallet so the pull is the only way the purchase can be funded + uint256 held = settlementERC20.balanceOf(eSIMWallet); + if (held > 0) { + vm.prank(eSIMWallet); + settlementERC20.transfer(address(this), held); + } + settlementERC20.mint(deviceWallet, _settlementAmount(priceUSDCents)); + + eSIMWallet_buyDataBundleWithToken( + deviceWallet, + eSIMWallet, + _bundle(refSeed, priceUSDCents), + ASSET_USDC, + type(uint256).max, + _paymentReference(refSeed) + ); + } + + /// @notice A currency that is registered but cannot settle + /// @dev USD has no token address and ETH is not a dollar unit, so these are the two refusals + /// `resolveAsset` and `quote` are written for. The fuzzer would rarely guess a registered + /// symbol on its own. + function eSIMWallet_buyDataBundleWithToken_unsettleableAsset( + uint256 walletSeed, + uint256 refSeed, + bool useETH + ) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + eSIMWallet_buyDataBundleWithToken( + deviceWallet, + eSIMWallet, + _bundle(refSeed, 100), + useETH ? ASSET_ETH : ASSET_USD, + type(uint256).max, + _paymentReference(refSeed) + ); + } + + /// @notice Nominates another device wallet to take an eSIM wallet over + function eSIMWallet_requestTransferOwnership_clamped(uint256 walletSeed, uint256 targetSeed) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + address target = _pickDeviceWallet(targetSeed); + if (target == address(0)) return; + + eSIMWallet_requestTransferOwnership(deviceWallet, eSIMWallet, target); + } + + /// @notice Completes a handover, called by whichever address the wallet currently names + /// @dev The nominee is read off the wallet rather than guessed. There is one address that can + /// make this call at any moment and the fuzzer would never land on it. + function eSIMWallet_acceptOwnershipTransfer_clamped(uint256 walletSeed) public { + uint256 count = eSIMWallets.length; + if (count == 0) return; + + for (uint256 i; i < count; ++i) { + address candidate = eSIMWallets[(walletSeed + i) % count]; + address nominee = MockESIMWallet(payable(candidate)).newRequestedOwner(); + if (nominee == address(0)) continue; + + eSIMWallet_acceptOwnershipTransfer(nominee, candidate); + return; + } + } + + function eSIMWallet_sendETHToDeviceWallet_clamped(uint256 walletSeed, uint256 amount) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0) || eSIMWallet.balance == 0) return; + + amount = clampBetween(amount, 1, eSIMWallet.balance); + eSIMWallet_sendETHToDeviceWallet(deviceWallet, eSIMWallet, amount); + } + + function eSIMWallet_sendTokenToDeviceWallet_clamped(uint256 walletSeed, uint256 amount) public { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + uint256 held = settlementERC20.balanceOf(eSIMWallet); + if (held == 0) return; + + amount = clampBetween(amount, 1, held); + eSIMWallet_sendTokenToDeviceWallet(deviceWallet, eSIMWallet, settlementToken, amount); + } + + /// @notice ETH arriving at an eSIM wallet from outside any protocol path + function eSIMWallet_donateETH(uint256 walletSeed, uint256 amount) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0) || actor.balance == 0) return; + + amount = clampBetween(amount, 1, actor.balance); + Actor(payable(actor)).forceSendETH(eSIMWallet, amount); + } + + /// @notice Settlement token arriving at an eSIM wallet from outside any protocol path + function eSIMWallet_donateERC20(uint256 walletSeed, uint256 amount) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + uint256 balance = settlementERC20.balanceOf(actor); + if (balance == 0) return; + + amount = clampBetween(amount, 1, balance); + vm.prank(actor); + settlementERC20.transfer(eSIMWallet, amount); + } + + function eSIMWallet_secondary(uint8 selector, uint256 walletSeed, uint256 arg) public { + selector = uint8(selector % 2); + if (selector == 0) _eSIMWallet_setPriceCapUSDCents(walletSeed, arg); + else _eSIMWallet_donateETHToAdapter(arg); + } + + // ―――――――――――――――――――――――― Unclamped ――――――――――――――――――――――――― + + function eSIMWallet_buyDataBundleWithToken( + address caller, + address eSIMWallet, + DataBundleDetails memory _dataBundleDetail, + bytes32 _asset, + uint256 _maxAmountIn, + bytes32 _paymentRef + ) public { + address owningDeviceWallet = _safeESIMOwner(eSIMWallet); + _snapshotPair(owningDeviceWallet, eSIMWallet); + snapshotBefore(); + + bool wasPaused = registry.paused(); + uint256 capAtCallTime = _effectiveCap(eSIMWallet); + + vm.prank(caller); + MockESIMWallet(payable(eSIMWallet)).buyDataBundleWithToken( + _dataBundleDetail, _asset, _maxAmountIn, _paymentRef + ); + + // Everything below runs only when the call went through + if (wasPaused) ghosts.pausedCallSucceeded = true; + if (_dataBundleDetail.priceUSDCents > capAtCallTime) ghosts.purchaseAboveCap = true; + + snapshotAfter(); + ghosts.witnessedPurchases[eSIMWallet] += 1; + _recordSpentReference(eSIMWallet, _paymentRef); + + // Only the settleable currency actually moves tokens, so the conservation check is scoped + // to it. The other two registered symbols never reach a transfer. + if (_asset == ASSET_USDC) _prop_purchaseConservesValue(); + } + + function eSIMWallet_requestTransferOwnership(address caller, address eSIMWallet, address _newOwner) public { + vm.prank(caller); + MockESIMWallet(payable(eSIMWallet)).requestTransferOwnership(_newOwner); + } + + function eSIMWallet_acceptOwnershipTransfer(address caller, address eSIMWallet) public { + address associationBefore = registry.isESIMWalletValid(eSIMWallet); + + vm.prank(caller); + MockESIMWallet(payable(eSIMWallet)).acceptOwnershipTransfer(); + + _prop_handoverClearsThePriceCap(eSIMWallet); + _prop_handoverLeavesTheAssociation(eSIMWallet, associationBefore); + } + + function eSIMWallet_sendETHToDeviceWallet(address caller, address eSIMWallet, uint256 _amount) public { + vm.prank(caller); + MockESIMWallet(payable(eSIMWallet)).sendETHToDeviceWallet(_amount); + } + + function eSIMWallet_sendTokenToDeviceWallet( + address caller, + address eSIMWallet, + address _token, + uint256 _amount + ) public { + vm.prank(caller); + MockESIMWallet(payable(eSIMWallet)).sendTokenToDeviceWallet(_token, _amount); + } + + function _eSIMWallet_setPriceCapUSDCents(uint256 walletSeed, uint256 cap) internal { + (address eSIMWallet, address deviceWallet) = _pickOwnedPair(walletSeed); + if (eSIMWallet == address(0)) return; + + vm.prank(deviceWallet); + MockESIMWallet(payable(eSIMWallet)).setPriceCapUSDCents(uint64(clampBetween(cap, 0, type(uint64).max))); + } + + /// @notice Leaves settlement token sitting on the adapter with no purchase behind it + /// @dev The adapter is meant to hold nothing between calls. A residual balance is what would + /// let one purchase settle against another's funding. + function _eSIMWallet_donateETHToAdapter(uint256 amount) internal { + uint256 balance = settlementERC20.balanceOf(actor); + if (balance == 0) return; + + amount = clampBetween(amount, 1, balance); + address adapter = address(_activeAdapter()); + + vm.prank(actor); + settlementERC20.transfer(adapter, amount); + } + + // ――――――――――――――――――――――――― Helpers ―――――――――――――――――――――――――― + + /// @notice Puts enough settlement token where the purchase path will look for it + function _fundForPurchase(address eSIMWallet, address deviceWallet, uint256 amountIn) internal { + if (settlementERC20.balanceOf(eSIMWallet) < amountIn) { + settlementERC20.mint(deviceWallet, amountIn); + } + } +} diff --git a/test/fizz/handlers/Handlers.sol b/test/fizz/handlers/Handlers.sol new file mode 100644 index 00000000..f18a7db2 --- /dev/null +++ b/test/fizz/handlers/Handlers.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {LazyWalletRegistryHandler} from "./LazyWalletRegistryHandler.sol"; +import {RegistryHandler} from "./RegistryHandler.sol"; +import {DeviceWalletHandler} from "./DeviceWalletHandler.sol"; +import {DeviceWalletFactoryHandler} from "./DeviceWalletFactoryHandler.sol"; +import {ESIMWalletHandler} from "./ESIMWalletHandler.sol"; +import {ESIMWalletFactoryHandler} from "./ESIMWalletFactoryHandler.sol"; +import {PaymentAdapterHandler} from "./PaymentAdapterHandler.sol"; + +/// @notice Inherits from all the handlers to expose all entry points in a single contract. +/// Manages environment changes (e.g. current actor, current token, mocks setup, etc.). +abstract contract Handlers is + LazyWalletRegistryHandler, + RegistryHandler, + DeviceWalletHandler, + DeviceWalletFactoryHandler, + ESIMWalletHandler, + ESIMWalletFactoryHandler, + PaymentAdapterHandler +{ + function setCurrentActor(uint256 entropy) public { + actor = actors[entropy % actors.length]; + } +} diff --git a/test/fizz/handlers/LazyWalletRegistryHandler.sol b/test/fizz/handlers/LazyWalletRegistryHandler.sol new file mode 100644 index 00000000..db186c60 --- /dev/null +++ b/test/fizz/handlers/LazyWalletRegistryHandler.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {Properties} from "../Properties.sol"; + +/// @notice Handles the interaction with LazyWalletRegistry +/// @dev The fiat route, where purchase history is written before any wallet exists and copied in +/// afterwards. Two cursors run through it, `historyEntriesCopied` and `eSIMWalletsDeployed`, +/// and both are only safe because history freezes once a device has a wallet. Reaching that +/// freeze needs the calls in a particular order, which is what the clamped handlers arrange: +/// an all-random draw would almost never populate history before deploying against it. +abstract contract LazyWalletRegistryHandler is Properties { + + /// @dev Kept small so the same identifiers come up repeatedly and the cursors actually advance + /// across a sequence instead of every call starting a fresh, empty identifier. + uint256 internal constant LAZY_DEVICES = 6; + + // ――――――――――――――――――――――――― Clamped ―――――――――――――――――――――――――― + + /// @notice Pre-deployment purchase history for one device and one of its eSIMs + function lazyWalletRegistry_batchPopulateHistory_clamped( + uint256 deviceSeed, + uint256 eSIMSeed, + uint8 entryCount, + uint64 priceUSDCents, + bool contested + ) public { + string[] memory devices = new string[](1); + string[][] memory eSIMs = new string[][](1); + DataBundleDetails[][] memory bundles = new DataBundleDetails[][](1); + + devices[0] = contested + ? _contestedDeviceIdentifier(deviceSeed) + : _lazyDeviceIdentifier(deviceSeed % LAZY_DEVICES); + + uint256 count = clampBetween(uint256(entryCount), 1, 6); + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, registry.defaultPriceCapUSDCents())); + + // The two inner arrays are read in step, one bundle per identifier, so an eSIM gets several + // history entries by being named several times rather than by carrying a longer list. + string memory eSIMIdentifier = + contested ? _contestedESIMIdentifier(eSIMSeed) : _lazyESIMIdentifier(eSIMSeed % (LAZY_DEVICES * 2)); + + eSIMs[0] = new string[](count); + bundles[0] = new DataBundleDetails[](count); + for (uint256 i; i < count; ++i) { + eSIMs[0][i] = eSIMIdentifier; + bundles[0][i] = _bundle(eSIMSeed + i, priceUSDCents); + } + + lazyWalletRegistry_batchPopulateHistory(devices, eSIMs, bundles); + } + + /// @notice Deploys the device wallet a lazy device has been accumulating history for + /// @dev Moves the caller's ETH into the new wallet, which is why the pause check on this + /// function matters and why the handler is reachable while paused. + function lazyWalletRegistry_deployLazyWalletAndSetESIMIdentifier_clamped( + uint256 deviceSeed, + uint256 keySeed, + uint256 deposit, + uint8 maxWallets, + bool contested + ) public { + string memory identifier = contested + ? _contestedDeviceIdentifier(deviceSeed) + : _lazyDeviceIdentifier(deviceSeed % LAZY_DEVICES); + + // Topped up before the clamp, not after: a pranked call is debited from the pranked + // account, so a ceiling read off an empty admin would pin every deposit at zero and the + // ETH-moving branch this function exists to exercise would never run. + address caller = registry.eSIMWalletAdmin(); + vm.deal(caller, caller.balance + 5 ether); + deposit = clampBetween(deposit, 0, _spendable(caller, 5 ether)); + + lazyWalletRegistry_deployLazyWalletAndSetESIMIdentifier( + _ownerKey(keySeed), + identifier, + ++saltNonce, + deposit, + clampBetween(uint256(maxWallets), 1, 20) + ); + } + + /// @notice Deploys the next batch of eSIM wallets for a device already deployed + function lazyWalletRegistry_deployMoreESIMWalletsForLazyDevice_clamped( + uint256 deviceSeed, + uint8 maxWallets, + bool contested + ) public { + string memory identifier = contested + ? _contestedDeviceIdentifier(deviceSeed) + : _lazyDeviceIdentifier(deviceSeed % LAZY_DEVICES); + + lazyWalletRegistry_deployMoreESIMWalletsForLazyDevice(identifier, clampBetween(uint256(maxWallets), 1, 20)); + } + + /// @notice Copies a slice of stored history into a deployed wallet + /// @dev The cursor only ever advances here, and the whole ordering guarantee on the purchase + /// paths rests on it reaching the end. Small batches on purpose, so a run leaves history + /// partly copied often, which is the state `requireLazyHistoryCopied` exists to refuse. + function lazyWalletRegistry_setHistoryForLazyWallet_clamped( + uint256 eSIMSeed, + uint8 maxEntries, + bool contested + ) public { + string memory identifier = + contested ? _contestedESIMIdentifier(eSIMSeed) : _lazyESIMIdentifier(eSIMSeed % (LAZY_DEVICES * 2)); + + lazyWalletRegistry_setHistoryForLazyWallet(identifier, clampBetween(uint256(maxEntries), 1, 50)); + } + + /// @notice Moves an eSIM's reservation to a different device before either has a wallet + /// @dev Frozen the moment either device has one, which is the guard the two cursors depend on. + function lazyWalletRegistry_switchESIMIdentifierToNewDeviceIdentifier_clamped( + uint256 eSIMSeed, + uint256 fromSeed, + uint256 toSeed + ) public { + lazyWalletRegistry_switchESIMIdentifierToNewDeviceIdentifier( + _lazyESIMIdentifier(eSIMSeed % (LAZY_DEVICES * 2)), + _lazyDeviceIdentifier(fromSeed % LAZY_DEVICES), + _lazyDeviceIdentifier(toSeed % LAZY_DEVICES) + ); + } + + // ―――――――――――――――――――――――― Unclamped ――――――――――――――――――――――――― + + function lazyWalletRegistry_batchPopulateHistory( + string[] memory _deviceUniqueIdentifiers, + string[][] memory _eSIMUniqueIdentifiers, + DataBundleDetails[][] memory _dataBundleDetails + ) public asAdmin { + lazyWalletRegistry.batchPopulateHistory( + _deviceUniqueIdentifiers, _eSIMUniqueIdentifiers, _dataBundleDetails + ); + } + + function lazyWalletRegistry_deployLazyWalletAndSetESIMIdentifier( + bytes32[2] memory _deviceOwnerPublicKey, + string memory _deviceUniqueIdentifier, + uint256 _salt, + uint256 _depositAmount, + uint256 _maxWallets + ) public asAdmin { + // The one lazy entry point that moves ETH, and the one the pause reaches. Its two siblings + // move none and are deliberately exempt, so neither is checked here. + bool wasPaused = registry.paused(); + + (address deviceWallet, address[] memory deployed,) = lazyWalletRegistry + .deployLazyWalletAndSetESIMIdentifier{value: _depositAmount}( + _deviceOwnerPublicKey, _deviceUniqueIdentifier, _salt, _depositAmount, _maxWallets + ); + + if (wasPaused) ghosts.pausedCallSucceeded = true; + if (!_isKnownDeviceWallet(deviceWallet)) _trackDeviceWallet(deviceWallet); + _trackDeployed(deployed); + } + + function lazyWalletRegistry_deployMoreESIMWalletsForLazyDevice( + string memory _deviceUniqueIdentifier, + uint256 _maxWallets + ) public asAdmin { + (address[] memory deployed,) = + lazyWalletRegistry.deployMoreESIMWalletsForLazyDevice(_deviceUniqueIdentifier, _maxWallets); + _trackDeployed(deployed); + } + + function lazyWalletRegistry_setHistoryForLazyWallet(string memory _eSIMIdentifier, uint256 _maxEntries) + public + asAdmin + { + lazyWalletRegistry.setHistoryForLazyWallet(_eSIMIdentifier, _maxEntries); + } + + function lazyWalletRegistry_switchESIMIdentifierToNewDeviceIdentifier( + string memory _eSIMIdentifier, + string memory _oldDeviceIdentifier, + string memory _newDeviceIdentifier + ) public asAdmin { + lazyWalletRegistry.switchESIMIdentifierToNewDeviceIdentifier( + _eSIMIdentifier, _oldDeviceIdentifier, _newDeviceIdentifier + ); + } + + // ――――――――――――――――――――――――― Helpers ―――――――――――――――――――――――――― + + /// @notice Records whatever a lazy deployment produced, so later handlers can reach it + function _trackDeployed(address[] memory deployed) internal { + for (uint256 i; i < deployed.length; ++i) { + _trackESIMWallet(deployed[i]); + } + } + + /// @dev A lazy device deploys one device wallet and many eSIM wallets across several calls, so + /// the same device wallet comes back in every batch after the first. + function _isKnownDeviceWallet(address wallet) internal view returns (bool) { + for (uint256 i; i < deviceWallets.length; ++i) { + if (deviceWallets[i] == wallet) return true; + } + return false; + } +} diff --git a/test/fizz/handlers/PaymentAdapterHandler.sol b/test/fizz/handlers/PaymentAdapterHandler.sol new file mode 100644 index 00000000..eacd720d --- /dev/null +++ b/test/fizz/handlers/PaymentAdapterHandler.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {Properties} from "../Properties.sol"; + +/// @notice Handles the interaction with PaymentAdapter +/// @dev `settle` is written not to trust its caller: it re-derives the amount from the price +/// through the same helper `quote` uses, and checks its own balance rather than believing the +/// funding claim. Both of those are called here directly, funded and unfunded, so the two +/// guards are exercised without a whole purchase having to line up first. In production only +/// `buyDataBundleWithToken` reaches this, since an eSIM wallet has no way to be made to call +/// anything else. +abstract contract PaymentAdapterHandler is Properties { + + // ――――――――――――――――――――――――― Clamped ―――――――――――――――――――――――――― + + /// @notice A settlement funded exactly to the price, which is what a real purchase does + function paymentAdapter_settle_funded(uint256 walletSeed, uint64 priceUSDCents) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, registry.defaultPriceCapUSDCents())); + uint256 amountIn = _settlementAmount(priceUSDCents); + + PaymentAdapter adapter = _activeAdapter(); + settlementERC20.mint(address(adapter), amountIn); + + paymentAdapter_settle(eSIMWallet, ASSET_USDC, priceUSDCents, amountIn, eSIMWallet); + } + + /// @notice A settlement claiming funding that never arrived + /// @dev The adapter holds nothing between calls, so this is the check standing between a + /// caller's word and the vault. + function paymentAdapter_settle_unfunded(uint256 walletSeed, uint64 priceUSDCents) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, registry.defaultPriceCapUSDCents())); + + paymentAdapter_settle( + eSIMWallet, ASSET_USDC, priceUSDCents, _settlementAmount(priceUSDCents), eSIMWallet + ); + } + + /// @notice A settlement funded past the price, which is the branch that would refund + /// @dev The one caller in production passes `quote`'s own output, so the refund never fires + /// there. Funding it over is the only way to reach the branch at all. + function paymentAdapter_settle_overFunded(uint256 walletSeed, uint64 priceUSDCents, uint256 excess) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, registry.defaultPriceCapUSDCents())); + uint256 amountIn = _settlementAmount(priceUSDCents) + clampBetween(excess, 1, 1e9); + + PaymentAdapter adapter = _activeAdapter(); + settlementERC20.mint(address(adapter), amountIn); + + paymentAdapter_settle(eSIMWallet, ASSET_USDC, priceUSDCents, amountIn, eSIMWallet); + } + + /// @notice The conversion on its own, checked against a formula written here + /// @dev Nothing else in the harness calls `quote` directly: the funding amounts are worked out + /// from `_settlementAmount`, and `settle` recomputes its own. So without this the public + /// entry point to the protocol's only arithmetic goes untouched by the campaign. + function paymentAdapter_quote(uint256 symbolSeed, uint64 priceUSDCents) public { + bytes32 symbol = symbolSeed % 2 == 0 ? ASSET_USDC : ASSET_USD; + PaymentAdapter adapter = _activeAdapter(); + + // Read through the plain getter: `resolveAsset` reverts on a withdrawn currency, and the + // config handlers withdraw them, so the guard has to come before the read that would revert. + (bool allowed, bool isDollarUnit, uint8 decimals,) = adapter.assets(symbol); + if (!allowed || !isDollarUnit) return; + + _prop_quoteIsExact(priceUSDCents, decimals, adapter.quote(symbol, priceUSDCents)); + } + + /// @notice A higher price must never quote lower + function paymentAdapter_quote_monotonic(uint256 symbolSeed, uint64 lowPrice, uint64 highPrice) public { + bytes32 symbol = symbolSeed % 2 == 0 ? ASSET_USDC : ASSET_USD; + PaymentAdapter adapter = _activeAdapter(); + + (bool allowed, bool isDollarUnit,,) = adapter.assets(symbol); + if (!allowed || !isDollarUnit) return; + + if (lowPrice > highPrice) (lowPrice, highPrice) = (highPrice, lowPrice); + + _prop_quoteIsMonotonic( + adapter.quote(symbol, lowPrice), adapter.quote(symbol, highPrice), highPrice > lowPrice + ); + } + + /// @notice A settlement funded from what `quote` itself returned + /// @dev The two share one helper, so they cannot disagree today. That is the point: this is the + /// tripwire for a change that gives `settle` its own arithmetic. + function paymentAdapter_settle_atQuotedAmount(uint256 walletSeed, uint64 priceUSDCents) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, registry.defaultPriceCapUSDCents())); + + PaymentAdapter adapter = _activeAdapter(); + uint256 quoted = adapter.quote(ASSET_USDC, priceUSDCents); + settlementERC20.mint(address(adapter), quoted); + + vm.prank(eSIMWallet); + (uint256 spent,) = adapter.settle(ASSET_USDC, priceUSDCents, quoted, eSIMWallet); + + eq(spent, quoted, "SP-03 settle disagreed with quote for the same inputs"); + _prop_settlementMatchesReference(priceUSDCents, spent); + } + + /// @notice Settlement token left on the adapter with no purchase behind it + function paymentAdapter_donateERC20(uint256 amount) public { + uint256 balance = settlementERC20.balanceOf(actor); + if (balance == 0) return; + + amount = clampBetween(amount, 1, balance); + address adapter = address(_activeAdapter()); + + vm.prank(actor); + settlementERC20.transfer(adapter, amount); + } + + function paymentAdapter_secondary(uint8 selector, uint256 arg, uint8 decimals) public { + selector = uint8(selector % 3); + if (selector == 0) _paymentAdapter_registerAsset(arg, decimals); + else if (selector == 1) _paymentAdapter_updateAsset(arg, decimals); + else _paymentAdapter_consumePaymentReference(arg); + } + + // ―――――――――――――――――――――――― Unclamped ――――――――――――――――――――――――― + + function paymentAdapter_settle( + address caller, + bytes32 _symbol, + uint64 _priceUSDCents, + uint256 _amountIn, + address _refundTo + ) public { + // Resolved before the prank, not inside the call: reading it is itself an external call, + // and a prank only survives to the next one. + PaymentAdapter adapter = _activeAdapter(); + + vm.prank(caller); + adapter.settle(_symbol, _priceUSDCents, _amountIn, _refundTo); + } + + /// @notice Adds a currency under a symbol nothing has claimed + /// @dev Decimals are drawn across the whole byte range rather than the legal window, so the + /// two bounds either side of it are reached rather than assumed. + function _paymentAdapter_registerAsset(uint256 symbolSeed, uint8 decimals) internal asOwner { + _activeAdapter().registerAsset( + _symbol(symbolSeed), + Asset({ + allowed: true, + isDollarUnit: symbolSeed % 2 == 0, + decimals: decimals, + token: symbolSeed % 3 == 0 ? settlementToken : address(0) + }) + ); + } + + /// @notice Rewrites a currency already registered + /// @dev The settlement currency keeps its decimals. Everything that funds a purchase works the + /// amount out from six, so moving them under the harness would make it fund the wrong + /// number and report the mismatch as a protocol defect. Its `allowed` flag still moves, + /// which is the part that changes what the protocol does. The scratch symbols take the + /// fuzzed decimals, so the bound either side of the legal window is still reached. + function _paymentAdapter_updateAsset(uint256 symbolSeed, uint8 decimals) internal asOwner { + if (symbolSeed % 4 == 0) { + _activeAdapter().updateAsset( + ASSET_USDC, + Asset({ + allowed: symbolSeed % 5 != 0, + isDollarUnit: true, + decimals: SETTLEMENT_DECIMALS, + token: settlementToken + }) + ); + return; + } + + _activeAdapter().updateAsset( + _symbol(symbolSeed), + Asset({ + allowed: symbolSeed % 5 != 0, + isDollarUnit: true, + decimals: decimals, + token: settlementToken + }) + ); + } + + /// @notice The adapter's own retired reference store + /// @dev No live purchase path reads it any more; the registry holds the one that counts. Called + /// so a property can show the two are genuinely independent rather than assumed to be. + function _paymentAdapter_consumePaymentReference(uint256 refSeed) internal { + PaymentAdapter adapter = _activeAdapter(); + + vm.prank(address(registry)); + adapter.consumePaymentReference(_paymentReference(refSeed)); + } + + // ――――――――――――――――――――――――― Helpers ―――――――――――――――――――――――――― + + /// @dev A small symbol space, so `registerAsset` and `updateAsset` contend over the same entries + /// instead of each call inventing a symbol neither guard has an opinion about. + function _symbol(uint256 seed) internal pure returns (bytes32) { + return bytes32(uint256(seed % 6) + 1); + } +} diff --git a/test/fizz/handlers/RegistryHandler.sol b/test/fizz/handlers/RegistryHandler.sol new file mode 100644 index 00000000..43ac887d --- /dev/null +++ b/test/fizz/handlers/RegistryHandler.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +import "../Base.sol"; +import {Properties} from "../Properties.sol"; + +/// @notice Handles the interaction with Registry +/// @dev This is the contract every other one reads its authority from, so the config handlers in +/// the secondary dispatcher are the highest-value calls in the campaign: each one changes what +/// every wallet is allowed to do, in the same block a purchase may be in flight. They swap +/// between valid targets rather than fuzzed addresses, because a registry pointing at nothing +/// would leave the rest of the run reverting instead of exploring. +abstract contract RegistryHandler is Properties { + + // ――――――――――――――――――――――――― Clamped ―――――――――――――――――――――――――― + + /// @notice The admin recording a purchase the protocol never saw the money for + function registry_recordSettledPurchase_clamped( + uint256 walletSeed, + uint256 bundleSeed, + uint64 priceUSDCents, + uint256 tokenAmount, + uint256 refSeed, + bool fiat + ) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + priceUSDCents = uint64(clampBetween(uint256(priceUSDCents), 1, registry.defaultPriceCapUSDCents())); + + DataBundleDetails memory detail = _bundle(bundleSeed, priceUSDCents); + detail.settlement = fiat ? Settlement.Fiat : Settlement.ExternalWallet; + + registry_recordSettledPurchase( + eSIMWallet, detail, ASSET_USDC, clampBetween(tokenAmount, 0, 1e12), _paymentReference(refSeed) + ); + } + + /// @notice A settlement the admin claims the protocol witnessed + /// @dev Only `buyDataBundleWithToken` may make that claim. This is the call that must be + /// refused, and no random draw over a three-value enum reaches it often enough. + function registry_recordSettledPurchase_assertsDeviceWallet(uint256 walletSeed, uint256 refSeed) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + DataBundleDetails memory detail = _bundle(refSeed, 100); + detail.settlement = Settlement.DeviceWallet; + + registry_recordSettledPurchase(eSIMWallet, detail, ASSET_USDC, 0, _paymentReference(refSeed)); + } + + /// @notice A record priced above whichever ceiling applies to the wallet + function registry_recordSettledPurchase_overCap(uint256 walletSeed, uint256 refSeed) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + uint256 cap = registry.defaultPriceCapUSDCents(); + if (cap >= type(uint64).max) return; + + registry_recordSettledPurchase( + eSIMWallet, _bundle(refSeed, uint64(cap + 1)), ASSET_USDC, 0, _paymentReference(refSeed) + ); + } + + /// @notice Names an eSIM wallet, drawing from the pool both routes contend over + function registry_assignESIMIdentifier_clamped(uint256 walletSeed, uint256 idSeed, bool contested) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + registry_assignESIMIdentifier( + eSIMWallet, contested ? _contestedESIMIdentifier(idSeed) : _ordinaryESIMIdentifier(idSeed) + ); + } + + /// @notice A device wallet binding an eSIM wallet that already names it as owner + function registry_bindESIMWallet_clamped(uint256 walletSeed) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + address owner = MockESIMWallet(payable(eSIMWallet)).owner(); + if (owner == address(0)) return; + + registry_bindESIMWallet(owner, eSIMWallet, owner); + } + + /// @notice A device wallet binding an eSIM wallet it does not own + /// @dev The authorization reads the eSIM wallet's live `owner()` rather than the registry's own + /// association, and this is the call that separates the two. + function registry_bindESIMWallet_asStranger(uint256 walletSeed, uint256 strangerSeed) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + address stranger = _pickDeviceWallet(strangerSeed); + if (eSIMWallet == address(0) || stranger == address(0)) return; + if (MockESIMWallet(payable(eSIMWallet)).owner() == stranger) return; + + registry_bindESIMWallet(stranger, eSIMWallet, stranger); + } + + function registry_toggleESIMWalletStandbyStatus_clamped(uint256 walletSeed, bool onStandby) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + address owner = MockESIMWallet(payable(eSIMWallet)).owner(); + if (owner == address(0)) return; + + registry_toggleESIMWalletStandbyStatus(owner, eSIMWallet, onStandby); + } + + /// @notice An eSIM wallet spending a payment reference directly + /// @dev Reachable from `buyDataBundleWithToken` in production. Called on its own here so the + /// one-way latch is exercised without a purchase having to succeed first. + function registry_consumePaymentReference_clamped(uint256 walletSeed, uint256 refSeed) public { + address eSIMWallet = _pickESIMWallet(walletSeed); + if (eSIMWallet == address(0)) return; + + registry_consumePaymentReference(eSIMWallet, _paymentReference(refSeed)); + } + + /// @notice The config surface, sequenced against whatever else is in flight + /// @dev Owner-key rotation is deliberately not here. `updateDeviceWalletOwnerKey` is one half of + /// a two-sided write: the real path is `DeviceWallet.transferOwnership`, which sets the + /// wallet's own key and then tells the registry in the same call. Reaching the registry + /// alone leaves the two copies naming different keys, which is a state no caller can + /// produce and which every key property would then report. The rotation is exercised + /// through `DeviceWalletHandler` instead. + function registry_secondary(uint8 selector, uint256 arg) public { + selector = uint8(selector % 10); + + if (selector == 0) _registry_pause(); + else if (selector == 1) _registry_unpause(); + else if (selector == 2) _registry_setDefaultPriceCapUSDCents(arg); + else if (selector == 3) _registry_setPaymentAdapter(arg); + else if (selector == 4) _registry_updateVaultAddress(arg); + else if (selector == 5) _registry_disableAdmin(); + else if (selector == 6) _registry_enableAdmin(); + else if (selector == 7) _registry_requestAdminUpdate(arg); + else if (selector == 8) _registry_acceptAdminUpdate(); + else _registry_addOrUpdateLazyWalletRegistryAddress(arg); + } + + // ―――――――――――――――――――――――― Unclamped ――――――――――――――――――――――――― + + function registry_recordSettledPurchase( + address _eSIMWallet, + DataBundleDetails memory _dataBundleDetail, + bytes32 _asset, + uint256 _tokenAmount, + bytes32 _paymentRef + ) public asAdmin { + bool wasPaused = registry.paused(); + uint256 capAtCallTime = _effectiveCap(_eSIMWallet); + + registry.recordSettledPurchase(_eSIMWallet, _dataBundleDetail, _asset, _tokenAmount, _paymentRef); + + if (wasPaused) ghosts.pausedCallSucceeded = true; + if (_dataBundleDetail.priceUSDCents > capAtCallTime) ghosts.purchaseAboveCap = true; + _recordSpentReference(_eSIMWallet, _paymentRef); + } + + function registry_assignESIMIdentifier(address _eSIMWallet, string memory _eSIMUniqueIdentifier) public asAdmin { + registry.assignESIMIdentifier(_eSIMWallet, _eSIMUniqueIdentifier); + } + + /// @dev The authorization here reads the eSIM wallet's live owner, so the check that the call + /// should have been refused has to read it too, before the call moves it. + function registry_bindESIMWallet(address caller, address _eSIMWallet, address _deviceWallet) public { + address ownerBefore = _safeESIMOwner(_eSIMWallet); + + vm.prank(caller); + registry.bindESIMWallet(_eSIMWallet, _deviceWallet); + + if (ownerBefore != caller || _deviceWallet != caller) ghosts.unauthorizedBind = true; + } + + function registry_toggleESIMWalletStandbyStatus(address caller, address _eSIMWallet, bool _onStandby) public { + address ownerBefore = _safeESIMOwner(_eSIMWallet); + address registrationBefore = registry.isESIMWalletValid(_eSIMWallet); + + vm.prank(caller); + registry.toggleESIMWalletStandbyStatus(_eSIMWallet, _onStandby); + + if (ownerBefore != caller) ghosts.unauthorizedStandby = true; + _prop_standbyLeavesTheRegistration(_eSIMWallet, registrationBefore); + } + + function registry_consumePaymentReference(address caller, bytes32 _paymentRef) public { + vm.prank(caller); + registry.consumePaymentReference(_paymentRef); + + _recordSpentReference(caller, _paymentRef); + } + + function _registry_pause() internal asAdmin { + registry.pause(); + } + + function _registry_unpause() internal asOwner { + registry.unpause(); + } + + /// @dev Never zero: zero reads as "no ceiling" everywhere a cap is consumed, and both setters + /// refuse it. Clamped low often enough that existing wallet caps and the default disagree. + function _registry_setDefaultPriceCapUSDCents(uint256 cap) internal asOwner { + registry.setDefaultPriceCapUSDCents(uint64(clampBetween(cap, 1, 1_000_000))); + } + + /// @dev The rotation the reference store was moved off the adapter to survive. A fresh adapter + /// starts with an empty table of its own, so if replay protection still lived there every + /// reference already spent would re-open here. + function _registry_setPaymentAdapter(uint256 which) internal asOwner { + registry.setPaymentAdapter(which % 2 == 0 ? address(paymentAdapter) : address(spareAdapter)); + } + + function _registry_updateVaultAddress(uint256 which) internal asOwner { + registry.updateVaultAddress(which % 2 == 0 ? vault : spareVault); + } + + function _registry_disableAdmin() internal asOwner { + registry.disableAdmin(); + } + + function _registry_enableAdmin() internal asOwner { + registry.enableAdmin(); + } + + function _registry_requestAdminUpdate(uint256 which) internal asOwner { + registry.requestAdminUpdate(which % 2 == 0 ? adminSuccessor : admin); + } + + /// @dev Called by whichever address the registry currently names, since no other caller passes. + function _registry_acceptAdminUpdate() internal { + address nominee = registry.newRequestedAdmin(); + if (nominee == address(0)) return; + + vm.prank(nominee); + registry.acceptAdminUpdate(); + } + + function _registry_addOrUpdateLazyWalletRegistryAddress(uint256) internal asOwner { + registry.addOrUpdateLazyWalletRegistryAddress(address(lazyWalletRegistry)); + } +} diff --git a/test/fizz/utils/Clamp.sol b/test/fizz/utils/Clamp.sol new file mode 100644 index 00000000..fadda9c5 --- /dev/null +++ b/test/fizz/utils/Clamp.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity >=0.6.2 <0.9.0; + +import {Logger} from "./Logger.sol"; +import {StringUtils} from "./StringUtils.sol"; + +/// @author Modified from Crytic (https://github.com/crytic/properties/blob/main/contracts/util/PropertiesAsserts.sol) +contract Clamp is StringUtils { + /// @notice Clamps value to be between low and high, both inclusive + function clampBetween( + uint256 value, + uint256 low, + uint256 high + ) internal returns (uint256) { + if (value < low || value > high) { + uint256 range = high - low; + uint256 ans = low + (value % (range + 1)); + // When range == type(uint256).max (low=0, high=max), range+1 overflows + // to 0 causing division by zero. In that case any value is already in + // range, so this branch is only reachable when range < type(uint256).max. + string memory valueStr = toString(value); + string memory ansStr = toString(ans); + bytes memory message = abi.encodePacked( + "Clamping value ", + valueStr, + " to ", + ansStr + ); + Logger.logString(string(message)); + return ans; + } + return value; + } + + /// @notice int256 version of clampBetween + function clampBetween( + int256 value, + int256 low, + int256 high + ) internal returns (int256) { + if (value < low || value > high) { + int range = high - low + 1; + int clamped = (value - low) % (range); + if (clamped < 0) clamped += range; + int ans = low + clamped; + string memory valueStr = toString(value); + string memory ansStr = toString(ans); + bytes memory message = abi.encodePacked( + "Clamping value ", + valueStr, + " to ", + ansStr + ); + Logger.logString(string(message)); + return ans; + } + return value; + } + + /// @notice clamps a to be less than b + function clampLt(uint256 a, uint256 b) internal returns (uint256) { + if (!(a < b)) { + if(b == 0) { + Logger.logString("clampLt cannot clamp value a to be less than zero. Check your inputs/assumptions."); + assert(false); + } + uint256 value = a % b; + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } + return a; + } + + /// @notice int256 version of clampLt + function clampLt(int256 a, int256 b) internal returns (int256) { + if (!(a < b)) { + if (b == type(int256).min) { + Logger.logString("clampLt cannot clamp value a to be less than int256.min. Check your inputs/assumptions."); + assert(false); + } + int256 value = b - 1; + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } + return a; + } + + /// @notice clamps a to be less than or equal to b + function clampLte(uint256 a, uint256 b) internal returns (uint256) { + if (!(a <= b)) { + // When b == type(uint256).max, a <= b is always true so this + // branch is unreachable. Safe to use b + 1 without overflow. + uint256 value = a % (b + 1); + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } + return a; + } + + /// @notice int256 version of clampLte + function clampLte(int256 a, int256 b) internal returns (int256) { + if (!(a <= b)) { + int256 value = b; + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } + return a; + } + + /// @notice clamps a to be greater than b + function clampGt(uint256 a, uint256 b) internal returns (uint256) { + if (!(a > b)) { + if (b == type(uint256).max) { + Logger.logString("clampGt cannot clamp value a to be larger than uint256.max. Check your inputs/assumptions."); + assert(false); + } + uint256 value = b + 1; + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } else { + return a; + } + } + + /// @notice int256 version of clampGt + function clampGt(int256 a, int256 b) internal returns (int256) { + if (!(a > b)) { + if (b == type(int256).max) { + Logger.logString("clampGt cannot clamp value a to be larger than int256.max. Check your inputs/assumptions."); + assert(false); + } + int256 value = b + 1; + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } else { + return a; + } + } + + /// @notice clamps a to be greater than or equal to b + function clampGte(uint256 a, uint256 b) internal returns (uint256) { + if (!(a >= b)) { + uint256 value = b; + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } + return a; + } + + /// @notice int256 version of clampGte + function clampGte(int256 a, int256 b) internal returns (int256) { + if (!(a >= b)) { + int256 value = b; + string memory aStr = toString(a); + string memory valueStr = toString(value); + bytes memory message = abi.encodePacked( + "Clamping value ", + aStr, + " to ", + valueStr + ); + Logger.logString(string(message)); + return value; + } + return a; + } +} diff --git a/test/fizz/utils/DecimalPrinter.sol b/test/fizz/utils/DecimalPrinter.sol new file mode 100644 index 00000000..3c91e0c6 --- /dev/null +++ b/test/fizz/utils/DecimalPrinter.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.6.2 <0.9.0; + +library DecimalPrinter { + function toDec(uint256 _value) public pure returns (string memory) { + return toDec(_value, 18); + } + + function toDec(int256 _value) public pure returns (string memory) { + return toDec(_value, 18); + } + + function toDec(uint256 _value, uint256 decimals) public pure returns (string memory) { + uint256 base = 10**decimals; + uint256 integerPart = _value / base; + uint256 fractionalPart = _value % base; + + string memory integerPartStr = uintToStr(integerPart); + string memory fractionalPartStr = uintToStr(fractionalPart); + + // Pad fractional part with leading zeros if necessary + uint256 numZeros = decimals - bytes(fractionalPartStr).length; + for (uint256 i = 0; i < numZeros; i++) { + fractionalPartStr = string(abi.encodePacked("0", fractionalPartStr)); + } + + string memory result = string(abi.encodePacked(integerPartStr, ".", fractionalPartStr)); + + return result; + } + + function toDec(int256 _value, uint256 decimals) public pure returns (string memory) { + if (_value < 0) { + return string(abi.encodePacked("-", toDec(uint256(-_value), decimals))); + } else { + return toDec(uint256(_value), decimals); + } + } + + function uintToStr(uint256 _value) private pure returns (string memory) { + if (_value == 0) { + return "0"; + } + + uint256 temp = _value; + uint256 digits; + + while (temp != 0) { + digits++; + temp /= 10; + } + + bytes memory buffer = new bytes(digits); + + while (_value != 0) { + digits--; + buffer[digits] = bytes1(uint8(48 + _value % 10)); + _value /= 10; + } + + return string(buffer); + } +} diff --git a/test/fizz/utils/Deployer.sol b/test/fizz/utils/Deployer.sol new file mode 100644 index 00000000..6dd791ad --- /dev/null +++ b/test/fizz/utils/Deployer.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +// Source: https://solidity-by-example.org/app/deploy-any-contract/ + +contract Deployer { + function deploy(bytes memory _code) internal returns (address addr) { + return deploy(_code, 0); + } + + function deploy(bytes memory _code, uint256 _value) internal returns (address addr) { + assembly { + addr := create(_value, add(_code, 0x20), mload(_code)) + } + require(addr != address(0), "Deployer.sol: deploy failed"); + } +} diff --git a/test/fizz/utils/EnumerableSet.sol b/test/fizz/utils/EnumerableSet.sol new file mode 100644 index 00000000..183d114f --- /dev/null +++ b/test/fizz/utils/EnumerableSet.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (utils/structs/EnumerableSet.sol) +pragma solidity >=0.6.2 <0.9.0; + +/** + * @dev Library for managing + * https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets] of primitive + * types. + * + * Sets have the following properties: + * + * - Elements are added, removed, and checked for existence in constant time + * (O(1)). + * - Elements are enumerated in O(n). No guarantees are made on the ordering. + * + * ```solidity + * contract Example { + * // Add the library methods + * using EnumerableSet for EnumerableSet.AddressSet; + * + * // Declare a set state variable + * EnumerableSet.AddressSet private mySet; + * } + * ``` + * + * As of v3.3.0, sets of type `bytes32` (`Bytes32Set`), `address` (`AddressSet`) + * and `uint256` (`UintSet`) are supported. + * + * [WARNING] + * ==== + * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure + * unusable. + * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info. + * + * In order to clean an EnumerableSet, you can either remove all elements one by one or create a fresh instance using an + * array of EnumerableSet. + * ==== + */ +library EnumerableSet { + struct Set { + bytes32[] _values; + mapping(bytes32 value => uint256) _positions; + } + + function _add(Set storage set, bytes32 value) private returns (bool) { + if (!_contains(set, value)) { + set._values.push(value); + set._positions[value] = set._values.length; + return true; + } else { + return false; + } + } + + function _remove(Set storage set, bytes32 value) private returns (bool) { + uint256 position = set._positions[value]; + + if (position != 0) { + uint256 valueIndex = position - 1; + uint256 lastIndex = set._values.length - 1; + + if (valueIndex != lastIndex) { + bytes32 lastValue = set._values[lastIndex]; + set._values[valueIndex] = lastValue; + set._positions[lastValue] = position; + } + + set._values.pop(); + delete set._positions[value]; + + return true; + } else { + return false; + } + } + + function _contains(Set storage set, bytes32 value) private view returns (bool) { + return set._positions[value] != 0; + } + + function _length(Set storage set) private view returns (uint256) { + return set._values.length; + } + + function _at(Set storage set, uint256 index) private view returns (bytes32) { + return set._values[index]; + } + + function _values(Set storage set) private view returns (bytes32[] memory) { + return set._values; + } + + // Bytes32Set + + struct Bytes32Set { + Set _inner; + } + + function add(Bytes32Set storage set, bytes32 value) internal returns (bool) { + return _add(set._inner, value); + } + + function remove(Bytes32Set storage set, bytes32 value) internal returns (bool) { + return _remove(set._inner, value); + } + + function contains(Bytes32Set storage set, bytes32 value) internal view returns (bool) { + return _contains(set._inner, value); + } + + function length(Bytes32Set storage set) internal view returns (uint256) { + return _length(set._inner); + } + + function at(Bytes32Set storage set, uint256 index) internal view returns (bytes32) { + return _at(set._inner, index); + } + + function values(Bytes32Set storage set) internal view returns (bytes32[] memory) { + bytes32[] memory store = _values(set._inner); + bytes32[] memory result; + + /// @solidity memory-safe-assembly + assembly { + result := store + } + + return result; + } + + // AddressSet + + struct AddressSet { + Set _inner; + } + + function add(AddressSet storage set, address value) internal returns (bool) { + return _add(set._inner, bytes32(uint256(uint160(value)))); + } + + function remove(AddressSet storage set, address value) internal returns (bool) { + return _remove(set._inner, bytes32(uint256(uint160(value)))); + } + + function contains(AddressSet storage set, address value) internal view returns (bool) { + return _contains(set._inner, bytes32(uint256(uint160(value)))); + } + + function length(AddressSet storage set) internal view returns (uint256) { + return _length(set._inner); + } + + function at(AddressSet storage set, uint256 index) internal view returns (address) { + return address(uint160(uint256(_at(set._inner, index)))); + } + + function values(AddressSet storage set) internal view returns (address[] memory) { + bytes32[] memory store = _values(set._inner); + address[] memory result; + + /// @solidity memory-safe-assembly + assembly { + result := store + } + + return result; + } + + // UintSet + + struct UintSet { + Set _inner; + } + + function add(UintSet storage set, uint256 value) internal returns (bool) { + return _add(set._inner, bytes32(value)); + } + + function remove(UintSet storage set, uint256 value) internal returns (bool) { + return _remove(set._inner, bytes32(value)); + } + + function contains(UintSet storage set, uint256 value) internal view returns (bool) { + return _contains(set._inner, bytes32(value)); + } + + function length(UintSet storage set) internal view returns (uint256) { + return _length(set._inner); + } + + function at(UintSet storage set, uint256 index) internal view returns (uint256) { + return uint256(_at(set._inner, index)); + } + + function values(UintSet storage set) internal view returns (uint256[] memory) { + bytes32[] memory store = _values(set._inner); + uint256[] memory result; + + /// @solidity memory-safe-assembly + assembly { + result := store + } + + return result; + } +} diff --git a/test/fizz/utils/Hevm.sol b/test/fizz/utils/Hevm.sol new file mode 100644 index 00000000..544ea05b --- /dev/null +++ b/test/fizz/utils/Hevm.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity >=0.6.2 <0.9.0; + +// source: https://github.com/crytic/properties/blob/main/contracts/util/IHevm.sol + +interface IHevm { + // Set block.timestamp to newTimestamp + function warp(uint256 newTimestamp) external; + + // Set block.number to newNumber + function roll(uint256 newNumber) external; + + // Add the condition b to the assumption base for the current branch + // This function is almost identical to require + function assume(bool b) external; + + // Sets the eth balance of usr to amt + function deal(address usr, uint256 amt) external; + + // Loads a storage slot from an address + function load(address where, bytes32 slot) external returns (bytes32); + + // Stores a value to an address' storage slot + function store(address where, bytes32 slot, bytes32 value) external; + + // Signs data (privateKey, digest) => (v, r, s) + function sign( + uint256 privateKey, + bytes32 digest + ) external returns (uint8 v, bytes32 r, bytes32 s); + + // Gets address for a given private key + function addr(uint256 privateKey) external returns (address addr); + + // Performs a foreign function call via terminal + function ffi( + string[] calldata inputs + ) external returns (bytes memory result); + + // Performs the next smart contract call with specified msg.sender + function prank(address newSender) external; + + // Creates a new fork with the given endpoint and the latest block and returns the identifier of the fork + function createFork(string calldata urlOrAlias) external returns (uint256); + + // Takes a fork identifier created by createFork and sets the corresponding forked state as active + function selectFork(uint256 forkId) external; + + // Returns the identifier of the current fork + function activeFork() external returns (uint256); + + // Labels the address in traces + function label(address addr, string calldata label) external; + + // Sets msg.sender to the specified sender until stopPrank() is called + function startPrank(address sender) external; + + // Resets msg.sender to the default sender + function stopPrank() external; +} + +IHevm constant vm = IHevm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); diff --git a/test/fizz/utils/Logger.sol b/test/fizz/utils/Logger.sol new file mode 100644 index 00000000..20d068dd --- /dev/null +++ b/test/fizz/utils/Logger.sol @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +library Logger { + event Log(); + function log() internal { + emit Log(); + } + + event LogInt(int p0); + function logInt(int p0) internal { + emit LogInt(p0); + } + + event LogUint(uint p0); + function logUint(uint p0) internal { + emit LogUint(p0); + } + + event LogString(string p0); + function logString(string memory p0) internal { + emit LogString(p0); + } + + event LogBool(bool p0); + function logBool(bool p0) internal { + emit LogBool(p0); + } + + event LogAddress(address p0); + function logAddress(address p0) internal { + emit LogAddress(p0); + } + + event LogBytes(bytes p0); + function logBytes(bytes memory p0) internal { + emit LogBytes(p0); + } + + event LogBytes1(bytes1 p0); + function logBytes1(bytes1 p0) internal { + emit LogBytes1(p0); + } + + event LogBytes2(bytes2 p0); + function logBytes2(bytes2 p0) internal { + emit LogBytes2(p0); + } + + event LogBytes3(bytes3 p0); + function logBytes3(bytes3 p0) internal { + emit LogBytes3(p0); + } + + event LogBytes4(bytes4 p0); + function logBytes4(bytes4 p0) internal { + emit LogBytes4(p0); + } + + event LogBytes5(bytes5 p0); + function logBytes5(bytes5 p0) internal { + emit LogBytes5(p0); + } + + event LogBytes6(bytes6 p0); + function logBytes6(bytes6 p0) internal { + emit LogBytes6(p0); + } + + event LogBytes7(bytes7 p0); + function logBytes7(bytes7 p0) internal { + emit LogBytes7(p0); + } + + event LogBytes8(bytes8 p0); + function logBytes8(bytes8 p0) internal { + emit LogBytes8(p0); + } + + event LogBytes9(bytes9 p0); + function logBytes9(bytes9 p0) internal { + emit LogBytes9(p0); + } + + event LogBytes10(bytes10 p0); + function logBytes10(bytes10 p0) internal { + emit LogBytes10(p0); + } + + event LogBytes11(bytes11 p0); + function logBytes11(bytes11 p0) internal { + emit LogBytes11(p0); + } + + event LogBytes12(bytes12 p0); + function logBytes12(bytes12 p0) internal { + emit LogBytes12(p0); + } + + event LogBytes13(bytes13 p0); + function logBytes13(bytes13 p0) internal { + emit LogBytes13(p0); + } + + event LogBytes14(bytes14 p0); + function logBytes14(bytes14 p0) internal { + emit LogBytes14(p0); + } + + event LogBytes15(bytes15 p0); + function logBytes15(bytes15 p0) internal { + emit LogBytes15(p0); + } + + event LogBytes16(bytes16 p0); + function logBytes16(bytes16 p0) internal { + emit LogBytes16(p0); + } + + event LogBytes17(bytes17 p0); + function logBytes17(bytes17 p0) internal { + emit LogBytes17(p0); + } + + event LogBytes18(bytes18 p0); + function logBytes18(bytes18 p0) internal { + emit LogBytes18(p0); + } + + event LogBytes19(bytes19 p0); + function logBytes19(bytes19 p0) internal { + emit LogBytes19(p0); + } + + event LogBytes20(bytes20 p0); + function logBytes20(bytes20 p0) internal { + emit LogBytes20(p0); + } + + event LogBytes21(bytes21 p0); + function logBytes21(bytes21 p0) internal { + emit LogBytes21(p0); + } + + event LogBytes22(bytes22 p0); + function logBytes22(bytes22 p0) internal { + emit LogBytes22(p0); + } + + event LogBytes23(bytes23 p0); + function logBytes23(bytes23 p0) internal { + emit LogBytes23(p0); + } + + event LogBytes24(bytes24 p0); + function logBytes24(bytes24 p0) internal { + emit LogBytes24(p0); + } + + event LogBytes25(bytes25 p0); + function logBytes25(bytes25 p0) internal { + emit LogBytes25(p0); + } + + event LogBytes26(bytes26 p0); + function logBytes26(bytes26 p0) internal { + emit LogBytes26(p0); + } + + event LogBytes27(bytes27 p0); + function logBytes27(bytes27 p0) internal { + emit LogBytes27(p0); + } + + event LogBytes28(bytes28 p0); + function logBytes28(bytes28 p0) internal { + emit LogBytes28(p0); + } + + event LogBytes29(bytes29 p0); + function logBytes29(bytes29 p0) internal { + emit LogBytes29(p0); + } + + event LogBytes30(bytes30 p0); + function logBytes30(bytes30 p0) internal { + emit LogBytes30(p0); + } + + event LogBytes31(bytes31 p0); + function logBytes31(bytes31 p0) internal { + emit LogBytes31(p0); + } + + event LogBytes32(bytes32 p0); + function logBytes32(bytes32 p0) internal { + emit LogBytes32(p0); + } + + event Log(uint p0); + function log(uint p0) internal { + emit Log(p0); + } + + event Log(string p0); + function log(string memory p0) internal { + emit Log(p0); + } + + event Log(bool p0); + function log(bool p0) internal { + emit Log(p0); + } + + event Log(address p0); + function log(address p0) internal { + emit Log(p0); + } + + event Log(uint p0, uint p1); + function log(uint p0, uint p1) internal { + emit Log(p0, p1); + } + + event Log(uint p0, string p1); + function log(uint p0, string memory p1) internal { + emit Log(p0, p1); + } + + event Log(uint p0, bool p1); + function log(uint p0, bool p1) internal { + emit Log(p0, p1); + } + + event Log(uint p0, address p1); + function log(uint p0, address p1) internal { + emit Log(p0, p1); + } + + event Log(string p0, uint p1); + function log(string memory p0, uint p1) internal { + emit Log(p0, p1); + } + + event Log(string p0, string p1); + function log(string memory p0, string memory p1) internal { + emit Log(p0, p1); + } + + event Log(string p0, bool p1); + function log(string memory p0, bool p1) internal { + emit Log(p0, p1); + } + + event Log(string p0, address p1); + function log(string memory p0, address p1) internal { + emit Log(p0, p1); + } + + event Log(bool p0, uint p1); + function log(bool p0, uint p1) internal { + emit Log(p0, p1); + } + + event Log(bool p0, string p1); + function log(bool p0, string memory p1) internal { + emit Log(p0, p1); + } + + event Log(bool p0, bool p1); + function log(bool p0, bool p1) internal { + emit Log(p0, p1); + } + + event Log(bool p0, address p1); + function log(bool p0, address p1) internal { + emit Log(p0, p1); + } + + event Log(address p0, uint p1); + function log(address p0, uint p1) internal { + emit Log(p0, p1); + } + + event Log(address p0, string p1); + function log(address p0, string memory p1) internal { + emit Log(p0, p1); + } + + event Log(address p0, bool p1); + function log(address p0, bool p1) internal { + emit Log(p0, p1); + } + + event Log(address p0, address p1); + function log(address p0, address p1) internal { + emit Log(p0, p1); + } + + event Log(uint p0, uint p1, uint p2); + function log(uint p0, uint p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, uint p1, string p2); + function log(uint p0, uint p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, uint p1, bool p2); + function log(uint p0, uint p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, uint p1, address p2); + function log(uint p0, uint p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, string p1, uint p2); + function log(uint p0, string memory p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, string p1, string p2); + function log(uint p0, string memory p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, string p1, bool p2); + function log(uint p0, string memory p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, string p1, address p2); + function log(uint p0, string memory p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, bool p1, uint p2); + function log(uint p0, bool p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, bool p1, string p2); + function log(uint p0, bool p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, bool p1, bool p2); + function log(uint p0, bool p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, bool p1, address p2); + function log(uint p0, bool p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, address p1, uint p2); + function log(uint p0, address p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, address p1, string p2); + function log(uint p0, address p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, address p1, bool p2); + function log(uint p0, address p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(uint p0, address p1, address p2); + function log(uint p0, address p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, uint p1, uint p2); + function log(string memory p0, uint p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, uint p1, string p2); + function log(string memory p0, uint p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, uint p1, bool p2); + function log(string memory p0, uint p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, uint p1, address p2); + function log(string memory p0, uint p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, string p1, uint p2); + function log(string memory p0, string memory p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, string p1, string p2); + function log(string memory p0, string memory p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, string p1, bool p2); + function log(string memory p0, string memory p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, string p1, address p2); + function log(string memory p0, string memory p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, bool p1, uint p2); + function log(string memory p0, bool p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, bool p1, string p2); + function log(string memory p0, bool p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, bool p1, bool p2); + function log(string memory p0, bool p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, bool p1, address p2); + function log(string memory p0, bool p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, address p1, uint p2); + function log(string memory p0, address p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, address p1, string p2); + function log(string memory p0, address p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, address p1, bool p2); + function log(string memory p0, address p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(string p0, address p1, address p2); + function log(string memory p0, address p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, uint p1, uint p2); + function log(bool p0, uint p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, uint p1, string p2); + function log(bool p0, uint p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, uint p1, bool p2); + function log(bool p0, uint p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, uint p1, address p2); + function log(bool p0, uint p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, string p1, uint p2); + function log(bool p0, string memory p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, string p1, string p2); + function log(bool p0, string memory p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, string p1, bool p2); + function log(bool p0, string memory p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, string p1, address p2); + function log(bool p0, string memory p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, bool p1, uint p2); + function log(bool p0, bool p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, bool p1, string p2); + function log(bool p0, bool p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, bool p1, bool p2); + function log(bool p0, bool p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, bool p1, address p2); + function log(bool p0, bool p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, address p1, uint p2); + function log(bool p0, address p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, address p1, string p2); + function log(bool p0, address p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, address p1, bool p2); + function log(bool p0, address p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(bool p0, address p1, address p2); + function log(bool p0, address p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, uint p1, uint p2); + function log(address p0, uint p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, uint p1, string p2); + function log(address p0, uint p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, uint p1, bool p2); + function log(address p0, uint p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, uint p1, address p2); + function log(address p0, uint p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, string p1, uint p2); + function log(address p0, string memory p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, string p1, string p2); + function log(address p0, string memory p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, string p1, bool p2); + function log(address p0, string memory p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, string p1, address p2); + function log(address p0, string memory p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, bool p1, uint p2); + function log(address p0, bool p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, bool p1, string p2); + function log(address p0, bool p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, bool p1, bool p2); + function log(address p0, bool p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, bool p1, address p2); + function log(address p0, bool p1, address p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, address p1, uint p2); + function log(address p0, address p1, uint p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, address p1, string p2); + function log(address p0, address p1, string memory p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, address p1, bool p2); + function log(address p0, address p1, bool p2) internal { + emit Log(p0, p1, p2); + } + + event Log(address p0, address p1, address p2); + function log(address p0, address p1, address p2) internal { + emit Log(p0, p1, p2); + } +} diff --git a/test/fizz/utils/Math.sol b/test/fizz/utils/Math.sol new file mode 100644 index 00000000..8424ef23 --- /dev/null +++ b/test/fizz/utils/Math.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +/// @author Modified from Forge standard lib (https://github.com/foundry-rs/forge-std/blob/master/src/StdMath.sol) +contract Math { + int256 private constant INT256_MIN = -57896044618658097711785492504343953926634992332820282019728792003956564819968; + + function abs(int256 a) internal pure returns (uint256) { + // Required or it will fail when `a = type(int256).min` + if (a == INT256_MIN) { + return 57896044618658097711785492504343953926634992332820282019728792003956564819968; + } + + return uint256(a > 0 ? a : -a); + } + + function delta(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a - b : b - a; + } + + function delta(int256 a, int256 b) internal pure returns (uint256) { + // a and b are of the same sign + // this works thanks to two's complement, the left-most bit is the sign bit + if ((a ^ b) > -1) { + return delta(abs(a), abs(b)); + } + + // a and b are of opposite signs + return abs(a) + abs(b); + } + + function percentDelta(uint256 a, uint256 b) internal pure returns (uint256) { + uint256 absDelta = delta(a, b); + + return absDelta * 1e18 / b; + } + + function percentDelta(int256 a, int256 b) internal pure returns (uint256) { + uint256 absDelta = delta(a, b); + uint256 absB = abs(b); + + return absDelta * 1e18 / absB; + } + + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } + + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } +} diff --git a/test/fizz/utils/MockERC20.sol b/test/fizz/utils/MockERC20.sol new file mode 100644 index 00000000..dea364d8 --- /dev/null +++ b/test/fizz/utils/MockERC20.sol @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.2 <0.9.0; + +/// @dev Interface of the ERC20 standard as defined in the EIP. +/// @dev This includes the optional name, symbol, and decimals metadata. +interface IERC20 { + /// @dev Emitted when `value` tokens are moved from one account (`from`) to another (`to`). + event Transfer(address indexed from, address indexed to, uint256 value); + + /// @dev Emitted when the allowance of a `spender` for an `owner` is set, where `value` + /// is the new allowance. + event Approval(address indexed owner, address indexed spender, uint256 value); + + /// @notice Returns the amount of tokens in existence. + function totalSupply() external view returns (uint256); + + /// @notice Returns the amount of tokens owned by `account`. + function balanceOf(address account) external view returns (uint256); + + /// @notice Moves `amount` tokens from the caller's account to `to`. + function transfer(address to, uint256 amount) external returns (bool); + + /// @notice Returns the remaining number of tokens that `spender` is allowed + /// to spend on behalf of `owner` + function allowance(address owner, address spender) external view returns (uint256); + + /// @notice Sets `amount` as the allowance of `spender` over the caller's tokens. + /// @dev Be aware of front-running risks: https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + function approve(address spender, uint256 amount) external returns (bool); + + /// @notice Moves `amount` tokens from `from` to `to` using the allowance mechanism. + /// `amount` is then deducted from the caller's allowance. + function transferFrom(address from, address to, uint256 amount) external returns (bool); + + /// @notice Returns the name of the token. + function name() external view returns (string memory); + + /// @notice Returns the symbol of the token. + function symbol() external view returns (string memory); + + /// @notice Returns the decimals places of the token. + function decimals() external view returns (uint8); +} + +/// @notice This is a mock contract of the ERC20 standard for testing purposes only, it SHOULD NOT be used in production. +/// @notice Includes open deal function to mint tokens to any address. +/// @dev Forked from: https://github.com/transmissions11/solmate/blob/0384dbaaa4fcb5715738a9254a7c0a4cb62cf458/src/tokens/ERC20.sol +contract MockERC20 is IERC20 { + /*////////////////////////////////////////////////////////////// + METADATA STORAGE + //////////////////////////////////////////////////////////////*/ + + string internal _name; + + string internal _symbol; + + uint8 internal _decimals; + + function name() external view override returns (string memory) { + return _name; + } + + function symbol() external view override returns (string memory) { + return _symbol; + } + + function decimals() external view override returns (uint8) { + return _decimals; + } + + /*////////////////////////////////////////////////////////////// + ERC20 STORAGE + //////////////////////////////////////////////////////////////*/ + + uint256 internal _totalSupply; + + mapping(address => uint256) internal _balanceOf; + + mapping(address => mapping(address => uint256)) internal _allowance; + + function totalSupply() external view override returns (uint256) { + return _totalSupply; + } + + function balanceOf(address owner) external view override returns (uint256) { + return _balanceOf[owner]; + } + + function allowance(address owner, address spender) external view override returns (uint256) { + return _allowance[owner][spender]; + } + + /*////////////////////////////////////////////////////////////// + EIP-2612 STORAGE + //////////////////////////////////////////////////////////////*/ + + uint256 internal INITIAL_CHAIN_ID; + + bytes32 internal INITIAL_DOMAIN_SEPARATOR; + + mapping(address => uint256) public nonces; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + constructor(address recipient, uint256 mintAmount, string memory name, string memory symbol, uint8 decimals) { + _name = name; + _symbol = symbol; + _decimals = decimals; + + INITIAL_CHAIN_ID = _pureChainId(); + INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator(); + + _mint(recipient, mintAmount * 10 ** decimals); + } + + /*////////////////////////////////////////////////////////////// + ERC20 LOGIC + //////////////////////////////////////////////////////////////*/ + + function approve(address spender, uint256 amount) public virtual override returns (bool) { + _allowance[msg.sender][spender] = amount; + + emit Approval(msg.sender, spender, amount); + + return true; + } + + function transfer(address to, uint256 amount) public virtual override returns (bool) { + _balanceOf[msg.sender] = _sub(_balanceOf[msg.sender], amount); + _balanceOf[to] = _add(_balanceOf[to], amount); + + emit Transfer(msg.sender, to, amount); + + return true; + } + + function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) { + uint256 allowed = _allowance[from][msg.sender]; // Saves gas for limited approvals. + + if (allowed != ~uint256(0)) _allowance[from][msg.sender] = _sub(allowed, amount); + + _balanceOf[from] = _sub(_balanceOf[from], amount); + _balanceOf[to] = _add(_balanceOf[to], amount); + + emit Transfer(from, to, amount); + + return true; + } + + /*////////////////////////////////////////////////////////////// + DEAL LOGIC + //////////////////////////////////////////////////////////////*/ + + function deal(uint256 amount) public virtual { + _mint(msg.sender, amount); + } + + function deal(address to, uint256 amount) public virtual { + _mint(to, amount); + } + + /*////////////////////////////////////////////////////////////// + EIP-2612 LOGIC + //////////////////////////////////////////////////////////////*/ + + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + public + virtual + { + require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED"); + + address recoveredAddress = ecrecover( + keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ), + owner, + spender, + value, + nonces[owner]++, + deadline + ) + ) + ) + ), + v, + r, + s + ); + + require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER"); + + _allowance[recoveredAddress][spender] = value; + + emit Approval(owner, spender, value); + } + + function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + return _pureChainId() == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator(); + } + + function computeDomainSeparator() internal view virtual returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(_name)), + keccak256("1"), + _pureChainId(), + address(this) + ) + ); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL MINT/BURN LOGIC + //////////////////////////////////////////////////////////////*/ + + function _mint(address to, uint256 amount) internal virtual { + _totalSupply = _add(_totalSupply, amount); + _balanceOf[to] = _add(_balanceOf[to], amount); + + emit Transfer(address(0), to, amount); + } + + function _burn(address from, uint256 amount) internal virtual { + _balanceOf[from] = _sub(_balanceOf[from], amount); + _totalSupply = _sub(_totalSupply, amount); + + emit Transfer(from, address(0), amount); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL SAFE MATH LOGIC + //////////////////////////////////////////////////////////////*/ + + function _add(uint256 a, uint256 b) internal pure returns (uint256) { + uint256 c = a + b; + require(c >= a, "ERC20: addition overflow"); + return c; + } + + function _sub(uint256 a, uint256 b) internal pure returns (uint256) { + require(a >= b, "ERC20: subtraction underflow"); + return a - b; + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + // We use this complex approach of `_viewChainId` and `_pureChainId` to ensure there are no + // compiler warnings when accessing chain ID in any solidity version supported by forge-std. We + // can't simply access the chain ID in a normal view or pure function because the solc View Pure + // Checker changed `chainid` from pure to view in 0.8.0. + function _viewChainId() private view returns (uint256 chainId) { + // Assembly required since `block.chainid` was introduced in 0.8.0. + assembly { + chainId := chainid() + } + + address(this); // Silence warnings in older Solc versions. + } + + function _pureChainId() private pure returns (uint256 chainId) { + function() internal view returns (uint256) fnIn = _viewChainId; + function() internal pure returns (uint256) pureChainId; + assembly { + pureChainId := fnIn + } + chainId = pureChainId(); + } +} diff --git a/test/fizz/utils/PropertiesAsserts.sol b/test/fizz/utils/PropertiesAsserts.sol new file mode 100644 index 00000000..fa17c620 --- /dev/null +++ b/test/fizz/utils/PropertiesAsserts.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity >=0.6.2 <0.9.0; + +import {StringUtils} from "./StringUtils.sol"; + +/// @author Modified from Crytic (https://github.com/crytic/properties/blob/main/contracts/util/PropertiesAsserts.sol) +contract PropertiesAsserts is StringUtils { + event AssertFail(string); + event AssertEqFail(string); + event AssertNeqFail(string); + event AssertGteFail(string); + event AssertGtFail(string); + event AssertLteFail(string); + event AssertLtFail(string); + + function t(bool b, string memory reason) internal { + if (!b) { + emit AssertFail(reason); + assert(false); + } + } + + /// @notice asserts that a is equal to b. Violations are logged using reason. + function eq(uint256 a, uint256 b, string memory reason) internal { + if (a != b) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "!=", + bStr, + ", reason: ", + reason + ); + emit AssertEqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of eq + function eq(int256 a, int256 b, string memory reason) internal { + if (a != b) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "!=", + bStr, + ", reason: ", + reason + ); + emit AssertEqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is not equal to b. Violations are logged using reason. + function neq(uint256 a, uint256 b, string memory reason) internal { + if (a == b) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "==", + bStr, + ", reason: ", + reason + ); + emit AssertNeqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of neq + function neq(int256 a, int256 b, string memory reason) internal { + if (a == b) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "==", + bStr, + ", reason: ", + reason + ); + emit AssertNeqFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is greater than or equal to b. Violations are logged using reason. + function gte(uint256 a, uint256 b, string memory reason) internal { + if (!(a >= b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "<", + bStr, + " failed, reason: ", + reason + ); + emit AssertGteFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of gte + function gte(int256 a, int256 b, string memory reason) internal { + if (!(a >= b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "<", + bStr, + " failed, reason: ", + reason + ); + emit AssertGteFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is greater than b. Violations are logged using reason. + function gt(uint256 a, uint256 b, string memory reason) internal { + if (!(a > b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "<=", + bStr, + " failed, reason: ", + reason + ); + emit AssertGtFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of gt + function gt(int256 a, int256 b, string memory reason) internal { + if (!(a > b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + "<=", + bStr, + " failed, reason: ", + reason + ); + emit AssertGtFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is less than or equal to b. Violations are logged using reason. + function lte(uint256 a, uint256 b, string memory reason) internal { + if (!(a <= b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + ">", + bStr, + " failed, reason: ", + reason + ); + emit AssertLteFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of lte + function lte(int256 a, int256 b, string memory reason) internal { + if (!(a <= b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + ">", + bStr, + " failed, reason: ", + reason + ); + emit AssertLteFail(string(assertMsg)); + assert(false); + } + } + + /// @notice asserts that a is less than b. Violations are logged using reason. + function lt(uint256 a, uint256 b, string memory reason) internal { + if (!(a < b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + ">=", + bStr, + " failed, reason: ", + reason + ); + emit AssertLtFail(string(assertMsg)); + assert(false); + } + } + + /// @notice int256 version of lt + function lt(int256 a, int256 b, string memory reason) internal { + if (!(a < b)) { + string memory aStr = toString(a); + string memory bStr = toString(b); + bytes memory assertMsg = abi.encodePacked( + "Invalid: ", + aStr, + ">=", + bStr, + " failed, reason: ", + reason + ); + emit AssertLtFail(string(assertMsg)); + assert(false); + } + } +} diff --git a/test/fizz/utils/StringUtils.sol b/test/fizz/utils/StringUtils.sol new file mode 100644 index 00000000..a18c5522 --- /dev/null +++ b/test/fizz/utils/StringUtils.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity >=0.6.2 <0.9.0; + +/// @notice Efficient library for creating string representations of integers. +/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/LibString.sol) +/// @author Modified from Solady (https://github.com/Vectorized/solady/blob/main/src/utils/LibString.sol) +/// @dev Name of the library is modified to prevent collisions with contract-under-test uses of LibString +contract StringUtils { + function toString(int256 value) internal pure returns (string memory str) { + uint256 absValue = value >= 0 ? uint256(value) : uint256(-value); + str = toString(absValue); + + if (value < 0) { + str = string(abi.encodePacked("-", str)); + } + } + + function toString(uint256 value) internal pure returns (string memory str) { + /// @solidity memory-safe-assembly + assembly { + // The maximum value of a uint256 contains 78 digits (1 byte per digit), but we allocate 160 bytes + // to keep the free memory pointer word aligned. We'll need 1 word for the length, 1 word for the + // trailing zeros padding, and 3 other words for a max of 78 digits. In total: 5 * 32 = 160 bytes. + let newFreeMemoryPointer := add(mload(0x40), 160) + + // Update the free memory pointer to avoid overriding our string. + mstore(0x40, newFreeMemoryPointer) + + // Assign str to the end of the zone of newly allocated memory. + str := sub(newFreeMemoryPointer, 32) + + // Clean the last word of memory it may not be overwritten. + mstore(str, 0) + + // Cache the end of the memory to calculate the length later. + let end := str + + // We write the string from rightmost digit to leftmost digit. + // The following is essentially a do-while loop that also handles the zero case. + // prettier-ignore + for { let temp := value } 1 {} { + // Move the pointer 1 byte to the left. + str := sub(str, 1) + + // Write the character to the pointer. + // The ASCII index of the '0' character is 48. + mstore8(str, add(48, mod(temp, 10))) + + // Keep dividing temp until zero. + temp := div(temp, 10) + + // prettier-ignore + if iszero(temp) { break } + } + + // Compute and cache the final total length of the string. + let length := sub(end, str) + + // Move the pointer 32 bytes leftwards to make room for the length. + str := sub(str, 32) + + // Store the string's length at the start of memory allocated for our string. + mstore(str, length) + } + } + + function toString(address value) internal pure returns (string memory str) { + bytes memory s = new bytes(40); + for (uint i = 0; i < 20; i++) { + bytes1 b = bytes1( + uint8(uint(uint160(value)) / (2 ** (8 * (19 - i)))) + ); + bytes1 hi = bytes1(uint8(b) / 16); + bytes1 lo = bytes1(uint8(b) - 16 * uint8(hi)); + s[2 * i] = char(hi); + s[2 * i + 1] = char(lo); + } + return string(s); + } + + function char(bytes1 b) internal pure returns (bytes1 c) { + if (uint8(b) < 10) return bytes1(uint8(b) + 0x30); + else return bytes1(uint8(b) + 0x57); + } +} From eb548f446c355d8d5b375dfb6d0c61aa3e070787 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 11:37:04 +0530 Subject: [PATCH 66/75] Trim comments that only restated the code beneath them Shortened WebAuthn's verification-exclusions list and dropped comments in DeviceWalletFactory, RegistryHelper, LazyWalletRegistry and DeviceWallet that repeated what the next line already said. --- contracts/LazyWalletRegistry.sol | 5 ---- contracts/RegistryHelper.sol | 8 ++---- contracts/WebAuthn.sol | 28 ++++++------------- contracts/device-wallet/DeviceWallet.sol | 7 ++--- .../device-wallet/DeviceWalletFactory.sol | 3 -- 5 files changed, 12 insertions(+), 39 deletions(-) diff --git a/contracts/LazyWalletRegistry.sol b/contracts/LazyWalletRegistry.sol index a72bfc65..73161bdb 100644 --- a/contracts/LazyWalletRegistry.sol +++ b/contracts/LazyWalletRegistry.sol @@ -606,7 +606,6 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra string calldata _newDeviceIdentifier ) internal { DataBundleDetails[] storage dataBundleDetails = deviceIdentifierToESIMDetails[_oldDeviceIdentifier][_eSIMIdentifier]; - // Transfer history of the eSIM identifier to the new device identifier DataBundleDetails[] storage newDataBundleDetails = deviceIdentifierToESIMDetails[_newDeviceIdentifier][_eSIMIdentifier]; // The two arrays are distinct because the caller refuses a switch to the same device, so // pushing onto one cannot lengthen the other and the bound can be read once. @@ -616,7 +615,6 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra } emit DataBundleDetailsTransferredToNewDeviceIdentifier(_newDeviceIdentifier, newDataBundleDetails); - // delete any reference of eSIM identifier from previous device identifier delete deviceIdentifierToESIMDetails[_oldDeviceIdentifier][_eSIMIdentifier]; emit DataBundleDetailsDeletedFromOldDeviceIdentifier(_oldDeviceIdentifier, _eSIMIdentifier); } @@ -634,7 +632,6 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra string calldata _oldDeviceIdentifier, string calldata _newDeviceIdentifier ) internal { - // Remove eSIM identifier from previous device identifier string[] storage eSIMIdentifierOfOldDevice = eSIMIdentifiersAssociatedWithDeviceIdentifier[_oldDeviceIdentifier]; uint256 i = 0; @@ -649,12 +646,10 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra } if(i == associated) revert Errors.ESIMIdentifierNotFound(_eSIMIdentifier, _oldDeviceIdentifier); - // Swap element to be removed with the element at the last index, and then pop last element eSIMIdentifierOfOldDevice[i] = eSIMIdentifierOfOldDevice[eSIMIdentifierOfOldDevice.length - 1]; eSIMIdentifierOfOldDevice.pop(); emit ESIMIdentifierRemovedFromOldDeviceIdentifier(_oldDeviceIdentifier, _eSIMIdentifier, eSIMIdentifierOfOldDevice); - // Add eSIM identifier to new device identifier string[] storage eSIMIdentifierOfNewDevice = eSIMIdentifiersAssociatedWithDeviceIdentifier[_newDeviceIdentifier]; eSIMIdentifierOfNewDevice.push(_eSIMIdentifier); emit ESIMIdentifierAddedToNewDeviceIdentifier(_newDeviceIdentifier, _eSIMIdentifier, eSIMIdentifierOfNewDevice); diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index 72ce47b5..5a47fc8f 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -239,8 +239,6 @@ contract RegistryHelper { salt[0] = _salt; depositAmount[0] = _depositAmount; - // Deploys device smart wallet - // Updates device wallet info via Registry Wallets[] memory wallet = deviceWalletFactory.deployDeviceWalletForUsers{value: _depositAmount}( deviceUniqueIdentifier, deviceWalletOwnersKey, @@ -252,14 +250,12 @@ contract RegistryHelper { address firstESIMWallet = wallet[0].eSIMWallet; address[] memory eSIMWallets = new address[](_eSIMUniqueIdentifiers.length); - // Tracks the eSIMWallets array index uint256 i = 0; - // 1st eSIM wallet will already be deployed by the deployDeviceWalletForUsers function + // deployDeviceWalletForUsers already deploys the 1st eSIM wallet but doesn't set its + // identifier, so that's the one thing left to do for it here. eSIMWallets[i] = firstESIMWallet; - // deployDeviceWalletForUsers doesn't set the eSIM identifer, hence updating it here for the 1st eSIM wallet _assignESIMIdentifier(firstESIMWallet, _eSIMUniqueIdentifiers[i]); - // Increase the index to deploy and set the identifier for the remaining _eSIMUniqueIdentifiers i++; for(; i<_eSIMUniqueIdentifiers.length; ++i) { diff --git a/contracts/WebAuthn.sol b/contracts/WebAuthn.sol index 023b7edf..0dd8150d 100644 --- a/contracts/WebAuthn.sol +++ b/contracts/WebAuthn.sol @@ -134,26 +134,14 @@ library WebAuthn { /// key (x, y). /// /// We make some assumptions about the particular use case of this verifier, so we do NOT verify the following: - /// - Does NOT verify that the origin in the `clientDataJSON` matches the Relying Party's origin: tt is considered - /// the authenticator's responsibility to ensure that the user is interacting with the correct RP. This is - /// enforced by most high quality authenticators properly, particularly the iCloud Keychain and Google Password - /// Manager were tested. - /// - Does NOT verify That `topOrigin` in `clientDataJSON` is well-formed: We assume it would never be present, i.e. - /// the credentials are never used in a cross-origin/iframe context. The website/app set up should disallow - /// cross-origin usage of the credentials. This is the default behaviour for created credentials in common settings. - /// - Does NOT verify that the `rpIdHash` in `authenticatorData` is the SHA-256 hash of the RP ID expected by the Relying - /// Party: this means that we rely on the authenticator to properly enforce credentials to be used only by the correct RP. - /// This is generally enforced with features like Apple App Site Association and Google Asset Links. To protect from - /// edge cases in which a previously-linked RP ID is removed from the authorised RP IDs, we recommend that messages - /// signed by the authenticator include some expiry mechanism. - /// - Does NOT verify the credential backup state: this assumes the credential backup state is NOT used as part of Relying - /// Party business logic or policy. - /// - Does NOT verify the values of the client extension outputs: this assumes that the Relying Party does not use client - /// extension outputs. - /// - Does NOT verify the signature counter: signature counters are intended to enable risk scoring for the Relying Party. - /// This assumes risk scoring is not used as part of Relying Party business logic or policy. - /// - Does NOT verify the attestation object: this assumes that response.attestationObject is NOT present in the response, - /// i.e. the RP does not intend to verify an attestation. + /// - `clientDataJSON.origin`: trusted to the authenticator's own cross-origin policy. + /// - `clientDataJSON.topOrigin`: assumed absent, i.e. no cross-origin/iframe usage. + /// - `authenticatorData.rpIdHash`: trusted to the authenticator. Sign messages with an + /// expiry to limit exposure if a linked RP ID is later removed. + /// - Credential backup state: assumed unused in Relying Party policy. + /// - Client extension outputs: assumed unused. + /// - The signature counter: assumed unused for risk scoring. + /// - The attestation object: assumed absent from the response. /// /// @param challenge The challenge that was provided by the relying party. /// @param requireUV A boolean indicating whether user verification is required. diff --git a/contracts/device-wallet/DeviceWallet.sol b/contracts/device-wallet/DeviceWallet.sol index 24eb105e..52a08f43 100644 --- a/contracts/device-wallet/DeviceWallet.sol +++ b/contracts/device-wallet/DeviceWallet.sol @@ -334,11 +334,8 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 ) internal { if(_hasAccessToFunds) revert Errors.FundsAccessNotGrantableAtBind(_eSIMWalletAddress); if(isValidESIMWallet[_eSIMWalletAddress]) revert Errors.ESIMWalletAlreadyAdded(_eSIMWalletAddress); - // If the eSIM wallet is a newly deployed one, then the owner will definitely be set - // during initialisation. This device wallet will be the owner. - // If the eSIM wallet already existed, then the previous owner (device wallet) - // must transfer the ownership to the eSIM wallet, and mark its status as standby. - // And this device wallet must accept the ownership before calling the addESIMWallet function + // New deployment: owner is set to this wallet at initialisation. + // Existing wallet: the previous owner must transfer and this wallet must accept first. address eSIMWalletOwner = ESIMWallet(payable(_eSIMWalletAddress)).owner(); if(eSIMWalletOwner != address(this)) { revert Errors.ESIMWalletNotOwnedByThisDeviceWallet(_eSIMWalletAddress, eSIMWalletOwner); diff --git a/contracts/device-wallet/DeviceWalletFactory.sol b/contracts/device-wallet/DeviceWalletFactory.sol index a937f733..2b7e4c8d 100644 --- a/contracts/device-wallet/DeviceWalletFactory.sol +++ b/contracts/device-wallet/DeviceWalletFactory.sol @@ -124,7 +124,6 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr verifier = _verifier; eSIMWalletFactory = ESIMWalletFactory(_eSIMWalletFactoryAddress); - // Upgradable beacon for device wallet implementation contract beacon = new UpgradeableBeacon(_deviceWalletImplementation, address(this)); emit DeviceWalletFactoryDeployed( @@ -210,7 +209,6 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr revert Errors.ArrayLengthMismatch(numberOfDeviceWallets, _depositAmounts.length); } - // Track the available ETH to spend uint256 availableETH = msg.value; Wallets[] memory walletsDeployed = new Wallets[](numberOfDeviceWallets); @@ -240,7 +238,6 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr availableETH -= spentETH; } - // return unused ETH if(availableETH > 0) { (bool success,) = msg.sender.call{value: availableETH}(""); if(!success) revert Errors.FailedToTransfer(); From 8796b20638f991acb8d799b8e69a87c6f91b1f3f Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 11:37:09 +0530 Subject: [PATCH 67/75] Fix false AlreadyDeployed revert in fork rehearsal 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. --- scripts/fork/rehearse.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/fork/rehearse.sh b/scripts/fork/rehearse.sh index a8d6eb6c..804b8d66 100755 --- a/scripts/fork/rehearse.sh +++ b/scripts/fork/rehearse.sh @@ -138,11 +138,11 @@ probe_p256() { probe_p256 "$RPC" "the fork" probe_p256 "$ALCHEMY_BASE_SEPOLIA_HTTPS" "Base Sepolia itself" -# Start from an empty record and an empty address book. A leftover from an interrupted run would -# make Deploy.s.sol refuse to start, which is the behaviour a real deployment wants and not what a -# rehearsal wants. -echo '{}' > "$RECORD" -echo '{}' > "$ADDRESS_BOOK" +# Start with neither file present. DeploymentRecord.isRecorded() reads file existence as "already +# deployed", the same as it does for a live chain, so writing an empty {} here would make +# Deploy.s.sol refuse to start on its own rehearsal. A leftover from an interrupted run would cause +# the exact same false positive, which is why cleanup below always removes both on exit. +rm -f "$RECORD" "$ADDRESS_BOOK" # Forge sizes a broadcast transaction from what the simulation consumed. That is wrong for # handleOps: the EntryPoint checks the transaction carries the operation's whole declared gas From ba77f71c4135f41ecbd8a591a6d29eeacfdd60fd Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 11:42:06 +0530 Subject: [PATCH 68/75] Fix NatSpec gaps: missing tags, wrong return name, missing inheritdoc 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. --- contracts/LazyWalletRegistry.sol | 2 +- contracts/Registry.sol | 7 +++---- contracts/RegistryHelper.sol | 3 ++- contracts/interfaces/IPaymentRegistry.sol | 3 +++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/contracts/LazyWalletRegistry.sol b/contracts/LazyWalletRegistry.sol index 73161bdb..7ec4f9f0 100644 --- a/contracts/LazyWalletRegistry.sol +++ b/contracts/LazyWalletRegistry.sol @@ -452,7 +452,7 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra /// @param _eSIMIdentifier unique eSIM identifier that needs to be switched to a new device /// @param _oldDeviceIdentifier device identifier that the eSIM is currently associated with /// @param _newDeviceIdentifier new device identifier that the eSIM needs to be switched to - /// @return bool Returns `true` if the switching of eSIM was successful + /// @return True if the switch succeeded function switchESIMIdentifierToNewDeviceIdentifier( string calldata _eSIMIdentifier, string calldata _oldDeviceIdentifier, diff --git a/contracts/Registry.sol b/contracts/Registry.sol index b5748d69..d0030b75 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -208,7 +208,7 @@ contract Registry is // Admin handover // --------------------------------------------------------------------------------------------- - /// @notice Nominates the next eSIM wallet admin, who then has to accept + /// @inheritdoc IRegistryAdmin /// @dev Owner and not the admin, deliberately. An admin that had to nominate its own /// replacement could not be removed once its key was in someone else's hands, and the /// pause is the admin's own lever, so a compromised key could hold the protocol stopped @@ -222,7 +222,6 @@ contract Registry is /// Deliberately does not check for an existing request, so an unintended nomination is /// overridden by calling this again. Naming the incumbent withdraws the request and hands /// the powers back, which also lifts a suspension, so one call undoes either mistake. - /// @param _newAdmin Address of the recipient to receive the admin role function requestAdminUpdate(address _newAdmin) external onlyOwner { if(_newAdmin == address(0)) revert Errors.ZeroAddress("_newAdmin"); @@ -257,7 +256,7 @@ contract Registry is return msg.sender; } - /// @notice Suspends the admin's powers protocol-wide, leaving its address on the books + /// @inheritdoc IRegistryAdmin /// @dev Every gate in the protocol reads `eSIMWalletAdmin()`, which answers zero from here on, /// and no transaction can arrive from the zero address, so one write closes all of them /// in the same transaction. The address itself is kept so the suspension can be lifted @@ -337,7 +336,7 @@ contract Registry is emit Paused(msg.sender); } - /// @notice Releases the pause + /// @inheritdoc IPausable /// @dev Owner only, see `pause` function unpause() external onlyOwner { paused = false; diff --git a/contracts/RegistryHelper.sol b/contracts/RegistryHelper.sol index 5a47fc8f..c71fd22a 100644 --- a/contracts/RegistryHelper.sol +++ b/contracts/RegistryHelper.sol @@ -216,7 +216,8 @@ contract RegistryHelper { /// @param _salt CREATE2 salt the device wallet and its first eSIM wallet are deployed at /// @param _eSIMUniqueIdentifiers First batch of eSIM identifiers, in the order the full list holds them /// @param _depositAmount ETH forwarded to the new device wallet - /// @return Return device wallet address and the eSIM wallet addresses this call deployed + /// @return The device wallet address + /// @return The eSIM wallet addresses this call deployed function deployLazyWallet( bytes32[2] memory _deviceWalletOwnerKey, string calldata _deviceUniqueIdentifier, diff --git a/contracts/interfaces/IPaymentRegistry.sol b/contracts/interfaces/IPaymentRegistry.sol index 8584cf4a..8eddf4fc 100644 --- a/contracts/interfaces/IPaymentRegistry.sol +++ b/contracts/interfaces/IPaymentRegistry.sol @@ -7,8 +7,11 @@ pragma solidity 0.8.36; /// also keeps the adapter's view of the registry down to what it reads. interface IPaymentRegistry { /// @notice Address that receives payments for data bundles + /// @return The vault address function vault() external view returns (address); /// @notice The device wallet an eSIM wallet belongs to, or zero if the registry has no record + /// @param eSIMWallet The eSIM wallet to look up + /// @return The owning device wallet, or the zero address if unrecognized function isESIMWalletValid(address eSIMWallet) external view returns (address); } From af6e70ff4bfa5a9ba48f6ce69be182dde57cecb9 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 11:48:29 +0530 Subject: [PATCH 69/75] Add missing @notice on constructors and @return on simple getters 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. --- contracts/LazyWalletRegistry.sol | 2 ++ contracts/Registry.sol | 2 ++ contracts/aa-helper/Account4337.sol | 2 ++ contracts/admin/ProtocolAdmin.sol | 1 + contracts/device-wallet/DeviceWallet.sol | 1 + contracts/device-wallet/DeviceWalletFactory.sol | 3 +++ contracts/esim-wallet/ESIMWallet.sol | 2 ++ contracts/esim-wallet/ESIMWalletFactory.sol | 2 ++ contracts/payments/PaymentAdapter.sol | 2 ++ 9 files changed, 17 insertions(+) diff --git a/contracts/LazyWalletRegistry.sol b/contracts/LazyWalletRegistry.sol index 7ec4f9f0..369df13f 100644 --- a/contracts/LazyWalletRegistry.sol +++ b/contracts/LazyWalletRegistry.sol @@ -190,6 +190,7 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Disables initializers on the implementation contract /// @dev Locks the implementation contract itself. Without this, anyone can call initialize /// directly on the implementation and own it. The proxy is unaffected either way, but an /// owned implementation is a trap for any later upgrade that adds an outward call. @@ -723,6 +724,7 @@ contract LazyWalletRegistry is Initializable, UUPSUpgradeable, Ownable2StepUpgra /// @dev Reads through to the owner rather than holding its own copy. `_authorizeUpgrade` is /// gated on `onlyOwner`, so the owner is the upgrade authority by definition and a second /// copy could only ever disagree with it. + /// @return The address that may upgrade this contract function upgradeManager() public view returns (address) { return owner(); } diff --git a/contracts/Registry.sol b/contracts/Registry.sol index d0030b75..31a94e5d 100644 --- a/contracts/Registry.sol +++ b/contracts/Registry.sol @@ -144,6 +144,7 @@ contract Registry is // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Disables initializers on the implementation contract /// @dev Locks the implementation contract itself. Without this, anyone can call initialize /// directly on the implementation and own it. The proxy is unaffected either way, but an /// owned implementation is a trap for any later upgrade that adds an outward call. @@ -664,6 +665,7 @@ contract Registry is /// @dev Reads through to the owner rather than holding its own copy. `_authorizeUpgrade` is /// gated on `onlyOwner`, so the owner is the upgrade authority by definition and a second /// copy could only ever disagree with it. + /// @return The address that may upgrade this contract function upgradeManager() public view returns (address) { return owner(); } diff --git a/contracts/aa-helper/Account4337.sol b/contracts/aa-helper/Account4337.sol index 3038aec7..5c853e93 100644 --- a/contracts/aa-helper/Account4337.sol +++ b/contracts/aa-helper/Account4337.sol @@ -87,6 +87,7 @@ contract Account4337 is IAccount, Initializable, TokenCallbackHandler, IERC1271 // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Wires the entry point and WebAuthn verifier used by this account /// @param _entryPoint EntryPoint singleton this account validates against /// @param _verifier Contract used to verify WebAuthn assertions /// @custom:oz-upgrades-unsafe-allow constructor @@ -241,6 +242,7 @@ contract Account4337 is IAccount, Initializable, TokenCallbackHandler, IERC1271 // --------------------------------------------------------------------------------------------- /// @notice This account's gas deposit held by the EntryPoint + /// @return The deposited amount function getDeposit() public view returns (uint256) { return entryPoint.balanceOf(address(this)); } diff --git a/contracts/admin/ProtocolAdmin.sol b/contracts/admin/ProtocolAdmin.sol index f4acb38a..4fefe799 100644 --- a/contracts/admin/ProtocolAdmin.sol +++ b/contracts/admin/ProtocolAdmin.sol @@ -102,6 +102,7 @@ contract ProtocolAdmin is TimelockController { /// @notice The contract was not offered ownership of this target error OwnershipNotOffered(address target); + /// @notice Sets up the timelock's roles and delay bounds /// @param _initialDelay Delay new operations wait before they can be executed /// @param _minDelayFloor Shortest delay `updateDelay` can ever bring the contract down to /// @param _proposers Accounts that may schedule operations, and that may also cancel them diff --git a/contracts/device-wallet/DeviceWallet.sol b/contracts/device-wallet/DeviceWallet.sol index 52a08f43..4e163a05 100644 --- a/contracts/device-wallet/DeviceWallet.sol +++ b/contracts/device-wallet/DeviceWallet.sol @@ -137,6 +137,7 @@ contract DeviceWallet is Initializable, ReentrancyGuardUpgradeable, Account4337 // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Wires the entry point and WebAuthn verifier used by this wallet /// @param anEntryPoint EntryPoint singleton this wallet validates against /// @param _verifier Contract used to verify WebAuthn assertions /// @custom:oz-upgrades-unsafe-allow constructor diff --git a/contracts/device-wallet/DeviceWalletFactory.sol b/contracts/device-wallet/DeviceWalletFactory.sol index 2b7e4c8d..108eae02 100644 --- a/contracts/device-wallet/DeviceWalletFactory.sol +++ b/contracts/device-wallet/DeviceWalletFactory.sol @@ -90,6 +90,7 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Disables initializers on the implementation contract /// @dev Locks the implementation contract itself. Without this, anyone can call initialize /// directly on the implementation, own it, and make it deploy a beacon it controls. The /// proxy is unaffected either way, but an owned implementation is a trap for any later @@ -529,6 +530,7 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr /// behind the rest of the protocol after a rotation. Answers address(0) before the /// registry is wired up, which no caller can match, so admin functions stay closed until /// then rather than reverting on a call into address(0). + /// @return The current admin, or zero if the registry is not yet wired up function eSIMWalletAdmin() public view returns (address) { if(address(registry) == address(0)) return address(0); return registry.eSIMWalletAdmin(); @@ -590,6 +592,7 @@ contract DeviceWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgr } /// @notice The device wallet logic contract every device wallet currently runs + /// @return The current implementation address function getCurrentDeviceWalletImplementation() public view returns (address) { return beacon.implementation(); } diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 14f35658..4c84c1c8 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -136,6 +136,7 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Disables initializers on the implementation contract /// @dev `_disableInitializers` rather than an `initializer` modifier. The modifier leaves the /// version at 1, which a later `reinitializer(2)` would still accept on the implementation /// itself. This pins it at the maximum so no version can ever run there. @@ -507,6 +508,7 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice The device wallet that owns this eSIM wallet /// @dev Declared so subclasses and mocks have one place to override. + /// @return The owning device wallet address function owner() public view override returns (address) { return OwnableUpgradeable.owner(); } diff --git a/contracts/esim-wallet/ESIMWalletFactory.sol b/contracts/esim-wallet/ESIMWalletFactory.sol index 0e17edbd..c186aff9 100644 --- a/contracts/esim-wallet/ESIMWalletFactory.sol +++ b/contracts/esim-wallet/ESIMWalletFactory.sol @@ -80,6 +80,7 @@ contract ESIMWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgrad // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Disables initializers on the implementation contract /// @dev Locks the implementation contract itself. Without this, anyone can call initialize /// directly on the implementation, own it, and make it deploy a beacon it controls. The /// proxy is unaffected either way, but an owned implementation is a trap for any later @@ -240,6 +241,7 @@ contract ESIMWalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgrad {} /// @notice The eSIM wallet logic contract every eSIM wallet currently runs + /// @return The current implementation address function getCurrentESIMWalletImplementation() public view returns (address) { return beacon.implementation(); } diff --git a/contracts/payments/PaymentAdapter.sol b/contracts/payments/PaymentAdapter.sol index db36104a..e8fe13a6 100644 --- a/contracts/payments/PaymentAdapter.sol +++ b/contracts/payments/PaymentAdapter.sol @@ -116,6 +116,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab // Initialisation // --------------------------------------------------------------------------------------------- + /// @notice Disables initializers on the implementation contract /// @dev Locks the implementation contract itself, so nobody can initialise and own it directly. /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -293,6 +294,7 @@ contract PaymentAdapter is Initializable, UUPSUpgradeable, Ownable2StepUpgradeab /// @notice Address that can upgrade this contract /// @dev Reads through to the owner rather than holding a second copy that could disagree. + /// @return The address that may upgrade this contract function upgradeManager() public view returns (address) { return owner(); } From cc41080935eedeafadf8eb9d62646f63b6cc9506 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 12:06:39 +0530 Subject: [PATCH 70/75] Remove stray character breaking compilation in ESIMWallet.sol --- contracts/esim-wallet/ESIMWallet.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/esim-wallet/ESIMWallet.sol b/contracts/esim-wallet/ESIMWallet.sol index 4c84c1c8..db3fad15 100644 --- a/contracts/esim-wallet/ESIMWallet.sol +++ b/contracts/esim-wallet/ESIMWallet.sol @@ -516,4 +516,4 @@ contract ESIMWallet is Initializable, OwnableUpgradeable, ReentrancyGuardUpgrade /// @notice Accepts plain ETH transfers, which is how the device wallet tops this wallet up // solhint-disable-next-line no-empty-blocks receive() external payable {} -} +} \ No newline at end of file From 32b6b69a6ef9ef4769901b40fbf6e21c45f3ff1b Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 12:06:48 +0530 Subject: [PATCH 71/75] Regenerate NatSpec docs to match recent contract comment fixes --- docs/LazyWalletRegistry.md | 18 ++++++- docs/Registry.md | 60 +++++++++++++++++++---- docs/RegistryHelper.md | 34 ++++++++----- docs/WebAuthn.md | 28 +++-------- docs/aa-helper/Account4337.md | 8 +++ docs/admin/ProtocolAdmin.md | 14 +++++- docs/device-wallet/DeviceWallet.md | 17 +++++-- docs/device-wallet/DeviceWalletFactory.md | 14 ++++++ docs/esim-wallet/ESIMWallet.md | 37 +++++++++++--- docs/esim-wallet/ESIMWalletFactory.md | 20 ++++++-- docs/interfaces/IOwnableESIMWallet.md | 48 ------------------ docs/interfaces/IPaymentRegistry.md | 18 +++++++ docs/payments/PaymentAdapter.md | 32 ++++++++++-- 13 files changed, 237 insertions(+), 111 deletions(-) delete mode 100644 docs/interfaces/IOwnableESIMWallet.md diff --git a/docs/LazyWalletRegistry.md b/docs/LazyWalletRegistry.md index d57a85ba..37efd795 100644 --- a/docs/LazyWalletRegistry.md +++ b/docs/LazyWalletRegistry.md @@ -242,6 +242,8 @@ _Read from the registry on every call, so a rotation there takes effect immediat constructor() public ``` +Disables initializers on the implementation contract + _Locks the implementation contract itself. Without this, anyone can call initialize directly on the implementation and own it. The proxy is unaffected either way, but an owned implementation is a trap for any later upgrade that adds an outward call._ @@ -297,7 +299,13 @@ _Only the first `_maxWallets` eSIM wallets are deployed here. Anything left goes dropped transaction leaves the user with nothing rather than with most of what they bought. Switching an eSIM to another device is refused for the whole time the rest are outstanding, which `switchESIMIdentifierToNewDeviceIdentifier` already does by refusing - any device that has a wallet._ + any device that has a wallet. + + Refused while the protocol is paused. This is the one lazy route that moves ETH, so it + is held for the same reason the purchase and pull paths are. Its two siblings, + `deployMoreESIMWalletsForLazyDevice` and `setHistoryForLazyWallet`, move none and run + through a pause deliberately, so a stopped protocol can still finish a device already + half deployed._ #### Parameters @@ -401,7 +409,7 @@ _Only ever before deployment. Once a wallet exists onchain, the onchain graph is | Name | Type | Description | | ---- | ---- | ----------- | -| [0] | bool | bool Returns `true` if the switching of eSIM was successful | +| [0] | bool | True if the switch succeeded | ### renounceOwnership @@ -501,6 +509,12 @@ _Reads through to the owner rather than holding its own copy. `_authorizeUpgrade gated on `onlyOwner`, so the owner is the upgrade authority by definition and a second copy could only ever disagree with it._ +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The address that may upgrade this contract | + ### outstandingHistoryEntries ```solidity diff --git a/docs/Registry.md b/docs/Registry.md index 6ec51d88..7f644901 100644 --- a/docs/Registry.md +++ b/docs/Registry.md @@ -68,7 +68,7 @@ _Only the owner can request the transfer. The nominated address has to accept it bool paused ``` -True while the ETH-moving paths are stopped protocol-wide +True while the protocol's guarded purchase and pull paths are stopped _Held here for the same reason the admin address is: device wallets and eSIM wallets are beacon proxies tracked by a mapping with no enumerable list, so there is no way to @@ -112,6 +112,20 @@ Contract holding the accepted currencies and the spent payment references _A pointer and a setter, the same shape this registry already uses for the lazy wallet registry._ +### usedPaymentReferences + +```solidity +mapping(bytes32 => bool) usedPaymentReferences +``` + +Payment references already spent, scoped per eSIM wallet + +_Held here rather than on the payment adapter, so replay protection survives an adapter + rotation through `setPaymentAdapter` instead of resetting with it. Keyed by + `keccak256(abi.encode(eSIMWallet, paymentReference))` rather than by the reference + alone, so one wallet spending a reference cannot burn it for an unrelated wallet's + pending settlement._ + ### onlyESIMWallet ```solidity @@ -155,6 +169,8 @@ _The hot key the backend signs with, not the owner. It can trip the pause but no constructor() public ``` +Disables initializers on the implementation contract + _Locks the implementation contract itself. Without this, anyone can call initialize directly on the implementation and own it. The proxy is unaffected either way, but an owned implementation is a trap for any later upgrade that adds an outward call._ @@ -185,7 +201,7 @@ Wires the registry to the two factories and sets the protocol's addresses function requestAdminUpdate(address _newAdmin) external ``` -Nominates the next eSIM wallet admin, who then has to accept +Nominates a new admin, which strips the incumbent until the nominee accepts _Owner and not the admin, deliberately. An admin that had to nominate its own replacement could not be removed once its key was in someone else's hands, and the @@ -310,7 +326,7 @@ _Owner and not admin, deliberately. This is the destination of every payment the function pause() external ``` -Stops the ETH-moving paths on every device wallet and eSIM wallet +Stops the token purchase and pull paths on every device wallet and eSIM wallet _The admin trips this and the owner clears it. The admin key signs backend batches all day and is the one watching, so it needs to act without waiting; giving it the release @@ -323,7 +339,7 @@ _The admin trips this and the owner clears it. The admin key signs backend batch function unpause() external ``` -Releases the pause +Clears the pause _Owner only, see `pause`_ @@ -380,10 +396,10 @@ _Owner and not admin. The adapter holds the spent payment references, so an admi function consumePaymentReference(bytes32 _paymentReference) external ``` -Spends a payment reference for an eSIM wallet buying with ETH +Spends a payment reference for an eSIM wallet paying with USDC (or any other acceptable stablecoin/ERC20) -_The adapter only accepts this from the registry, so the wallet has to come through - here. One reference then cannot be spent once on each path._ +_Scoped to `msg.sender`, so this and `recordSettledPurchase` cannot spend the same + reference once on each for the same wallet._ #### Parameters @@ -397,7 +413,7 @@ _The adapter only accepts this from the registry, so the wallet has to come thro function recordSettledPurchase(address _eSIMWallet, struct DataBundleDetails _dataBundleDetail, bytes32 _asset, uint256 _tokenAmount, bytes32 _paymentReference) external ``` -Records a data bundle paid for through Moonpay, a card or an external wallet +Records a data bundle paid for through an external wallet or a card _No money moves here. Three things bound what the admin can state: the price ceiling, the payment reference being spendable once, and the settlement not being @@ -412,7 +428,27 @@ _No money moves here. Three things bound what the admin can state: the price cei | _dataBundleDetail | struct DataBundleDetails | The purchase, priced in USD cents like everywhere else | | _asset | bytes32 | Symbol of the currency the user paid in | | _tokenAmount | uint256 | What the user actually paid, in that currency's smallest unit. Recorded for offchain matching, never checked: it and the price both come from the admin. | -| _paymentReference | bytes32 | Hash of the Moonpay charge or the Stripe payment intent | +| _paymentReference | bytes32 | Hash tying this purchase to its offchain payment intent | + +### requireLazyHistoryCopied + +```solidity +function requireLazyHistoryCopied(address _eSIMWallet) external view +``` + +Refuses a new entry while older history is still waiting to be copied in + +_The new entry would land first and the older ones append after it, leaving the history + out of order. The backend retries the whole onchain step on failure, so this cannot be + left as an ordering rule for the caller to follow. External so a wallet can run the same + check on its own paths that see the money move; `recordSettledPurchase` uses the + private form since it already has this contract's own state in scope._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| _eSIMWallet | address | Wallet being appended to | ### updateDeviceWalletInfo @@ -609,3 +645,9 @@ _Reads through to the owner rather than holding its own copy. `_authorizeUpgrade gated on `onlyOwner`, so the owner is the upgrade authority by definition and a second copy could only ever disagree with it._ +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The address that may upgrade this contract | + diff --git a/docs/RegistryHelper.md b/docs/RegistryHelper.md index 9e83d83e..a9b210e0 100644 --- a/docs/RegistryHelper.md +++ b/docs/RegistryHelper.md @@ -90,13 +90,17 @@ _This is the registration record. A non-zero entry means the protocol deployed t mapping(address => bool) isESIMWalletOnStandby ``` -If an existing eSIM wallet is in the process of being transferred from one device wallet to another +True while an eSIM wallet sits between device wallets, released by one and not yet + taken on by another -_If bool is `true`, the eSIM wallet is in a transient state. `isESIMWalletValid` still - points at the old device wallet. Do not use this mapping to check whether an eSIM - wallet belongs to the protocol; that is what `isESIMWalletValid` is for. Its job is to - hold transactions on this eSIM wallet until it reads false again, meaning the new - device wallet has accepted it._ +_A marker for offchain readers, not a gate: nothing in the protocol reads it, and no + purchase, pull or history path is held while it is true. Gating spend on it would brick + a wallet its device wallet simply removed, since `removeESIMWallet` raises it whether or + not a transfer follows and only a later bind lowers it again. + + `isESIMWalletValid` still names the device wallet that last held the wallet while this + is true. Do not use this mapping to ask whether an eSIM wallet belongs to the protocol; + that is what `isESIMWalletValid` is for._ ### claimedESIMIdentifiers @@ -149,10 +153,10 @@ Emitted the first and only time an eSIM identifier is bound to an eSIM wallet _The identifier is carried unindexed as well as hashed, because indexing a dynamic type stores its hash and no consumer can read the value back out of that._ -### UpdatedDeviceWalletassociatedWithESIMWallet +### UpdatedDeviceWalletAssociatedWithESIMWallet ```solidity -event UpdatedDeviceWalletassociatedWithESIMWallet(address _eSIMWalletAddress, address _deviceWalletAddress) +event UpdatedDeviceWalletAssociatedWithESIMWallet(address _eSIMWalletAddress, address _deviceWalletAddress) ``` Emitted when an eSIM wallet is bound to a device wallet @@ -230,7 +234,7 @@ Emitted when the owner points data bundle payments at a different vault event Paused(address _admin) ``` -Emitted when the admin stops the ETH-moving paths protocol-wide +Emitted when the admin stops the protocol's guarded purchase and pull paths ### Unpaused @@ -256,6 +260,14 @@ event PaymentAdapterUpdated(address _paymentAdapter) Emitted when the owner points the registry at a payment adapter +### PaymentReferenceConsumed + +```solidity +event PaymentReferenceConsumed(address _eSIMWallet, bytes32 _paymentReference) +``` + +Emitted when a payment reference is spent for an eSIM wallet + ### DataBundleSettled ```solidity @@ -314,8 +326,8 @@ _Deploys the wallets and sets their identifiers only. Purchase history is copied | Name | Type | Description | | ---- | ---- | ----------- | -| [0] | address | Return device wallet address and the eSIM wallet addresses this call deployed | -| [1] | address[] | | +| [0] | address | The device wallet address | +| [1] | address[] | The eSIM wallet addresses this call deployed | ### deployMoreLazyESIMWallets diff --git a/docs/WebAuthn.md b/docs/WebAuthn.md index f9c4c1c4..12247044 100644 --- a/docs/WebAuthn.md +++ b/docs/WebAuthn.md @@ -70,26 +70,14 @@ _We do not verify all the steps as described in the specification, only ones rel key (x, y). We make some assumptions about the particular use case of this verifier, so we do NOT verify the following: - - Does NOT verify that the origin in the `clientDataJSON` matches the Relying Party's origin: tt is considered - the authenticator's responsibility to ensure that the user is interacting with the correct RP. This is - enforced by most high quality authenticators properly, particularly the iCloud Keychain and Google Password - Manager were tested. - - Does NOT verify That `topOrigin` in `clientDataJSON` is well-formed: We assume it would never be present, i.e. - the credentials are never used in a cross-origin/iframe context. The website/app set up should disallow - cross-origin usage of the credentials. This is the default behaviour for created credentials in common settings. - - Does NOT verify that the `rpIdHash` in `authenticatorData` is the SHA-256 hash of the RP ID expected by the Relying - Party: this means that we rely on the authenticator to properly enforce credentials to be used only by the correct RP. - This is generally enforced with features like Apple App Site Association and Google Asset Links. To protect from - edge cases in which a previously-linked RP ID is removed from the authorised RP IDs, we recommend that messages - signed by the authenticator include some expiry mechanism. - - Does NOT verify the credential backup state: this assumes the credential backup state is NOT used as part of Relying - Party business logic or policy. - - Does NOT verify the values of the client extension outputs: this assumes that the Relying Party does not use client - extension outputs. - - Does NOT verify the signature counter: signature counters are intended to enable risk scoring for the Relying Party. - This assumes risk scoring is not used as part of Relying Party business logic or policy. - - Does NOT verify the attestation object: this assumes that response.attestationObject is NOT present in the response, - i.e. the RP does not intend to verify an attestation._ + - `clientDataJSON.origin`: trusted to the authenticator's own cross-origin policy. + - `clientDataJSON.topOrigin`: assumed absent, i.e. no cross-origin/iframe usage. + - `authenticatorData.rpIdHash`: trusted to the authenticator. Sign messages with an + expiry to limit exposure if a linked RP ID is later removed. + - Credential backup state: assumed unused in Relying Party policy. + - Client extension outputs: assumed unused. + - The signature counter: assumed unused for risk scoring. + - The attestation object: assumed absent from the response._ #### Parameters diff --git a/docs/aa-helper/Account4337.md b/docs/aa-helper/Account4337.md index 3b51294a..d250f8ce 100644 --- a/docs/aa-helper/Account4337.md +++ b/docs/aa-helper/Account4337.md @@ -84,6 +84,8 @@ Restricts a call to the EntryPoint singleton constructor(contract IEntryPoint _entryPoint, contract P256Verifier _verifier) public ``` +Wires the entry point and WebAuthn verifier used by this account + #### Parameters | Name | Type | Description | @@ -249,6 +251,12 @@ function getDeposit() public view returns (uint256) This account's gas deposit held by the EntryPoint +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | uint256 | The deposited amount | + ### addDeposit ```solidity diff --git a/docs/admin/ProtocolAdmin.md b/docs/admin/ProtocolAdmin.md index 2d94b1ec..f36b8c18 100644 --- a/docs/admin/ProtocolAdmin.md +++ b/docs/admin/ProtocolAdmin.md @@ -27,7 +27,7 @@ _Replaces the single externally owned account that owns `Registry`, `LazyWalletR chosen by the guardian. Restoring any of the three is an owner action and waits, so the side taking power away always wins the race against the side handing it back. A guardian able to reinstate an admin would lose that, and a guardian able to appoint one would reach - `ESIMWallet.buyDataBundle` and every wallet holding ETH access through it. + `ESIMWallet.buyDataBundleWithToken` and every wallet holding funds access through it. The second one exists because without it a compromised canceller is permanent. Evicting any role holder means scheduling `revokeRole`, a scheduled operation can be cancelled by any @@ -172,6 +172,8 @@ The contract was not offered ownership of this target constructor(uint256 _initialDelay, uint256 _minDelayFloor, address[] _proposers, address[] _cancellers, address[] _guardians) public ``` +Sets up the timelock's roles and delay bounds + _No admin account. The zero passed to `TimelockController` leaves this contract holding its own `DEFAULT_ADMIN_ROLE`, so granting or revoking any role is itself an operation that has to be scheduled and waited out. Rotating a signer set is a role change here and @@ -342,7 +344,15 @@ _Permissionless, and safe to be: it only takes ownership that the current owner Each event is emitted ahead of the call it describes, so a target that emits its own events on handover cannot interleave them out of order. A failing handover takes the - whole batch down with it, so no event here can outlive the call it announced._ + whole batch down with it, so no event here can outlive the call it announced. + + `OwnershipAccepted` names an address the caller chose, and anyone can call this with a + contract they wrote that answers this address to `pendingOwner()` and returns quietly + from `acceptOwnership()`. Both answers come from the target, so no check here can tell + a protocol contract from one written to look like it. Read the event as a claim about + an address, and filter it against the contracts this one is meant to own. It costs the + protocol nothing beyond the log line: this contract holds no funds, and the powers it + does hold are gated on roles a target cannot obtain by being called._ #### Parameters diff --git a/docs/device-wallet/DeviceWallet.md b/docs/device-wallet/DeviceWallet.md index c75b1719..3b987008 100644 --- a/docs/device-wallet/DeviceWallet.md +++ b/docs/device-wallet/DeviceWallet.md @@ -47,10 +47,10 @@ Set to true if the eSIM wallet belongs to this device wallet mapping(address => bool) canPullFunds ``` -Tracks if an associated eSIM wallet may pull this wallet's ETH and tokens +Tracks if an associated eSIM wallet may pull this wallet's tokens -_One flag for both. A wallet trusted with the ETH can already drain the owner, so a - second flag per asset would cost another signature and limit nothing._ +_A wallet trusted with the funds can already drain the owner, so a second flag per + asset would cost another signature and limit nothing._ ### FundsAccessUpdated @@ -143,6 +143,8 @@ _Read from the registry on every call, so a rotation there takes effect immediat constructor(contract IEntryPoint anEntryPoint, contract P256Verifier _verifier) public ``` +Wires the entry point and WebAuthn verifier used by this wallet + #### Parameters | Name | Type | Description | @@ -183,7 +185,14 @@ _The new wallet has no eSIM identifier yet. That arrives through `setESIMUniqueIdentifierForAnESIMWallet` once the eSIM itself has been created. Access to this wallet's money is granted only afterwards, by the owner, with - `toggleAccessToFunds`._ + `toggleAccessToFunds`. + + The admin gate here is a workflow convenience, not a security boundary against this + wallet's own owner: `ESIMWalletFactory.deployESIMWallet` also accepts a call from any + device wallet the registry knows, so the owner can reach the same outcome directly + through `execute` with no admin involved. Closing that would need the deployment + triggered by the admin rather than by this wallet, since nothing downstream of a device + wallet's own call can tell one caller's signed intent from another's._ #### Parameters diff --git a/docs/device-wallet/DeviceWalletFactory.md b/docs/device-wallet/DeviceWalletFactory.md index 2cb07d4d..eb143c68 100644 --- a/docs/device-wallet/DeviceWalletFactory.md +++ b/docs/device-wallet/DeviceWalletFactory.md @@ -106,6 +106,8 @@ Restricts a call to the eSIM wallet admin or the registry constructor() public ``` +Disables initializers on the implementation contract + _Locks the implementation contract itself. Without this, anyone can call initialize directly on the implementation, own it, and make it deploy a beacon it controls. The proxy is unaffected either way, but an owned implementation is a trap for any later @@ -352,6 +354,12 @@ _Held by the registry, which is where it is rotated, so this contract cannot fal registry is wired up, which no caller can match, so admin functions stay closed until then rather than reverting on a call into address(0)._ +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The current admin, or zero if the registry is not yet wired up | + ### preCreateAccountValidation ```solidity @@ -408,3 +416,9 @@ function getCurrentDeviceWalletImplementation() public view returns (address) The device wallet logic contract every device wallet currently runs +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The current implementation address | + diff --git a/docs/esim-wallet/ESIMWallet.md b/docs/esim-wallet/ESIMWallet.md index 044adb19..d90654ba 100644 --- a/docs/esim-wallet/ESIMWallet.md +++ b/docs/esim-wallet/ESIMWallet.md @@ -2,7 +2,7 @@ ## ESIMWallet -One eSIM, its purchase history and the ETH that pays for its data bundles +One eSIM, its purchase history, and the funds that move through it while it buys data bundles _A beacon proxy deployed by `ESIMWalletFactory`, always owned by a device wallet. The owner is a contract rather than a key, so every call that moves ETH or ownership arrives through @@ -76,7 +76,7 @@ Emitted when the eSIM wallet is deployed event DataBundleBoughtWithToken(bytes32 _dataBundleID, uint64 _priceUSDCents, bytes32 _asset, address _token, uint256 _amountSpent, bytes32 _paymentReference) ``` -Emitted when a data bundle is paid for in an ERC-20 +Emitted when a data bundle is paid for in USDC (or any other acceptable stablecoin/ERC20) _The adapter emits the settlement. This one is for an indexer watching one wallet._ @@ -181,6 +181,8 @@ Restricts a call to the owning device wallet or the eSIM wallet admin constructor() public ``` +Disables initializers on the implementation contract + _`_disableInitializers` rather than an `initializer` modifier. The modifier leaves the version at 1, which a later `reinitializer(2)` would still accept on the implementation itself. This pins it at the maximum so no version can ever run there._ @@ -234,7 +236,11 @@ _Only the owning device wallet, which means the person holding its P256 key: rea this needs a device wallet `execute`, and that needs a signature. The admin names the price on `buyDataBundle`, so it must not also be able to raise the ceiling on that price. Setting zero hands the wallet back to the registry's ceiling. A handover clears - it, so an incoming owner starts on the registry ceiling._ + it, so an incoming owner starts on the registry ceiling. + + The ceiling bounds one charge and not what the admin can charge in total. Nothing limits + how many purchases it makes, so a wallet holding `canPullFunds` is an open allowance over + the device wallet's balance in that asset rather than a capped one._ #### Parameters @@ -252,7 +258,13 @@ Appends pre-deployment purchase history, one batch at a time, on behalf of the l wallet registry _The registry carries the cursor that says how much of an eSIM's history has already been - copied, so this function appends whatever it is handed and does not police repeats._ + copied, so this function appends whatever it is handed and does not police repeats. + + Not held to the price ceiling, unlike `recordSettledPurchase`. These entries are a + record of what the user already paid before any of this existed, so there is nothing + here for a ceiling to bound: the ceiling limits what the admin can charge, and no + charge happens on this path. Refusing an entry priced above today's ceiling would only + stop true history from being written._ #### Parameters @@ -324,10 +336,15 @@ Deliberately not nonReentrant. removeESIMWallet calls this from inside a try/cat function buyDataBundleWithToken(struct DataBundleDetails _dataBundleDetail, bytes32 _asset, uint256 _maxAmountIn, bytes32 _paymentReference) external returns (bool) ``` -Pays the vault for one data bundle in an ERC-20 and records the purchase +Pays the vault for one data bundle in USDC (or any other acceptable stablecoin/ERC20) and records the purchase -_The adapter works the amount out from the price, so unlike the ETH path there is no - second figure to take on trust. Any shortfall is pulled from the device wallet._ +_The adapter works the amount out from the price, so there is never a second figure to + take on trust. Any shortfall is pulled from the device wallet. What reaches the adapter + is this wallet's real balance after the pull, not the nominal amount asked for, so a + non-standard token that delivers less than requested (fee-on-transfer, deflationary) + fails at `settle`'s funding check with a clear reason instead of an opaque transfer + revert here. The protocol does not otherwise support such tokens: `settle` still needs + the price in full._ #### Parameters @@ -451,6 +468,12 @@ The device wallet that owns this eSIM wallet _Declared so subclasses and mocks have one place to override._ +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The owning device wallet address | + ### receive ```solidity diff --git a/docs/esim-wallet/ESIMWalletFactory.md b/docs/esim-wallet/ESIMWalletFactory.md index 381c79fa..c293e98c 100644 --- a/docs/esim-wallet/ESIMWalletFactory.md +++ b/docs/esim-wallet/ESIMWalletFactory.md @@ -38,10 +38,10 @@ mapping(address => bool) isESIMWalletDeployed Set to true if eSIM wallet address is deployed using the factory, false otherwise -### ESIMWalletFactorydeployed +### ESIMWalletFactoryDeployed ```solidity -event ESIMWalletFactorydeployed(address _upgradeManager, address _eSIMWalletImplementation, address beacon) +event ESIMWalletFactoryDeployed(address _upgradeManager, address _eSIMWalletImplementation, address _beacon) ``` Emitted when the eSIM wallet factory is deployed @@ -79,7 +79,13 @@ modifier onlyRegistryOrDeviceWalletFactoryOrDeviceWallet() Restricts a call to the registry, the device wallet factory or a known device wallet _The first two deploy on behalf of a device wallet during setup. A device wallet reaching - this directly is constrained further inside `deployESIMWallet`._ + this directly is constrained further inside `deployESIMWallet`. + + That third caller is what makes `DeviceWallet.deployESIMWallet`'s admin gate a workflow + convenience rather than a boundary: an owner can sign an `execute` straight at this + function and get the same wallet with no admin in the call. Deliberate, since a device + wallet reaches every external function through `execute` and no check downstream of its + call can tell which of its owner's intents produced it._ ### constructor @@ -87,6 +93,8 @@ _The first two deploy on behalf of a device wallet during setup. A device wallet constructor() public ``` +Disables initializers on the implementation contract + _Locks the implementation contract itself. Without this, anyone can call initialize directly on the implementation, own it, and make it deploy a beacon it controls. The proxy is unaffected either way, but an owned implementation is a trap for any later @@ -235,3 +243,9 @@ function getCurrentESIMWalletImplementation() public view returns (address) The eSIM wallet logic contract every eSIM wallet currently runs +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The current implementation address | + diff --git a/docs/interfaces/IOwnableESIMWallet.md b/docs/interfaces/IOwnableESIMWallet.md deleted file mode 100644 index 7fec5643..00000000 --- a/docs/interfaces/IOwnableESIMWallet.md +++ /dev/null @@ -1,48 +0,0 @@ -# Solidity API - -## IOwnableESIMWalletEvents - -### TransferApprovalChanged - -```solidity -event TransferApprovalChanged(address from, address to, bool status) -``` - -## IOwnableESIMWallet - -### initialize - -```solidity -function initialize(address eSIMWalletFactoryAddress, address deviceWalletAddress) external -``` - -### setESIMUniqueIdentifier - -```solidity -function setESIMUniqueIdentifier(string eSIMUniqueIdentifier) external -``` - -### owner - -```solidity -function owner() external view returns (address) -``` - -### transferOwnership - -```solidity -function transferOwnership(address newOwner) external -``` - -### isTransferApproved - -```solidity -function isTransferApproved(address from, address to) external view returns (bool) -``` - -### setApproval - -```solidity -function setApproval(address to, bool status) external -``` - diff --git a/docs/interfaces/IPaymentRegistry.md b/docs/interfaces/IPaymentRegistry.md index 0829b942..70d80276 100644 --- a/docs/interfaces/IPaymentRegistry.md +++ b/docs/interfaces/IPaymentRegistry.md @@ -15,6 +15,12 @@ function vault() external view returns (address) Address that receives payments for data bundles +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The vault address | + ### isESIMWalletValid ```solidity @@ -23,3 +29,15 @@ function isESIMWalletValid(address eSIMWallet) external view returns (address) The device wallet an eSIM wallet belongs to, or zero if the registry has no record +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| eSIMWallet | address | The eSIM wallet to look up | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The owning device wallet, or the zero address if unrecognized | + diff --git a/docs/payments/PaymentAdapter.md b/docs/payments/PaymentAdapter.md index 595d9921..e4f8c3ca 100644 --- a/docs/payments/PaymentAdapter.md +++ b/docs/payments/PaymentAdapter.md @@ -65,8 +65,12 @@ mapping(bytes32 => bool) usedReferences Payment references already spent, protocol-wide -_One reference is one offchain payment. The backend retries the whole onchain step on - any failure, so without this a retry of a call that already landed records it twice._ +_Retired: `Registry.usedPaymentReferences` is now the live replay-protection store, kept + there instead of here so it survives `setPaymentAdapter` rotating this contract out, and + scoped per wallet there so one wallet cannot burn a reference for another. Left declared + at this slot, unread and unwritten by the live purchase paths, because + `StorageLayout.t.sol` pins it here behind the proxy and removing it would shift every + variable below._ ### PaymentAdapterInitialized @@ -128,6 +132,8 @@ _Read from the registry on every call rather than held here, so a wallet the reg constructor() public ``` +Disables initializers on the implementation contract + _Locks the implementation contract itself, so nobody can initialise and own it directly._ ### initialize @@ -246,7 +252,14 @@ _The caller funds this contract and calls settle in the same transaction, which mistake. What stops that is the caller: the one path into here passes `quote`, and an eSIM wallet has no way to call this with a figure of its own choosing. Keep it that way. - A fee-on-transfer token delivers less than declared and fails the funding check._ + A fee-on-transfer token delivers less than declared and fails the funding check. + + `spent` is recomputed here from `_symbol` and `_priceUSDCents` rather than taken from + the caller's own `quote()` call, so the two agree only because nothing mutates + `assets[_symbol]` between the two calls in the same transaction today. Nothing + structurally enforces that: a swap path or any other step that can change an asset's + entry mid-transaction would need `settle` to check a value the caller's own `quote()` + call committed to, not one recomputed fresh here._ #### Parameters @@ -270,9 +283,12 @@ _The caller funds this contract and calls settle in the same transaction, which function consumePaymentReference(bytes32 _paymentReference) external ``` -Spends a payment reference, refusing one already spent +Spends a payment reference against this adapter's own record, refusing one already + spent through it -_Registry only, on both payment paths, so one reference cannot be spent once on each._ +_Retired from the registry's live purchase paths; see `usedReferences`. Left callable so + an adapter instance's own record still means what it says, but nothing in the protocol + calls this any more._ #### Parameters @@ -315,3 +331,9 @@ Address that can upgrade this contract _Reads through to the owner rather than holding a second copy that could disagree._ +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | address | The address that may upgrade this contract | + From 00c321d37725b3deca7a7dd21e1d075536c6a293 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 12:06:53 +0530 Subject: [PATCH 72/75] Remove funding.json --- funding.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 funding.json diff --git a/funding.json b/funding.json deleted file mode 100644 index f923c00a..00000000 --- a/funding.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "opRetro": { - "projectId": "0xda023a3af1b97a8100fddf653cfcb22263384b353a5b129845a04dbea4652d07" - } -} From a5564cf8f62b4cf031bfd0172134c4645f62b776 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 12:06:53 +0530 Subject: [PATCH 73/75] Fix stale README doc links and interfaces description Docs section pointed at a doc file for an interface removed earlier (IOwnableESIMWallet), and was missing PaymentAdapter, IPaymentRegistry and IRegistryAdmin. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4a04eed1..da0a820c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ upgrade one at a time. Every wallet is a beacon proxy, so one beacon call moves | `ProtocolAdmin` | Timelock meant to own the five singletons. Adds a delay floor that `updateDelay` cannot go under, and a guardian role with exactly two powers | Plain contract | | `Errors` | Every custom error in the suite | Library | | `CustomStructs` | Structs shared across contracts | Types | -| `interfaces/` | `IPausable` and `IOwnable2Step`, the two calls `ProtocolAdmin` makes back into the protocol | Interfaces | +| `interfaces/` | `IPausable`, `IOwnable2Step` and `IRegistryAdmin` for the calls `ProtocolAdmin` makes back into the protocol; `IPaymentRegistry` for what `PaymentAdapter` reads from the registry | Interfaces | A backend server generates the device and eSIM identifiers and writes them into the wallets. That is what ties an onchain wallet to a provisioned eSIM. @@ -187,13 +187,15 @@ on one chain and a plain EOA on the other. - [Device Wallet](./docs/device-wallet/DeviceWallet.md) - [eSIM Wallet Factory](./docs/esim-wallet/ESIMWalletFactory.md) - [eSIM Wallet](./docs/esim-wallet/ESIMWallet.md) +- [Payment Adapter](./docs/payments/PaymentAdapter.md) - [Account4337](./docs/aa-helper/Account4337.md) - [Upgradeable Beacon](./docs/UpgradableBeacon.md) - [Custom Structs](./docs/CustomStructs.md) - [Errors](./docs/Errors.md) -- [eSIM Wallet Interface](./docs/interfaces/IOwnableESIMWallet.md) - [Ownable Two-Step Interface](./docs/interfaces/IOwnable2Step.md) - [Pausable Interface](./docs/interfaces/IPausable.md) +- [Registry Admin Interface](./docs/interfaces/IRegistryAdmin.md) +- [Payment Registry Interface](./docs/interfaces/IPaymentRegistry.md) - [P256 Verifier](./docs/P256Verifier.md) - [WebAuthn](./docs/WebAuthn.md) From 1c2e964535fce86d2441a41e5e9a5cd502cc39a2 Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 12:09:42 +0530 Subject: [PATCH 74/75] Update stale test count in README forge test --list --json counts 819 tests across the same 73 suites, not 804. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da0a820c..b50d2d13 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ That parity depends on `bytecode_hash = "none"` in `foundry.toml` and `metadata. ## Testing -804 tests across 73 suites, about eight minutes for a full run. +819 tests across 73 suites, about eight minutes for a full run. | Suite | Files | What it covers | |---|---|---| From 77fcb2ea63ec598dd5522b27ea4f27df28ae43de Mon Sep 17 00:00:00 2001 From: ManulParihar Date: Tue, 1 Sep 2026 13:41:16 +0530 Subject: [PATCH 75/75] Updated storage layout for Registry --- storage-layouts/Registry.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/storage-layouts/Registry.json b/storage-layouts/Registry.json index 835c78ba..611c9ec0 100644 --- a/storage-layouts/Registry.json +++ b/storage-layouts/Registry.json @@ -140,6 +140,13 @@ "bytes": "20", "label": "paymentAdapter", "type": "t_address" + }, + { + "slot": "66", + "offset": 0, + "bytes": "32", + "label": "usedPaymentReferences", + "type": "t_mapping(t_bytes32,t_bool)" } ] }