From 496d0b455d312880ca09eb70eff4601d66b0170a Mon Sep 17 00:00:00 2001 From: Xiaozhou Li Date: Mon, 27 Apr 2026 23:30:25 -0700 Subject: [PATCH 1/3] add docs, tests, and ci --- .github/workflows/ci.yml | 68 ++ .gitignore | 16 +- README.md | 133 ++- docs/architecture-summary.md | 147 +++ docs/contracts.md | 376 ++++++ foundry.toml | 2 + gas_logs/CelerLedger-ERC20.txt | 8 + gas_logs/CelerLedger-ETH.txt | 29 + gas_logs/CelerLedger-Migrate.txt | 6 + gas_logs/EthPool.txt | 9 + gas_logs/PayResolver.txt | 5 + gas_logs/VirtContractResolver.txt | 4 + gas_logs/fine_granularity/ClearPays.txt | 9 + .../fine_granularity/DepositEthInBatch.txt | 9 + .../IntendSettle-OneState.txt | 10 + src/CelerLedger.sol | 20 +- src/CelerLedgerMock.sol | 8 + src/CelerWallet.sol | 14 +- src/EthPool.sol | 10 +- src/PayRegistry.sol | 34 +- src/PayResolver.sol | 34 +- src/RouterRegistry.sol | 11 +- src/VirtContractResolver.sol | 22 +- src/helper/BooleanCondMock.sol | 16 +- src/helper/ERC20ExampleToken.sol | 12 +- src/helper/NumericCondMock.sol | 18 +- src/helper/WalletTestHelper.sol | 16 +- src/lib/data/Pb.sol | 9 +- src/lib/data/PbChain.sol | 8 + src/lib/data/PbEntity.sol | 8 + src/lib/interface/IBooleanCond.sol | 18 + src/lib/interface/ICelerLedger.sol | 194 ++- src/lib/interface/ICelerWallet.sol | 79 ++ src/lib/interface/IEthPool.sol | 57 +- src/lib/interface/INumericCond.sol | 14 + src/lib/interface/IPayRegistry.sol | 62 +- src/lib/interface/IPayResolver.sol | 27 + src/lib/interface/IRouterRegistry.sol | 19 +- src/lib/interface/IVirtContractResolver.sol | 18 + src/lib/ledgerlib/LedgerBalanceLimit.sol | 6 +- src/lib/ledgerlib/LedgerChannel.sol | 9 +- src/lib/ledgerlib/LedgerMigrate.sol | 9 +- src/lib/ledgerlib/LedgerOperation.sol | 8 +- src/lib/ledgerlib/LedgerStruct.sol | 58 +- test/CelerLedger.ERC20.t.sol | 162 +++ test/CelerLedger.ETH.t.sol | 1044 +++++++++++++++++ test/CelerLedger.Migrate.t.sol | 175 +++ test/CelerWallet.t.sol | 388 ++++++ test/EthPool.t.sol | 192 +++ test/GasReport.t.sol | 888 ++++++++++++++ test/PayRegistry.t.sol | 255 ++++ test/PayResolver.t.sol | 447 +++++++ test/RouterRegistry.t.sol | 114 ++ test/VirtContractResolver.t.sol | 57 + test/utils/Base.t.sol | 118 ++ test/utils/Fixtures.sol | 353 ++++++ test/utils/LedgerTestBase.t.sol | 221 ++++ test/utils/Proto.sol | 160 +++ test/utils/Proto.t.sol | 268 +++++ test/utils/SignUtil.sol | 37 + 60 files changed, 6388 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/architecture-summary.md create mode 100644 docs/contracts.md create mode 100644 gas_logs/CelerLedger-ERC20.txt create mode 100644 gas_logs/CelerLedger-ETH.txt create mode 100644 gas_logs/CelerLedger-Migrate.txt create mode 100644 gas_logs/EthPool.txt create mode 100644 gas_logs/PayResolver.txt create mode 100644 gas_logs/VirtContractResolver.txt create mode 100644 gas_logs/fine_granularity/ClearPays.txt create mode 100644 gas_logs/fine_granularity/DepositEthInBatch.txt create mode 100644 gas_logs/fine_granularity/IntendSettle-OneState.txt create mode 100644 test/CelerLedger.ERC20.t.sol create mode 100644 test/CelerLedger.ETH.t.sol create mode 100644 test/CelerLedger.Migrate.t.sol create mode 100644 test/CelerWallet.t.sol create mode 100644 test/EthPool.t.sol create mode 100644 test/GasReport.t.sol create mode 100644 test/PayRegistry.t.sol create mode 100644 test/PayResolver.t.sol create mode 100644 test/RouterRegistry.t.sol create mode 100644 test/VirtContractResolver.t.sol create mode 100644 test/utils/Base.t.sol create mode 100644 test/utils/Fixtures.sol create mode 100644 test/utils/LedgerTestBase.t.sol create mode 100644 test/utils/Proto.sol create mode 100644 test/utils/Proto.t.sol create mode 100644 test/utils/SignUtil.sol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05392dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +# Run only on pull requests, and only when code-relevant files change. +# Doc-only changes (README, docs/, gas_logs/, comments) skip CI. +on: + pull_request: + paths: + # Solidity sources, tests, deploy scripts. + - 'src/**' + - 'test/**' + - 'script/**' + # Submodule SHA bumps (forge-std, openzeppelin) record under lib/. + - 'lib/**' + # Build / dependency / import config. + - 'foundry.toml' + - 'remappings.txt' + - '.gitmodules' + # The workflow itself — catches changes to CI definitions. + - '.github/workflows/**' + +# Cancel an in-flight run if the PR is updated again. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-lint: + name: Build & lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Show versions + run: forge --version + + - name: Format check + run: forge fmt --check + + - name: Lint + run: forge lint + + - name: Build + run: forge build --sizes + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Run tests + run: forge test -vv diff --git a/.gitignore b/.gitignore index be4c767..aa70742 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,18 @@ broadcast/ # Local environment file .env -deployments/ \ No newline at end of file +deployments/ + +# Local-machine MCP server config (contains absolute paths) +.mcp.json + +# AI assistant local docs — kept out of the public repo +AGENTS.md +CLAUDE.md +.claude + +# Coverage output (forge coverage --report lcov) +lcov.info +coverage/ + +local \ No newline at end of file diff --git a/README.md b/README.md index 284748e..ecc865c 100644 --- a/README.md +++ b/README.md @@ -1 +1,132 @@ -# Celer AgentPay state channel contracts +# Celer AgentPay state-channel contracts + +Smart contracts for **Celer AgentPay** — a generalized state-channel payment network +that enables real-time, trust-free micropayments between AI agents, decentralized +services, and human users. + +This repo contains the on-chain L1 contract suite. Almost all activity happens +off-chain via co-signed simplex states; the chain is touched only for deposits, +withdrawals, settlement, and dispute resolution. For the full architecture — design +principles, data structures, off-chain protocols, app channels — see the canonical +docs: + +- 📖 [agentpay-docs.celer.network](https://agentpay-docs.celer.network/) + +A condensed in-repo orientation lives at [`docs/architecture-summary.md`](docs/architecture-summary.md). + +--- + +## Quick Start + +```bash +# Install submodule dependencies (forge-std, openzeppelin) +git submodule update --init --recursive + +# Build +forge build + +# Test +forge test + +# Format / lint +forge fmt +forge lint +``` + +Verified with `forge 1.3.5-stable`. See [foundry.toml](foundry.toml) for compiler / +optimizer settings. + +--- + +## Components + +Seven top-level contracts in [`src/`](src/), grouped by role: + +### Asset custody (permanent, audited, not versioned) + +- **[CelerWallet](src/CelerWallet.sol)** — Multi-owner / multi-token wallet shared by + the whole network; the only contract that holds funds. Operated by a `CelerLedger`. +- **[PayRegistry](src/PayRegistry.sol)** — Append-only global record of resolved + conditional-payment results, indexed by `payId = keccak256(payHash, setterAddress)`. +- **[VirtContractResolver](src/VirtContractResolver.sol)** — On-demand deployer for + virtual (off-chain) contracts when disputes need them on-chain. +- **[EthPool](src/EthPool.sol)** — ERC-20-shaped wrapper for native ETH; enables + single-tx channel opening via a uniform `transferFrom` flow. + +### Channel & payment logic (versioned, peer-controlled migration) + +- **[CelerLedger](src/CelerLedger.sol)** — Channel state machine and primary user entry + point; operator of `CelerWallet`. Logic split across libraries in + [`src/lib/ledgerlib/`](src/lib/ledgerlib/). +- **[PayResolver](src/PayResolver.sol)** — On-chain resolution of conditional payments; + evaluates conditions or vouched results and writes outcomes to `PayRegistry`. + +### Networking + +- **[RouterRegistry](src/RouterRegistry.sol)** — Optional registry where relay-router + operators advertise themselves. + +For per-contract APIs, constructor args, events, and storage, see +[`docs/contracts.md`](docs/contracts.md). + +--- + +## Structure + +``` +src/ +├── CelerLedger.sol # channel state machine; primary entry point (versioned) +├── CelerLedgerMock.sol # test-only ledger variant +├── CelerWallet.sol # multi-owner asset custodian (permanent) +├── EthPool.sol # ERC20-like wrapper for native ETH +├── PayRegistry.sol # global resolved-payment registry (permanent) +├── PayResolver.sol # conditional-pay resolution (versioned) +├── RouterRegistry.sol # optional relay-router registry +├── VirtContractResolver.sol # on-demand virtual-contract deployer +├── helper/ # test fixtures (mocks + sample ERC20) — NOT production +└── lib/ + ├── data/ # auto-generated protobuf decoders + .proto sources + ├── interface/ # I*.sol interfaces + └── ledgerlib/ # CelerLedger logic split into libraries + +test/ # Foundry tests +script/ # Forge deploy scripts (TBD) +lib/ # git submodules: forge-std, openzeppelin +``` + +`broadcast/`, `cache/`, `out/`, `deployments/`, `.env`, `.mcp.json`, `.vscode/` are +gitignored. + +--- + +## Documentation + +| Document | Purpose | +|---|---| +| [docs/architecture-summary.md](docs/architecture-summary.md) | One-page on-chain orientation; links into the full architecture docs. | +| [docs/contracts.md](docs/contracts.md) | Per-contract API reference (constructor, functions, events, storage). | + +--- + +## Dependencies + +| Package | Version | Purpose | +|---|---|---| +| [forge-std](https://github.com/foundry-rs/forge-std) | latest | Testing & scripting | +| [openzeppelin-contracts](https://github.com/OpenZeppelin/openzeppelin-contracts) | release-v5.3 | Access control, ERC-20, ECDSA, Pausable | + +Pinned via git submodules; see [.gitmodules](.gitmodules). + +--- + +## Toolchain + +- [Foundry](https://book.getfoundry.sh) (`forge`, `cast`, `anvil`) +- Solidity `^0.8.20` (see `pragma` in [src/](src/)) +- Optimizer enabled, `runs = 200` ([foundry.toml](foundry.toml)) + +--- + +## License + +MIT — see [LICENSE](LICENSE). \ No newline at end of file diff --git a/docs/architecture-summary.md b/docs/architecture-summary.md new file mode 100644 index 0000000..6fa781e --- /dev/null +++ b/docs/architecture-summary.md @@ -0,0 +1,147 @@ +# Architecture Summary + +A one-page orientation for the on-chain layer of **Celer AgentPay**. For the full +specification — design principles, off-chain protocols, app channels, message flows — +see the canonical architecture docs: + +> 📖 **Full architecture:** [agentpay-docs.celer.network](https://agentpay-docs.celer.network/) + +This page is a quick map. **It does not duplicate the architecture docs** — it gives you +just enough context to read the Solidity in this repo without bouncing back and forth. + +--- + +## On-chain layer in two paragraphs + +AgentPay is a generalized state-channel payment network. The on-chain contracts in this +repo bind two off-chain primitives — the **duplex payment channel** and the +**conditional payment** — to a minimal, verifiable on-chain footprint. Almost all +activity happens off-chain: peers exchange co-signed simplex states and forward +conditional payments through routed paths. The blockchain is only touched for deposits, +withdrawals, settlement, dispute resolution, and (rarely) deploying virtual contracts. + +The seven contracts split cleanly into two roles. **Asset custody** lives in +permanent, audited contracts that change rarely or never (`CelerWallet`, `PayRegistry`, +`VirtContractResolver`, `EthPool`). **Channel and payment logic** lives in *versioned* +contracts (`CelerLedger`, `PayResolver`) that peers can cooperatively migrate between +without disturbing the assets — see [Decentralized Versioning][versioning] in the full +docs. `RouterRegistry` is an optional advertisement registry for relay nodes. + +[versioning]: https://agentpay-docs.celer.network/agentpay-architecture/on-chain-contracts/decentralized-versioning + +--- + +## Five design principles (one-line each) + +These shape every choice in the codebase. Read the full text in +[system-overview.md][system-overview] when context matters. + +1. **Minimize on-chain footprint.** Touch the chain only for deposits / withdrawals / + disputes; keep storage compact. +2. **Minimize relay-node on-chain interaction.** Disputes are between source and + destination; relays never write to chain. +3. **Minimize on-chain view calls.** Cache locally, exchange verified state directly. +4. **Minimize off-chain communication overhead.** Few round-trips, lightweight encoding. +5. **Decouple payment channels from app channels.** Conditions expose a uniform + `isFinalized` / `getOutcome` interface; payment logic is independent of app logic. + +A sixth, structural principle: **decentralized, peer-controlled versioning** — instead +of admin-controlled proxy upgrades, channel peers cooperatively migrate to new +`CelerLedger` / `PayResolver` versions. This eliminates trusted upgrade controllers and +keeps asset custody (`CelerWallet`) immutable. + +[system-overview]: https://agentpay-docs.celer.network/agentpay-architecture/system-overview + +--- + +## Channel state machine + +The status of a payment channel inside `CelerLedger` (see +[`LedgerStruct.ChannelStatus`](../src/lib/ledgerlib/LedgerStruct.sol)): + +``` + openChannel + Uninitialized ─────────────▶ Operable + │ ▲ + intendSettle │ │ migrateChannelFrom + ▼ │ (re-activates) + Settling + │ + confirmSettle │ + (after window) ▼ + Closed + + Operable / Settling ──── migrateChannelFrom ───▶ Migrated + (on the OLD ledger) +``` + +- **Uninitialized** — channel does not yet exist in this `CelerLedger` instance. +- **Operable** — active; deposits, withdrawals, snapshots, off-chain pay forwarding. +- **Settling** — `intendSettle` opened a challenge window; counterparty can submit + newer simplex states. +- **Closed** — terminal; balances paid out, channel finalized. +- **Migrated** — terminal *on the old ledger*; the channel continues life on a new + `CelerLedger` version. Migration outranks `intendSettle`: peers can migrate even + while `Settling`, returning the channel to `Operable` on the new ledger. + +For the full state-transition rules, see +[channel-operations.md][channel-operations]. + +[channel-operations]: https://agentpay-docs.celer.network/agentpay-architecture/on-chain-contracts/channel-operations + +--- + +## Contract map (1-line each) + +| Contract | Role | Versioned? | +|---|---|---| +| [`CelerWallet`](../src/CelerWallet.sol) | Multi-owner / multi-token asset custodian. One global instance. | No (permanent) | +| [`CelerLedger`](../src/CelerLedger.sol) | Channel state machine + primary user entry point. Operator of `CelerWallet`. | **Yes** | +| [`PayResolver`](../src/PayResolver.sol) | On-chain conditional-pay resolution; writes results to `PayRegistry`. | **Yes** (chosen per-payment) | +| [`PayRegistry`](../src/PayRegistry.sol) | Global `payId → (amount, deadline)` map; immutable, public reference. | No (permanent) | +| [`VirtContractResolver`](../src/VirtContractResolver.sol) | On-demand deployment of virtual contracts during disputes. | No (permanent) | +| [`EthPool`](../src/EthPool.sol) | ERC20-like wrapper for native ETH; enables single-tx channel opening. | No | +| [`RouterRegistry`](../src/RouterRegistry.sol) | Optional registry for relay-router self-advertisement. | No | + +For per-contract APIs (constructor args, external functions, events, storage), see +[`contracts.md`](contracts.md). + +--- + +## Key invariants (worth flagging in any change) + +These come straight from the architecture docs. Tests should encode them; PRs should +not violate them. + +- A simplex state is valid only if **co-signed by both peers** and has the highest + `seq_num`. +- `payID = keccak256(payHash, setterAddress)` — binds a payment result to its + designated resolver. `payHash = keccak256(serializedConditionalPay)`. +- During the challenge window of `resolvePayment*`, a result may be **raised** but + never lowered. This protects relay nodes from collusive source/dest pairs. +- Migration outranks `intendSettle`. Cooperative migration always wins over a unilateral + settle in flight. +- `CelerWallet` has exactly one **operator** (a `CelerLedger` instance). Operatorship + transfer is the migration pivot; only the current operator (or all owners + cooperatively, via `proposeNewOperator`) can transfer it. + +--- + +## Where to read more + +| Topic | Source | +|---|---| +| Design principles | [system-overview.md][system-overview] | +| Core data structures (protobuf) | [core-data-structures.md][data-structures] | +| Per-contract responsibilities & relationships | [contracts-architecture.md][contracts-arch] | +| Channel operations (open / deposit / withdraw / settle) | [channel-operations.md][channel-operations] | +| Decentralized versioning (migration) | [decentralized-versioning.md][versioning] | +| App contracts and condition interface | [app-contracts-and-protocols.md][app-protocol] | + +[data-structures]: https://agentpay-docs.celer.network/agentpay-architecture/on-chain-contracts/core-data-structures +[contracts-arch]: https://agentpay-docs.celer.network/agentpay-architecture/on-chain-contracts/contracts-architecture +[app-protocol]: https://agentpay-docs.celer.network/agentpay-architecture/app-contracts-and-protocols + +--- + +**See also:** [`contracts.md`](contracts.md) · [`README.md`](../README.md) diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 0000000..595fc40 --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,376 @@ +# Contracts API Reference + +Per-contract reference for the on-chain layer of Celer AgentPay. For high-level +context, read [`architecture-summary.md`](architecture-summary.md) first. For per-function +details (parameters, semantics, edge cases), follow the source links — NatSpec in the +contract file is authoritative. + +## Table of Contents + +- [CelerWallet](#celerwallet) +- [CelerLedger](#celerledger) +- [PayResolver](#payresolver) +- [PayRegistry](#payregistry) +- [VirtContractResolver](#virtcontractresolver) +- [EthPool](#ethpool) +- [RouterRegistry](#routerregistry) +- [Ledger libraries](#ledger-libraries) (where the channel logic actually lives) +- [Helpers and mocks](#helpers-and-mocks) + +--- + +## CelerWallet + +[Source](../src/CelerWallet.sol) · [Interface](../src/lib/interface/ICelerWallet.sol) · **Permanent** (not versioned) + +Multi-owner, multi-token wallet that holds the funds for every channel in the network. +A single `CelerWallet` instance is shared globally; every `CelerLedger` (current or +future versions) is an *operator* over individual wallets within it. The contract has +deliberately minimal logic — it only knows how to deposit, withdraw, and transfer +operatorship — to keep the audit surface small. + +### Constructor + +```solidity +constructor() Ownable(msg.sender) +``` + +No parameters. Inherits `Ownable` (deployer is initial owner) and `Pausable`. The owner +can `pause` / `unpause` and (when paused) `drainToken` to recover stuck funds. + +### Roles + +- **Owners** — the channel peers; receive withdrawals and vote on operator proposals. +- **Operator** — exactly one per wallet; the `CelerLedger` instance authorized to move + funds. Operatorship is the migration pivot point. +- **Contract owner** (Ownable) — can pause / unpause and drain when paused. + +### External / public functions + +| Function | Caller | Purpose | +|---|---|---| +| [`create`](../src/CelerWallet.sol#L66) | anyone (typically a `CelerLedger`) | Create a new wallet for a peer-pair, returning its `walletId`. | +| [`depositETH`](../src/CelerWallet.sol#L89) | anyone (payable) | Deposit native ETH into a wallet. | +| [`depositERC20`](../src/CelerWallet.sol#L101) | anyone | Deposit ERC-20 tokens (requires prior `approve`). | +| [`withdraw`](../src/CelerWallet.sol#L119) | operator only | Withdraw funds to a receiver. | +| [`transferToWallet`](../src/CelerWallet.sol#L141) | operator only | Move funds between two wallets sharing the same operator (channel rebalancing). | +| [`transferOperatorship`](../src/CelerWallet.sol#L164) | current operator | Transfer operatorship to a new operator (the migration path). | +| [`proposeNewOperator`](../src/CelerWallet.sol#L179) | wallet owner | Propose a new operator; takes effect when *all* owners propose the same address (manual fallback for stuck migrations). | +| [`drainToken`](../src/CelerWallet.sol#L207) | contract owner, when paused | Emergency token recovery. | +| `pause` / `unpause` | contract owner | Pause guard for deposits / withdrawals / operator changes. | +| `getWalletOwners` / `getOperator` / `getBalance` / `getProposedNewOperator` / `getProposalVote` | view | Wallet introspection. | + +### Events + +`CreateWallet`, `DepositToWallet`, `WithdrawFromWallet`, `TransferToWallet`, +`ChangeOperator`, `ProposeNewOperator`, `DrainToken`. See +[`ICelerWallet.sol`](../src/lib/interface/ICelerWallet.sol). + +### Storage + +```solidity +struct Wallet { + address[] owners; + address operator; + mapping(address => uint256) balances; // tokenAddr (0 = ETH) → balance + address proposedNewOperator; + mapping(address => bool) proposalVotes; +} +uint256 public walletNum; +mapping(bytes32 => Wallet) private wallets; +``` + +--- + +## CelerLedger + +[Source](../src/CelerLedger.sol) · [Interface](../src/lib/interface/ICelerLedger.sol) · **Versioned** + +The channel state machine and primary user entry point. The contract itself is a thin +wrapper — the actual logic is split across five libraries under +[`src/lib/ledgerlib/`](../src/lib/ledgerlib/) and attached via `using ... for ...`. See +[Ledger libraries](#ledger-libraries) below. + +### Constructor + +```solidity +constructor(address _ethPool, address _payRegistry, address _celerWallet) Ownable(msg.sender) +``` + +| Param | Purpose | +|---|---| +| `_ethPool` | Address of the deployed [`EthPool`](#ethpool). | +| `_payRegistry` | Address of the deployed [`PayRegistry`](#payregistry). | +| `_celerWallet` | Address of the deployed [`CelerWallet`](#celerwallet). | + +Balance limits are **enabled by default** post-deployment. Configure them via +`setBalanceLimits` or call `disableBalanceLimits` for unlimited per-channel deposits. + +### External functions — channel lifecycle + +| Function | Purpose | +|---|---| +| [`openChannel`](../src/CelerLedger.sol#L66) | Open a fully-funded channel from a co-signed `PaymentChannelInitializer` (single tx). | +| [`deposit`](../src/CelerLedger.sol#L78) | Deposit ETH (msg.value) and/or pull from `EthPool`/ERC20 into a channel. | +| [`depositInBatch`](../src/CelerLedger.sol#L91) | Batch deposit across multiple channels in one tx. | +| [`snapshotStates`](../src/CelerLedger.sol#L114) | Persist a co-signed simplex state on-chain (lightweight checkpoint). | + +### External functions — withdrawals + +| Function | Purpose | +|---|---| +| [`cooperativeWithdraw`](../src/CelerLedger.sol#L153) | Single-tx withdrawal with a co-signed `CooperativeWithdrawInfo`. | +| [`intendWithdraw`](../src/CelerLedger.sol#L126) | Start a unilateral withdrawal challenge window. | +| [`vetoWithdraw`](../src/CelerLedger.sol#L145) | Counterparty cancels an in-flight unilateral withdrawal. | +| [`confirmWithdraw`](../src/CelerLedger.sol#L135) | Finalize a unilateral withdrawal after the window closes. | + +### External functions — settlement + +| Function | Purpose | +|---|---| +| [`cooperativeSettle`](../src/CelerLedger.sol#L192) | Single-tx close with a co-signed `CooperativeSettleInfo`. | +| [`intendSettle`](../src/CelerLedger.sol#L165) | Start unilateral settlement using the latest co-signed simplex states. | +| [`clearPays`](../src/CelerLedger.sol#L175) | Settle additional pending pays via a `PayIdList` after `intendSettle`. | +| [`confirmSettle`](../src/CelerLedger.sol#L184) | Finalize unilateral settlement after the challenge window. | + +### External functions — migration (decentralized versioning) + +| Function | Purpose | +|---|---| +| [`migrateChannelFrom`](../src/CelerLedger.sol#L210) | Called on the **new** ledger; orchestrates the migration end-to-end. | +| [`migrateChannelTo`](../src/CelerLedger.sol#L201) | Called on the **old** ledger by the new one; transfers operatorship and exposes state. | + +### External functions — admin + +| Function | Caller | Purpose | +|---|---|---| +| `setBalanceLimits` | `Ownable` owner | Set per-token per-channel deposit caps. | +| `enableBalanceLimits` / `disableBalanceLimits` | `Ownable` owner | Toggle the balance-limit gate globally. | + +### View functions + +A wide set of getters: `getChannelStatus`, `getTokenContract`, `getTokenType`, +`getTotalBalance`, `getBalanceMap`, `getStateSeqNumMap`, `getTransferOutMap`, +`getNextPayIdListHashMap`, `getLastPayResolveDeadlineMap`, `getPendingPayOutMap`, +`getWithdrawIntent`, `getCooperativeWithdrawSeqNum`, `getSettleFinalizedTime`, +`getDisputeTimeout`, `getMigratedTo`, `getChannelMigrationArgs`, +`getPeersMigrationInfo`, `getChannelStatusNum`, `getEthPool`, `getPayRegistry`, +`getCelerWallet`, `getBalanceLimit`, `getBalanceLimitsEnabled`. See +[`ICelerLedger.sol`](../src/lib/interface/ICelerLedger.sol). + +### Events + +Open/Deposit/Snapshot: `OpenChannel`, `Deposit`, `SnapshotStates`. +Withdraw: `IntendWithdraw`, `ConfirmWithdraw`, `VetoWithdraw`, `CooperativeWithdraw`. +Settle: `IntendSettle`, `ClearOnePay`, `ConfirmSettle`, `ConfirmSettleFail`, +`CooperativeSettle`. Migration: `MigrateChannelFrom`, `MigrateChannelTo`. + +### Storage + +```solidity +LedgerStruct.Ledger private ledger; +// → channelStatusNums, ethPool, payRegistry, celerWallet, +// balanceLimits, balanceLimitsEnabled, channelMap (bytes32 → Channel) +``` + +See [`LedgerStruct.sol`](../src/lib/ledgerlib/LedgerStruct.sol) for the full layout. + +--- + +## PayResolver + +[Source](../src/PayResolver.sol) · [Interface](../src/lib/interface/IPayResolver.sol) · **Versioned** (chosen per-payment) + +On-chain conditional-payment resolution. Payment senders embed the resolver address in +field 8 of `ConditionalPay`, which means each payment is tightly bound to a specific +resolver version (the resolver address goes into `payID = keccak256(payHash, resolver)`). + +### Constructor + +```solidity +constructor(address _registryAddr, address _virtResolverAddr) +``` + +| Param | Purpose | +|---|---| +| `_registryAddr` | Address of the [`PayRegistry`](#payregistry). | +| `_virtResolverAddr` | Address of the [`VirtContractResolver`](#virtcontractresolver). | + +### External functions + +| Function | Purpose | +|---|---| +| [`resolvePaymentByConditions`](../src/PayResolver.sol#L44) | Evaluate every condition (hash-locks via preimages, deployed/virtual contracts via `isFinalized`+`getOutcome`), apply the `transfer_func`, write to `PayRegistry`. Reverts if any condition is not finalized. | +| [`resolvePaymentByVouchedResult`](../src/PayResolver.sol#L71) | Bypass condition evaluation by accepting a result co-signed by `pay.src` and `pay.dest`; capped at `pay.transferFunc.maxTransfer.receiver.amt`. | + +### Resolution rules + +- A payment must be resolved before `pay.resolveDeadline` (block number). +- A result equal to the maximum-transfer amount **finalizes immediately** — no challenge + window. +- A partial result opens a challenge window of length `pay.resolveTimeout`. During the + window the result may be **raised** (never lowered), protecting relay nodes against + collusive source/dest pairs. +- Supported transfer functions: `BOOLEAN_AND`, `BOOLEAN_OR`, `NUMERIC_ADD`, + `NUMERIC_MAX`, `NUMERIC_MIN`. (`BOOLEAN_CIRCUIT` is reserved in the protobuf but not + yet implemented — `assert(false)` on encounter.) +- Hash-lock conditions must always evaluate `true`; their role is multi-hop secret + unlocking, not transfer-amount gating. + +### Events + +`ResolvePayment(bytes32 payId, uint256 amount, uint256 resolveDeadline)` from this +contract; `PayInfoUpdate` emitted on the registry as a side effect. + +--- + +## PayRegistry + +[Source](../src/PayRegistry.sol) · [Interface](../src/lib/interface/IPayRegistry.sol) · **Permanent** + +Global mapping `payId → (amount, deadline)`. Append-only; any contract can be a setter +because the `payId` derivation `keccak256(payHash, msg.sender)` namespaces results by +setter address. + +### Constructor + +No constructor (no state to initialize). + +### External / public functions + +| Function | Purpose | +|---|---| +| [`calculatePayId`](../src/PayRegistry.sol#L25) | Pure helper: `keccak256(payHash, setter)`. | +| [`setPayAmount`](../src/PayRegistry.sol#L29) | Setter writes the resolved amount under its own namespace. | +| [`setPayDeadline`](../src/PayRegistry.sol#L37) | Setter writes the resolve deadline under its own namespace. | +| [`setPayInfo`](../src/PayRegistry.sol#L45) | Combined `setPayAmount` + `setPayDeadline`. | +| [`setPayAmounts`](../src/PayRegistry.sol#L54) / [`setPayDeadlines`](../src/PayRegistry.sol#L68) / [`setPayInfos`](../src/PayRegistry.sol#L82) | Batched variants. | +| [`getPayAmounts`](../src/PayRegistry.sol#L107) | Bulk read for settlement (verifies each pay's deadline ≤ a per-channel `lastPayResolveDeadline`). | +| [`getPayInfo`](../src/PayRegistry.sol#L126) | Single-pay read. | +| `payInfoMap` (auto-getter) | Public mapping accessor. | + +### Events + +`PayInfoUpdate(bytes32 indexed payId, uint256 amount, uint256 resolveDeadline)`. + +### Storage + +```solidity +struct PayInfo { uint256 amount; uint256 resolveDeadline; } +mapping(bytes32 => PayInfo) public payInfoMap; +``` + +--- + +## VirtContractResolver + +[Source](../src/VirtContractResolver.sol) · [Interface](../src/lib/interface/IVirtContractResolver.sol) · **Permanent** + +Materializes off-chain "virtual" contracts on-chain when a dispute requires it. The +virtual address (used in `Condition.virtual_contract_address`) is +`keccak256(code, nonce)`; deployment uses the `CREATE` opcode. + +### External functions + +| Function | Purpose | +|---|---| +| [`deploy`](../src/VirtContractResolver.sol#L20) | Deploy bytecode under the (code, nonce) virtual address; reverts if already deployed. | +| [`resolve`](../src/VirtContractResolver.sol#L40) | Look up the deployed address for a virtual address. | + +### Events + +`Deploy(bytes32 indexed virtAddr)`. + +--- + +## EthPool + +[Source](../src/EthPool.sol) · [Interface](../src/lib/interface/IEthPool.sol) · **Permanent** + +ERC-20-shaped wrapper for native ETH. Used so that `CelerLedger.openChannel` and +`deposit` can pull funds via a uniform `transferFrom` flow regardless of token type. +Etherscan-friendly metadata: `name = "EthInPool"`, `symbol = "EthIP"`, `decimals = 18`. + +### External / public functions + +| Function | Purpose | +|---|---| +| [`deposit`](../src/EthPool.sol#L24) (payable) | Deposit `msg.value` ETH for a receiver. | +| [`withdraw`](../src/EthPool.sol#L35) | Withdraw ETH back to `msg.sender`. | +| [`approve`](../src/EthPool.sol#L44) / [`increaseAllowance`](../src/EthPool.sol#L93) / [`decreaseAllowance`](../src/EthPool.sol#L106) | ERC-20-style allowance management. | +| [`transferFrom`](../src/EthPool.sol#L59) | Pull-based ETH transfer to a payable address. | +| [`transferToCelerWallet`](../src/EthPool.sol#L73) | Specialized transfer that funds a `CelerWallet` wallet directly. | +| [`balanceOf`](../src/EthPool.sol#L119) / [`allowance`](../src/EthPool.sol#L129) | Standard ERC-20 views. | + +### Events + +`Deposit`, `Transfer`, `Approval` (ERC-20 shape). + +--- + +## RouterRegistry + +[Source](../src/RouterRegistry.sol) · [Interface](../src/lib/interface/IRouterRegistry.sol) · **Permanent** + +Optional global registry where relay-router operators advertise their addresses to the +network. Each entry stores the latest registration / refresh `block.number`. + +### External functions + +| Function | Purpose | +|---|---| +| [`registerRouter`](../src/RouterRegistry.sol#L18) | Add `msg.sender` to the registry. Reverts if already present. | +| [`deregisterRouter`](../src/RouterRegistry.sol#L29) | Remove `msg.sender`. | +| [`refreshRouter`](../src/RouterRegistry.sol#L40) | Update the stored block number for `msg.sender`. | + +### Events + +`RouterUpdated(RouterOperation indexed op, address indexed routerAddress)` — +`op ∈ {Add, Remove, Refresh}`. + +--- + +## Ledger libraries + +`CelerLedger.sol` is intentionally thin. The actual channel logic lives in five +libraries under [`src/lib/ledgerlib/`](../src/lib/ledgerlib/), attached to `CelerLedger` +via `using ... for ...`: + +| Library | Responsibility | +|---|---| +| [`LedgerStruct`](../src/lib/ledgerlib/LedgerStruct.sol) | All shared structs and the `ChannelStatus` enum. No logic. | +| [`LedgerOperation`](../src/lib/ledgerlib/LedgerOperation.sol) | Open / deposit / withdraw / settle / snapshot — the bulk of the user-facing flows. | +| [`LedgerChannel`](../src/lib/ledgerlib/LedgerChannel.sol) | View functions and channel-state derivations (balance maps, peer state, withdraw intent, etc.). | +| [`LedgerMigrate`](../src/lib/ledgerlib/LedgerMigrate.sol) | `migrateChannelFrom` / `migrateChannelTo` — peer-controlled version migration. | +| [`LedgerBalanceLimit`](../src/lib/ledgerlib/LedgerBalanceLimit.sol) | Per-token per-channel deposit caps (gate enabled by default). | + +**When debugging or extending channel behavior, the implementation almost always lives +in one of these libraries — not in `CelerLedger.sol`.** + +--- + +## Helpers and mocks + +These contracts are **test fixtures, not production**. They live under `src/` +because Foundry compiles everything in that directory together, but they should +never be deployed to a production network. + +| File | Purpose | +|---|---| +| [`CelerLedgerMock`](../src/CelerLedgerMock.sol) | Test ledger with extra hooks; used in migration tests where two ledger versions must coexist. | +| [`BooleanCondMock`](../src/helper/BooleanCondMock.sol) | Mock condition contract returning a settable boolean outcome. Used in `PayResolver` tests. | +| [`NumericCondMock`](../src/helper/NumericCondMock.sol) | Mock condition contract returning a settable numeric outcome. | +| [`WalletTestHelper`](../src/helper/WalletTestHelper.sol) | Helper for direct `CelerWallet` integration tests. | +| [`ERC20ExampleToken`](../src/helper/ERC20ExampleToken.sol) | Sample ERC-20 used in token-channel tests. | + +The protobuf decoders [`Pb.sol`](../src/lib/data/Pb.sol), +[`PbChain.sol`](../src/lib/data/PbChain.sol), +[`PbEntity.sol`](../src/lib/data/PbEntity.sol) are **auto-generated** by +[`pb3-gen-sol`](https://github.com/celer-network/pb3-gen-sol) from +[`proto/chain.proto`](../src/lib/data/proto/chain.proto) and +[`proto/entity.proto`](../src/lib/data/proto/entity.proto). Do not hand-edit; change +the `.proto` and regenerate. + +--- + +**See also:** [`architecture-summary.md`](architecture-summary.md) · [`README.md`](../README.md) diff --git a/foundry.toml b/foundry.toml index e748e11..2ae5ccd 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,6 +6,8 @@ out = "out" libs = ["lib"] optimizer = true optimizer_runs = 200 +# Allow GasReport tests to write `gas_logs/*.txt` reports. +fs_permissions = [{ access = "write", path = "./gas_logs" }] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/gas_logs/CelerLedger-ERC20.txt b/gas_logs/CelerLedger-ERC20.txt new file mode 100644 index 0000000..16f0bc1 --- /dev/null +++ b/gas_logs/CelerLedger-ERC20.txt @@ -0,0 +1,8 @@ +********** Gas Measurement: CelerLedger ERC20 ********** + +***** Function Calls Gas Used ***** +openChannel() with zero deposit: 324983 +openChannel() with non-zero ERC20 deposits: 488691 +deposit(): 150001 +cooperativeWithdraw(): 100936 +cooperativeSettle(): 111536 diff --git a/gas_logs/CelerLedger-ETH.txt b/gas_logs/CelerLedger-ETH.txt new file mode 100644 index 0000000..29bc007 --- /dev/null +++ b/gas_logs/CelerLedger-ETH.txt @@ -0,0 +1,29 @@ +********** Gas Measurement: CelerLedger ETH ********** + +***** Deploy Gas Used ***** +VirtContractResolver Deploy Gas: 206156 +EthPool Deploy Gas: 558320 +PayRegistry Deploy Gas: 565565 +CelerWallet Deploy Gas: 1147513 +PayResolver Deploy Gas: 1930640 +CelerLedger Deploy Gas: 1920257 + +***** Function Calls Gas Used ***** +openChannel() with zero deposit: 320196 +openChannel() using EthPool and msg.value: 431156 +setBalanceLimits(): 26122 +disableBalanceLimits(): 1688 +enableBalanceLimits(): 32589 +deposit() via msg.value: 78052 +deposit() via EthPool: 91281 +depositInBatch() with 5 deposits: 382069 +intendWithdraw(): 72739 +vetoWithdraw(): 3995 +confirmWithdraw(): 57548 +cooperativeWithdraw(): 104112 +snapshotStates() with one non-null simplex state: 92579 +intendSettle() with a null state: 108974 +intendSettle() with two 2-payment-hashList states: 324509 +clearPays() with 2 payments: 22595 +confirmSettle(): 48721 +cooperativeSettle(): 115705 diff --git a/gas_logs/CelerLedger-Migrate.txt b/gas_logs/CelerLedger-Migrate.txt new file mode 100644 index 0000000..5befe68 --- /dev/null +++ b/gas_logs/CelerLedger-Migrate.txt @@ -0,0 +1,6 @@ +********** Gas Measurement: CelerLedger Migration ********** + +***** Function Calls Gas Used ***** +migrateChannelFrom() an Operable ETH channel: 312572 +migrateChannelFrom() a Settling ETH channel: 332521 +migrateChannelFrom() an Operable ERC20 channel: 312572 diff --git a/gas_logs/EthPool.txt b/gas_logs/EthPool.txt new file mode 100644 index 0000000..d00bced --- /dev/null +++ b/gas_logs/EthPool.txt @@ -0,0 +1,9 @@ +********** Gas Measurement: EthPool ********** + +***** Function Calls Gas Used ***** +deposit(): 20910 +withdraw(): 10060 +approve(): 31759 +transferFrom(): 41948 +increaseAllowance(): 3692 +decreaseAllowance(): 3647 diff --git a/gas_logs/PayResolver.txt b/gas_logs/PayResolver.txt new file mode 100644 index 0000000..3aa5127 --- /dev/null +++ b/gas_logs/PayResolver.txt @@ -0,0 +1,5 @@ +********** Gas Measurement: PayResolver ********** + +***** Function Calls Gas Used ***** +resolvePaymentByConditions(): 104039 +resolvePaymentByVouchedResult(): 128510 diff --git a/gas_logs/VirtContractResolver.txt b/gas_logs/VirtContractResolver.txt new file mode 100644 index 0000000..285a536 --- /dev/null +++ b/gas_logs/VirtContractResolver.txt @@ -0,0 +1,4 @@ +********** Gas Measurement: VirtContractResolver ********** + +***** Function Calls Gas Used ***** +deploy() - BooleanCondMock: 157249 diff --git a/gas_logs/fine_granularity/ClearPays.txt b/gas_logs/fine_granularity/ClearPays.txt new file mode 100644 index 0000000..a85cb1b --- /dev/null +++ b/gas_logs/fine_granularity/ClearPays.txt @@ -0,0 +1,9 @@ +********** Gas Measurement of clearPays() - N pays per following list ********** + +pay number per following payIdList used gas +1 15203 +5 44774 +10 81743 +25 193526 +50 382734 +100 771773 diff --git a/gas_logs/fine_granularity/DepositEthInBatch.txt b/gas_logs/fine_granularity/DepositEthInBatch.txt new file mode 100644 index 0000000..75d0915 --- /dev/null +++ b/gas_logs/fine_granularity/DepositEthInBatch.txt @@ -0,0 +1,9 @@ +********** Gas Measurement of depositInBatch() - ETH ********** + +batch size used gas +1 92848 +5 382035 +10 743775 +25 1830363 +50 3647152 +75 5476464 diff --git a/gas_logs/fine_granularity/IntendSettle-OneState.txt b/gas_logs/fine_granularity/IntendSettle-OneState.txt new file mode 100644 index 0000000..2fab845 --- /dev/null +++ b/gas_logs/fine_granularity/IntendSettle-OneState.txt @@ -0,0 +1,10 @@ +********** Gas Measurement of intendSettle() one state with multi pays ********** + +pay number in head payIdList used gas +1 253618 +5 285770 +10 325574 +25 445424 +50 648519 +100 1065694 +200 1954934 diff --git a/src/CelerLedger.sol b/src/CelerLedger.sol index 9941d75..d840668 100644 --- a/src/CelerLedger.sol +++ b/src/CelerLedger.sol @@ -12,8 +12,14 @@ import "./lib/interface/IPayRegistry.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; /** - * @title CelerLedger wrapper contract - * @notice A wrapper contract using libraries to provide CelerLedger's APIs. + * @title CelerLedger + * @notice Channel state machine and primary user entry point for AgentPay. The + * contract itself is a thin wrapper — the bulk of channel logic lives in the + * libraries under `src/lib/ledgerlib/` (LedgerOperation, LedgerChannel, LedgerMigrate, + * LedgerBalanceLimit) attached via `using ... for ...`. CelerLedger acts as the + * operator of a {ICelerWallet}; cooperative migration to a future ledger version + * is supported via {migrateChannelTo} / {migrateChannelFrom}. + * @dev See {ICelerLedger} for canonical NatSpec on each function. */ contract CelerLedger is ICelerLedger, Ownable { using LedgerOperation for LedgerStruct.Ledger; @@ -24,9 +30,13 @@ contract CelerLedger is ICelerLedger, Ownable { LedgerStruct.Ledger private ledger; /** - * @notice CelerLedger constructor - * @param _ethPool address of ETH pool - * @param _payRegistry address of PayRegistry + * @notice Construct the ledger and wire it to its dependencies. + * @dev Balance limits are enabled by default; configure or disable via the + * owner-only admin functions. + * @param _ethPool Address of the deployed {IEthPool}. + * @param _payRegistry Address of the deployed {IPayRegistry}. + * @param _celerWallet Address of the deployed {ICelerWallet} — this ledger must + * later become its operator (during channel opening or via wallet creation). */ constructor(address _ethPool, address _payRegistry, address _celerWallet) Ownable(msg.sender) { ledger.ethPool = IEthPool(_ethPool); diff --git a/src/CelerLedgerMock.sol b/src/CelerLedgerMock.sol index c1e8000..9b9ef17 100644 --- a/src/CelerLedgerMock.sol +++ b/src/CelerLedgerMock.sol @@ -10,6 +10,14 @@ import "./lib/interface/ICelerWallet.sol"; import "./lib/interface/IEthPool.sol"; import "./lib/interface/IPayRegistry.sol"; +/** + * @title CelerLedgerMock + * @notice **Test-only.** Mock CelerLedger that exposes raw state-mutating helpers + * (`*MockSet`) and minimal stubs of the production API (`openChannel`, `deposit`, + * `intendSettle`, etc.) to set up isolated test scenarios without going through the + * full ledger logic. Used primarily by the migration tests, which require two ledger + * contracts to coexist. **Do not deploy to a production network.** + */ contract CelerLedgerMock { using LedgerChannel for LedgerStruct.Channel; diff --git a/src/CelerWallet.sol b/src/CelerWallet.sol index 0916627..92cd70b 100644 --- a/src/CelerWallet.sol +++ b/src/CelerWallet.sol @@ -8,10 +8,13 @@ import "@openzeppelin/contracts/utils/Pausable.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; /** - * @title CelerWallet contract - * @notice A multi-owner, multi-token, operator-centric wallet designed for CelerLedger. - * This wallet can run independetly and doesn't rely on trust of any external contracts - * even CelerLedger to maximize its security. + * @title CelerWallet + * @notice Multi-owner, multi-token, operator-centric wallet that holds funds for every + * channel in the AgentPay network. Designed as a permanent, audited custodian — it + * has no business logic of its own and does not trust any external contract, + * including CelerLedger. A single global instance is shared across ledger versions, + * and operatorship can be transferred cooperatively to enable channel migration. + * @dev See {ICelerWallet} for canonical NatSpec on each function. */ contract CelerWallet is ICelerWallet, Pausable, Ownable { using SafeERC20 for IERC20; @@ -349,11 +352,12 @@ contract CelerWallet is ICelerWallet, Pausable, Ownable { return false; } - // Expose pause/unpause controls to the owner (replacing old PauserRole) + /// @notice Pause the wallet. Owner-only. Blocks deposits / withdrawals / operator changes. function pause() external onlyOwner { _pause(); } + /// @notice Resume normal operation after a pause. Owner-only. function unpause() external onlyOwner { _unpause(); } diff --git a/src/EthPool.sol b/src/EthPool.sol index 94d6033..9f7d7cb 100644 --- a/src/EthPool.sol +++ b/src/EthPool.sol @@ -5,14 +5,18 @@ import "./lib/interface/IEthPool.sol"; import "./lib/interface/ICelerWallet.sol"; /** - * @title ETH Pool providing an ERC20 like interface - * @notice Implementation of an ERC20 like pool for native ETH. + * @title EthPool + * @notice ERC20-shaped wrapper for native ETH. Lets `ICelerLedger.openChannel` and + * `ICelerLedger.deposit` pull ETH via a uniform `transferFrom` flow regardless of + * the underlying token type. ERC20-like metadata is exposed so that block-explorer + * tooling can monitor the pool as if it were a token contract. + * @dev See {IEthPool} for canonical NatSpec on each function. */ contract EthPool is IEthPool { mapping(address => uint256) private balances; mapping(address => mapping(address => uint256)) private allowed; - // mock ERC20 details to enable etherscan-like tools to monitor EthPool correctly + /// @dev ERC20-shaped metadata; lets etherscan-like tools track EthPool correctly. string public constant name = "EthInPool"; string public constant symbol = "EthIP"; uint8 public constant decimals = 18; diff --git a/src/PayRegistry.sol b/src/PayRegistry.sol index ff7a569..a6c3b7d 100644 --- a/src/PayRegistry.sol +++ b/src/PayRegistry.sol @@ -4,28 +4,29 @@ pragma solidity ^0.8.20; import "./lib/interface/IPayRegistry.sol"; /** - * @title Pay Registry contract - * @notice Implementation of a global registry to record payment results reported by different PayResolvers. + * @title PayRegistry + * @notice Append-only global record of resolved conditional-payment results. Pay ids + * are namespaced by setter address (`payId = keccak256(payHash, msg.sender)`), so + * only the {PayResolver} version explicitly designated by a payment's source can + * produce a matching entry for that payment. + * @dev See {IPayRegistry} for canonical NatSpec on each function. */ contract PayRegistry is IPayRegistry { + /// @dev Per-pay registry entry. Stored under the namespaced `payId`. struct PayInfo { uint256 amount; uint256 resolveDeadline; } - // bytes32 payId => PayInfo payInfo + /// @notice `payId → (amount, resolveDeadline)`. Public auto-getter. mapping(bytes32 => PayInfo) public payInfoMap; - /** - * @notice Calculate pay id - * @param _payHash hash of serialized condPay - * @param _setter payment info setter, i.e. pay resolver - * @return calculated pay id - */ + /// @inheritdoc IPayRegistry function calculatePayId(bytes32 _payHash, address _setter) public pure returns (bytes32) { return keccak256(abi.encodePacked(_payHash, _setter)); } + /// @inheritdoc IPayRegistry function setPayAmount(bytes32 _payHash, uint256 _amt) external { bytes32 payId = calculatePayId(_payHash, msg.sender); PayInfo storage payInfo = payInfoMap[payId]; @@ -34,6 +35,7 @@ contract PayRegistry is IPayRegistry { emit PayInfoUpdate(payId, _amt, payInfo.resolveDeadline); } + /// @inheritdoc IPayRegistry function setPayDeadline(bytes32 _payHash, uint256 _deadline) external { bytes32 payId = calculatePayId(_payHash, msg.sender); PayInfo storage payInfo = payInfoMap[payId]; @@ -42,6 +44,7 @@ contract PayRegistry is IPayRegistry { emit PayInfoUpdate(payId, payInfo.amount, _deadline); } + /// @inheritdoc IPayRegistry function setPayInfo(bytes32 _payHash, uint256 _amt, uint256 _deadline) external { bytes32 payId = calculatePayId(_payHash, msg.sender); PayInfo storage payInfo = payInfoMap[payId]; @@ -51,6 +54,7 @@ contract PayRegistry is IPayRegistry { emit PayInfoUpdate(payId, _amt, _deadline); } + /// @inheritdoc IPayRegistry function setPayAmounts(bytes32[] calldata _payHashes, uint256[] calldata _amts) external { require(_payHashes.length == _amts.length, "Lengths do not match"); @@ -65,6 +69,7 @@ contract PayRegistry is IPayRegistry { } } + /// @inheritdoc IPayRegistry function setPayDeadlines(bytes32[] calldata _payHashes, uint256[] calldata _deadlines) external { require(_payHashes.length == _deadlines.length, "Lengths do not match"); @@ -79,6 +84,7 @@ contract PayRegistry is IPayRegistry { } } + /// @inheritdoc IPayRegistry function setPayInfos(bytes32[] calldata _payHashes, uint256[] calldata _amts, uint256[] calldata _deadlines) external { @@ -96,14 +102,7 @@ contract PayRegistry is IPayRegistry { } } - /** - * @notice Get the amounts of a list of queried pays - * @dev pay results must have been unchangable before calling this function. - * This API is for CelerLedger - * @param _payIds ids of queried pays - * @param _lastPayResolveDeadline the last pay resolve deadline of all queried pays - * @return queried pay amounts - */ + /// @inheritdoc IPayRegistry function getPayAmounts(bytes32[] calldata _payIds, uint256 _lastPayResolveDeadline) external view @@ -123,6 +122,7 @@ contract PayRegistry is IPayRegistry { return amounts; } + /// @inheritdoc IPayRegistry function getPayInfo(bytes32 _payId) external view returns (uint256, uint256) { PayInfo storage payInfo = payInfoMap[_payId]; return (payInfo.amount, payInfo.resolveDeadline); diff --git a/src/PayResolver.sol b/src/PayResolver.sol index afee6eb..84fc9d9 100644 --- a/src/PayResolver.sol +++ b/src/PayResolver.sol @@ -13,34 +13,37 @@ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; /** - * @title Pay Resolver contract - * @notice Payment resolver with different payment resolving logics. + * @title PayResolver + * @notice On-chain logic for resolving conditional payments. Versioned: each payment + * pins the resolver address it trusts (field 8 of `ConditionalPay`), and the resolver + * address is mixed into the pay id (`payId = keccak256(payHash, resolverAddress)`) + * so a result is bound to the exact resolver version the payment source designated. + * @dev See {IPayResolver} for canonical NatSpec on the external API. Resolution rules: + * HASH_LOCK conditions are present only to gate multi-hop secret reveal — they do not + * affect the transfer amount, and are always required to be true. A payment with no + * condition or only true hash-locks resolves to the max transfer amount. */ contract PayResolver is IPayResolver { using ECDSA for bytes32; using MessageHashUtils for bytes32; + /// @notice Registry where resolved amounts are recorded. IPayRegistry public payRegistry; + + /// @notice Resolver used to materialize virtual condition contracts on demand. IVirtContractResolver public virtResolver; /** - * @notice Pay registry constructor - * @param _registryAddr address of pay registry - * @param _virtResolverAddr address of virtual contract resolver + * @notice Construct the resolver and pin its dependencies. + * @param _registryAddr Address of the deployed {IPayRegistry}. + * @param _virtResolverAddr Address of the deployed {IVirtContractResolver}. */ constructor(address _registryAddr, address _virtResolverAddr) { payRegistry = IPayRegistry(_registryAddr); virtResolver = IVirtContractResolver(_virtResolverAddr); } - /** - * @notice Resolve a payment by onchain getting its condition outcomes - * @dev HASH_LOCK should only be used for establishing multi-hop payments, - * and is always required to be true for all transfer function logic types. - * a pay with no condition or only true HASH_LOCK conditions will use max transfer amount. - * The preimage order should align at the order of HASH_LOCK conditions in condition array. - * @param _resolvePayRequest bytes of PbChain.ResolvePayByConditionsRequest - */ + /// @inheritdoc IPayResolver function resolvePaymentByConditions(bytes calldata _resolvePayRequest) external { PbChain.ResolvePayByConditionsRequest memory resolvePayRequest = PbChain.decResolvePayByConditionsRequest(_resolvePayRequest); @@ -64,10 +67,7 @@ contract PayResolver is IPayResolver { _resolvePayment(pay, payHash, amount); } - /** - * @notice Resolve a payment by submitting an offchain vouched result - * @param _vouchedPayResult bytes of PbEntity.VouchedCondPayResult - */ + /// @inheritdoc IPayResolver function resolvePaymentByVouchedResult(bytes calldata _vouchedPayResult) external { PbEntity.VouchedCondPayResult memory vouchedPayResult = PbEntity.decVouchedCondPayResult(_vouchedPayResult); PbEntity.CondPayResult memory payResult = PbEntity.decCondPayResult(vouchedPayResult.condPayResult); diff --git a/src/RouterRegistry.sol b/src/RouterRegistry.sol index 12e9698..ad12a85 100644 --- a/src/RouterRegistry.sol +++ b/src/RouterRegistry.sol @@ -4,12 +4,15 @@ pragma solidity ^0.8.20; import "./lib/interface/IRouterRegistry.sol"; /** - * @title Router Registry contract for external routers to join the Celer Network - * @notice Implementation of a global registry to enable external routers to join + * @title RouterRegistry + * @notice Optional global registry where relay-router operators advertise themselves + * to the AgentPay network. Each registered router stores the latest registration + * or refresh `block.number`; off-chain consumers may use this for liveness signaling + * and router discovery. + * @dev See {IRouterRegistry} for canonical NatSpec on each function. */ contract RouterRegistry is IRouterRegistry { - // mapping to store the registered routers address as key - // and the lastest registered/refreshed block number as value + /// @notice Registered router addresses → most recent register/refresh block number. mapping(address => uint256) public routerInfo; /** diff --git a/src/VirtContractResolver.sol b/src/VirtContractResolver.sol index f4780d2..1f59303 100644 --- a/src/VirtContractResolver.sol +++ b/src/VirtContractResolver.sol @@ -4,19 +4,17 @@ pragma solidity ^0.8.20; import "./lib/interface/IVirtContractResolver.sol"; /** - * @title Virtual Contract Resolver contract - * @notice Implementation of the Virtual Contract Resolver. - * @dev this resolver establishes the mapping from off-chain address to on-chain address + * @title VirtContractResolver + * @notice Materializes off-chain ("virtual") contracts on-chain when a dispute requires + * them. Maps a deterministic virtual address — `keccak256(code, nonce)` — to the real + * on-chain address produced by deploying the bytecode via the `CREATE` opcode. + * @dev See {IVirtContractResolver} for canonical NatSpec on the external API. */ contract VirtContractResolver is IVirtContractResolver { + /// @dev `keccak256(code, nonce) → deployed address`. mapping(bytes32 => address) virtToRealMap; - /** - * @notice Deploy virtual contract to an on-chain address - * @param _code bytes of virtual contract code - * @param _nonce nonce associated to virtual contract code - * @return true if deployment succeeds - */ + /// @inheritdoc IVirtContractResolver function deploy(bytes calldata _code, uint256 _nonce) external returns (bool) { bytes32 virtAddr = keccak256(abi.encodePacked(_code, _nonce)); bytes memory c = _code; @@ -32,11 +30,7 @@ contract VirtContractResolver is IVirtContractResolver { return true; } - /** - * @notice look up the deployed address of a virtual address - * @param _virtAddr the virtual address to be looked up - * @return the deployed address of the input virtual address - */ + /// @inheritdoc IVirtContractResolver function resolve(bytes32 _virtAddr) external view returns (address) { require(virtToRealMap[_virtAddr] != address(0), "Nonexistent virtual address"); return virtToRealMap[_virtAddr]; diff --git a/src/helper/BooleanCondMock.sol b/src/helper/BooleanCondMock.sol index 3c81dd8..e89f0e3 100644 --- a/src/helper/BooleanCondMock.sol +++ b/src/helper/BooleanCondMock.sol @@ -3,8 +3,14 @@ pragma solidity ^0.8.20; import "../lib/interface/IBooleanCond.sol"; +/** + * @title BooleanCondMock + * @notice **Test-only.** Minimal {IBooleanCond} that always reports finalized and + * decodes its outcome straight from the query bytes. Used by PayResolver tests to + * exercise condition-evaluation paths. **Do not deploy to a production network.** + */ contract BooleanCondMock is IBooleanCond { - function isFinalized(bytes calldata /* _query */) external pure returns (bool) { + function isFinalized(bytes calldata /* _query */ ) external pure returns (bool) { return true; } @@ -18,8 +24,10 @@ contract BooleanCondMock is IBooleanCond { } uint256 v; - assembly { v := mload(add(_b, 32)) } // load all 32bytes to v - v = v >> (8 * (32 - _b.length)); // only first _b.length is valid + assembly { + v := mload(add(_b, 32)) + } // load all 32bytes to v + v = v >> (8 * (32 - _b.length)); // only first _b.length is valid return v != 0; } -} \ No newline at end of file +} diff --git a/src/helper/ERC20ExampleToken.sol b/src/helper/ERC20ExampleToken.sol index bb02d97..b4b9d39 100644 --- a/src/helper/ERC20ExampleToken.sol +++ b/src/helper/ERC20ExampleToken.sol @@ -5,10 +5,10 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /** - * @title SimpleToken - * @notice Very simple ERC20 Token example, where all tokens are pre-assigned to the creator. - * Note they can later distribute these tokens as they wish using `transfer` and other - * `ERC20` functions. + * @title ERC20ExampleToken + * @notice **Test-only.** Simple ERC-20 token whose entire supply is minted to the + * deployer. Used to fund ERC-20 channels in CelerLedger tests. **Do not deploy to a + * production network.** */ contract ERC20ExampleToken is ERC20 { uint8 public constant DECIMALS = 18; @@ -17,7 +17,7 @@ contract ERC20ExampleToken is ERC20 { /** * @notice Constructor that gives msg.sender all of existing tokens. */ - constructor () ERC20("ERC20ExampleToken", "EET20") { + constructor() ERC20("ERC20ExampleToken", "EET20") { _mint(msg.sender, INITIAL_SUPPLY); } -} \ No newline at end of file +} diff --git a/src/helper/NumericCondMock.sol b/src/helper/NumericCondMock.sol index 09d3a52..866303b 100644 --- a/src/helper/NumericCondMock.sol +++ b/src/helper/NumericCondMock.sol @@ -3,8 +3,14 @@ pragma solidity ^0.8.20; import "../lib/interface/INumericCond.sol"; +/** + * @title NumericCondMock + * @notice **Test-only.** Minimal {INumericCond} that always reports finalized and + * decodes its outcome straight from the query bytes. Numeric counterpart to + * {BooleanCondMock}. **Do not deploy to a production network.** + */ contract NumericCondMock is INumericCond { - function isFinalized(bytes calldata /* _query */) external pure returns (bool) { + function isFinalized(bytes calldata /* _query */ ) external pure returns (bool) { return true; } @@ -18,9 +24,11 @@ contract NumericCondMock is INumericCond { } uint256 v; - assembly { v := mload(add(_b, 32)) } // load all 32bytes to v - v = v >> (8 * (32 - _b.length)); // only first _b.length is valid - + assembly { + v := mload(add(_b, 32)) + } // load all 32bytes to v + v = v >> (8 * (32 - _b.length)); // only first _b.length is valid + return v; } -} \ No newline at end of file +} diff --git a/src/helper/WalletTestHelper.sol b/src/helper/WalletTestHelper.sol index 86692aa..6ad6c72 100644 --- a/src/helper/WalletTestHelper.sol +++ b/src/helper/WalletTestHelper.sol @@ -3,6 +3,12 @@ pragma solidity ^0.8.20; import "../lib/interface/ICelerWallet.sol"; +/** + * @title WalletTestHelper + * @notice **Test-only.** Thin wrapper used to create CelerWallet wallets from a + * contract (rather than an EOA) so tests can exercise non-EOA owner / operator + * paths and verify event emission. **Do not deploy to a production network.** + */ contract WalletTestHelper { event NewWallet(bytes32 walletId); @@ -12,15 +18,9 @@ contract WalletTestHelper { wallet = ICelerWallet(_celerWallet); } - function create( - address[] memory _owners, - address _operator, - uint256 _nonce - ) - public - { + function create(address[] memory _owners, address _operator, uint256 _nonce) public { bytes32 n = keccak256(abi.encodePacked(_nonce)); bytes32 walletId = wallet.create(_owners, _operator, n); emit NewWallet(walletId); } -} \ No newline at end of file +} diff --git a/src/lib/data/Pb.sol b/src/lib/data/Pb.sol index 0d356a5..5e394ee 100644 --- a/src/lib/data/Pb.sol +++ b/src/lib/data/Pb.sol @@ -1,7 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -// runtime proto sol library +/** + * @title Pb + * @notice Runtime support library for Solidity protobuf decoders generated by + * [`pb3-gen-sol`](https://github.com/celer-network/pb3-gen-sol). Provides a + * `Buffer` cursor type and primitive read helpers (varint, length-delimited bytes, + * fixed widths) used by the generated `Pb*` libraries in this directory. + * @dev Hand-written; the generated `PbChain`/`PbEntity` libraries depend on it. + */ library Pb { enum WireType { Varint, diff --git a/src/lib/data/PbChain.sol b/src/lib/data/PbChain.sol index 9953c95..a30a6ab 100644 --- a/src/lib/data/PbChain.sol +++ b/src/lib/data/PbChain.sol @@ -5,6 +5,14 @@ pragma solidity ^0.8.20; import "./Pb.sol"; +/** + * @title PbChain + * @notice **Auto-generated.** Solidity decoders for the protobuf messages defined in + * [`proto/chain.proto`](proto/chain.proto). These messages are used only by on-chain + * contracts (channel-open requests, settle/withdraw requests, migration requests, + * resolve-pay requests). Do not hand-edit — change `chain.proto` and regenerate + * via [`pb3-gen-sol`](https://github.com/celer-network/pb3-gen-sol). + */ library PbChain { using Pb for Pb.Buffer; // so we can call Pb funcs on Buffer obj diff --git a/src/lib/data/PbEntity.sol b/src/lib/data/PbEntity.sol index dd941c0..2db33e3 100644 --- a/src/lib/data/PbEntity.sol +++ b/src/lib/data/PbEntity.sol @@ -5,6 +5,14 @@ pragma solidity ^0.8.20; import "./Pb.sol"; +/** + * @title PbEntity + * @notice **Auto-generated.** Solidity decoders for the protobuf messages defined in + * [`proto/entity.proto`](proto/entity.proto). These messages are shared between + * on-chain contracts and off-chain protocol code (simplex states, conditional pays, + * conditions, transfer functions). Do not hand-edit — change `entity.proto` and + * regenerate via [`pb3-gen-sol`](https://github.com/celer-network/pb3-gen-sol). + */ library PbEntity { using Pb for Pb.Buffer; // so we can call Pb funcs on Buffer obj diff --git a/src/lib/interface/IBooleanCond.sol b/src/lib/interface/IBooleanCond.sol index 9ba228b..afaff00 100644 --- a/src/lib/interface/IBooleanCond.sol +++ b/src/lib/interface/IBooleanCond.sol @@ -3,9 +3,27 @@ pragma solidity ^0.8.20; /** * @title BooleanCond interface + * @notice Standard interface for app contracts whose outcome resolves to a boolean. + * @dev Implemented by deployed condition contracts and (when materialized on-chain) by + * virtual condition contracts. PayResolver invokes these methods during conditional + * payment resolution; the `_query` payload is supplied via `Condition.args_query_*` + * fields and is opaque to the AgentPay core. See the App Contracts and Protocols page + * in the architecture docs for the broader integration model. */ interface IBooleanCond { + /** + * @notice Check whether the condition's outcome is finalized and may be queried. + * @dev `getOutcome` must be safe to call once this returns true; PayResolver + * reverts payment resolution if any condition is not yet finalized. + * @param _query Condition-specific query payload. + * @return True if the outcome is finalized. + */ function isFinalized(bytes calldata _query) external view returns (bool); + /** + * @notice Return the boolean outcome of the finalized condition. + * @param _query Condition-specific query payload. + * @return The condition's boolean outcome. + */ function getOutcome(bytes calldata _query) external view returns (bool); } diff --git a/src/lib/interface/ICelerLedger.sol b/src/lib/interface/ICelerLedger.sol index c9f1e8a..2123aa2 100644 --- a/src/lib/interface/ICelerLedger.sol +++ b/src/lib/interface/ICelerLedger.sol @@ -6,49 +6,135 @@ import "../ledgerlib/LedgerStruct.sol"; /** * @title CelerLedger interface - * @dev any changes in this interface must be synchronized to corresponding libraries - * @dev events in this interface must be exactly same in corresponding used libraries + * @notice Channel state machine and primary user entry point for AgentPay. CelerLedger + * acts as the operator of a {ICelerWallet} and exposes the on-chain APIs for opening, + * funding, withdrawing from, settling, and migrating payment channels. + * @dev The interface is grouped by the library that implements each section in + * `src/lib/ledgerlib/` (LedgerOperation, LedgerChannel, LedgerBalanceLimit, + * LedgerMigrate). Any change here must be mirrored in the corresponding library, and + * events declared here must match the library declarations bit-for-bit. */ interface ICelerLedger { + // ========================================================================= + // LedgerOperation — channel lifecycle, deposit, withdraw, settle + // ========================================================================= + /** - * LedgerOperation related functions and events ********* + * @notice Open a fully-funded channel from a co-signed initializer in one tx. + * @dev Atomically creates a wallet in the underlying CelerWallet, derives + * `channelId = keccak256(walletAddr, ledgerAddr, keccak256(initializer))`, and + * pulls the initial deposits. Native value is allowed via `msg.value`. + * @param _openChannelRequest ABI-encoded `PbChain.OpenChannelRequest` message. */ function openChannel(bytes calldata _openChannelRequest) external payable; + /** + * @notice Deposit ETH or ERC-20 tokens into an existing channel. + * @dev Anyone can deposit; total credited is `msg.value + _transferFromAmount`. + * For ERC-20 channels, `msg.value` must be 0 and the depositor must have approved + * this contract for `_transferFromAmount`. For ETH channels, `_transferFromAmount` + * is pulled from {IEthPool}. + * @param _channelId Channel to credit. + * @param _receiver Peer credited with the deposit. + * @param _transferFromAmount Amount to pull via `transferFrom` (in addition to `msg.value`). + */ function deposit(bytes32 _channelId, address _receiver, uint256 _transferFromAmount) external payable; + /** + * @notice Batched variant of {deposit} across multiple channels in one tx. + * @param _channelIds Channels to credit. + * @param _receivers Peer per channel credited with the deposit. + * @param _transferFromAmounts Amount per channel pulled via `transferFrom`. + */ function depositInBatch( bytes32[] calldata _channelIds, address[] calldata _receivers, uint256[] calldata _transferFromAmounts ) external; + /** + * @notice Persist co-signed simplex states on-chain as a lightweight checkpoint. + * @dev Useful for snapshotting transferred amounts without closing the channel — + * e.g. before a long offline period. Updates per-peer `seqNum`/`transferOut`. + * @param _signedSimplexStateArray ABI-encoded `PbChain.SignedSimplexStateArray`. + */ function snapshotStates(bytes calldata _signedSimplexStateArray) external; + /** + * @notice Begin a unilateral withdrawal; opens a challenge window. + * @param _channelId Channel to withdraw from. + * @param _amount Amount to withdraw. + * @param _recipientChannelId Optional channel to redirect the funds into; pass + * `bytes32(0)` to withdraw to the caller's address instead. + */ function intendWithdraw(bytes32 _channelId, uint256 _amount, bytes32 _recipientChannelId) external; + /** + * @notice Finalize a unilateral withdrawal after its challenge window has closed. + * @param _channelId Channel whose pending withdraw is being finalized. + */ function confirmWithdraw(bytes32 _channelId) external; + /** + * @notice Counterparty veto of an in-flight unilateral withdrawal. + * @param _channelId Channel whose pending withdraw is being cancelled. + */ function vetoWithdraw(bytes32 _channelId) external; + /** + * @notice Co-signed single-tx withdrawal. + * @dev Skips the challenge window. Supports both withdraw-to-account and + * withdraw-into-another-channel paths via the request's `recipient_channel_id`. + * @param _cooperativeWithdrawRequest ABI-encoded `PbChain.CooperativeWithdrawRequest`. + */ function cooperativeWithdraw(bytes calldata _cooperativeWithdrawRequest) external; + /** + * @notice Begin unilateral settlement using the latest co-signed simplex states. + * @dev Opens a challenge window during which the counterparty may submit newer + * states with higher `seqNum`. Pending pay outcomes are read from + * {IPayRegistry} when `confirmSettle` finalizes. + * @param _signedSimplexStateArray ABI-encoded `PbChain.SignedSimplexStateArray`. + */ function intendSettle(bytes calldata _signedSimplexStateArray) external; + /** + * @notice Settle additional pending pays via a `PayIdList` after `intendSettle`. + * @dev Used for batched multi-pay clearing when a single tx data limit cannot + * carry all pay ids. Each call clears one segment of the linked list. + * @param _channelId Channel being settled. + * @param _peerFrom Peer whose simplex pending list is being walked. + * @param _payIdList ABI-encoded `PbEntity.PayIdList` segment. + */ function clearPays(bytes32 _channelId, address _peerFrom, bytes calldata _payIdList) external; + /** + * @notice Finalize unilateral settlement after the challenge window has closed. + * @param _channelId Channel to close. + */ function confirmSettle(bytes32 _channelId) external; + /** + * @notice Co-signed single-tx channel close. + * @dev Skips the challenge window. The co-signed final balance distribution + * short-circuits all off-chain simplex state — the signature *is* the agreement. + * @param _settleRequest ABI-encoded `PbChain.CooperativeSettleRequest`. + */ function cooperativeSettle(bytes calldata _settleRequest) external; + /// @notice Number of channels currently in the given {LedgerStruct.ChannelStatus}. function getChannelStatusNum(uint256 _channelStatus) external view returns (uint256); + /// @notice Address of the configured {IEthPool}. function getEthPool() external view returns (address); + /// @notice Address of the configured {IPayRegistry}. function getPayRegistry() external view returns (address); + /// @notice Address of the configured {ICelerWallet}. function getCelerWallet() external view returns (address); + /// @notice Emitted on successful {openChannel}. event OpenChannel( bytes32 indexed channelId, uint256 tokenType, @@ -57,20 +143,28 @@ interface ICelerLedger { uint256[2] initialDeposits ); + /// @notice Emitted on successful {deposit} or {depositInBatch}. event Deposit(bytes32 indexed channelId, address[2] peerAddrs, uint256[2] deposits, uint256[2] withdrawals); + /// @notice Emitted on successful {snapshotStates}. event SnapshotStates(bytes32 indexed channelId, uint256[2] seqNums); + /// @notice Emitted when {intendSettle} opens a settlement window. event IntendSettle(bytes32 indexed channelId, uint256[2] seqNums); + /// @notice Emitted on each pay cleared via {intendSettle} / {clearPays}. event ClearOnePay(bytes32 indexed channelId, bytes32 indexed payId, address indexed peerFrom, uint256 amount); + /// @notice Emitted on successful {confirmSettle}. event ConfirmSettle(bytes32 indexed channelId, uint256[2] settleBalance); + /// @notice Emitted when {confirmSettle} cannot finalize and the channel returns to operable. event ConfirmSettleFail(bytes32 indexed channelId); + /// @notice Emitted when {intendWithdraw} opens a withdrawal challenge window. event IntendWithdraw(bytes32 indexed channelId, address indexed receiver, uint256 amount); + /// @notice Emitted on successful {confirmWithdraw}. event ConfirmWithdraw( bytes32 indexed channelId, uint256 withdrawnAmount, @@ -80,8 +174,10 @@ interface ICelerLedger { uint256[2] withdrawals ); + /// @notice Emitted on successful {vetoWithdraw}. event VetoWithdraw(bytes32 indexed channelId); + /// @notice Emitted on successful {cooperativeWithdraw}. event CooperativeWithdraw( bytes32 indexed channelId, uint256 withdrawnAmount, @@ -92,33 +188,53 @@ interface ICelerLedger { uint256 seqNum ); + /// @notice Emitted on successful {cooperativeSettle}. event CooperativeSettle(bytes32 indexed channelId, uint256[2] settleBalance); - /** - * End of LedgerOperation related functions and events ********* - */ - /** - * LedgerChannel related functions and events ********* - */ + // ========================================================================= + // LedgerChannel — view functions and channel-state derivations + // ========================================================================= + + /// @notice Block number after which a settling channel can be confirmed. function getSettleFinalizedTime(bytes32 _channelId) external view returns (uint256); + /// @notice ERC-20 token contract address for this channel (`address(0)` for ETH). function getTokenContract(bytes32 _channelId) external view returns (address); + /// @notice Token type (ETH / ERC20) for this channel. function getTokenType(bytes32 _channelId) external view returns (PbEntity.TokenType); + /// @notice Current channel status. function getChannelStatus(bytes32 _channelId) external view returns (LedgerStruct.ChannelStatus); + /// @notice Latest cooperative-withdraw sequence number used for this channel. function getCooperativeWithdrawSeqNum(bytes32 _channelId) external view returns (uint256); + /// @notice Total balance currently held by this channel across both peers. function getTotalBalance(bytes32 _channelId) external view returns (uint256); + /** + * @notice Per-peer balance breakdown. + * @return addrs Peer addresses (sorted). + * @return deposits Cumulative deposits per peer. + * @return withdrawals Cumulative withdrawals per peer. + */ function getBalanceMap(bytes32 _channelId) external view - returns (address[2] memory, uint256[2] memory, uint256[2] memory); + returns (address[2] memory addrs, uint256[2] memory deposits, uint256[2] memory withdrawals); + /** + * @notice Bundle of fields used by a successor ledger during {migrateChannelFrom}. + * @dev Order: dispute timeout, token type, token address, cooperative-withdraw seq num. + */ function getChannelMigrationArgs(bytes32 _channelId) external view returns (uint256, uint256, address, uint256); + /** + * @notice Per-peer migration snapshot used by {migrateChannelFrom}. + * @dev Tuples are ordered: peer addresses, deposits, withdrawals, simplex seq nums, + * transfer-outs, pending pay-outs. + */ function getPeersMigrationInfo(bytes32 _channelId) external view @@ -131,55 +247,93 @@ interface ICelerLedger { uint256[2] memory ); + /// @notice Configured dispute-challenge window for this channel. function getDisputeTimeout(bytes32 _channelId) external view returns (uint256); + /// @notice Address of the new ledger this channel migrated to (`address(0)` if not migrated). function getMigratedTo(bytes32 _channelId) external view returns (address); + /// @notice Per-peer latest sequence numbers. function getStateSeqNumMap(bytes32 _channelId) external view returns (address[2] memory, uint256[2] memory); + /// @notice Per-peer cumulative transferred-out amounts. function getTransferOutMap(bytes32 _channelId) external view returns (address[2] memory, uint256[2] memory); + /// @notice Per-peer next-list hashes for batched pay clearing during settlement. function getNextPayIdListHashMap(bytes32 _channelId) external view returns (address[2] memory, bytes32[2] memory); + /// @notice Per-peer latest pay-resolve deadlines used during settlement. function getLastPayResolveDeadlineMap(bytes32 _channelId) external view returns (address[2] memory, uint256[2] memory); + /// @notice Per-peer pending pay totals (locked amounts). function getPendingPayOutMap(bytes32 _channelId) external view returns (address[2] memory, uint256[2] memory); - function getWithdrawIntent(bytes32 _channelId) external view returns (address, uint256, uint256, bytes32); /** - * End of LedgerChannel related functions and events ********* + * @notice Active unilateral withdrawal intent for a channel, if any. + * @return receiver Withdrawer address. + * @return amount Pending withdraw amount. + * @return requestTime Block number when {intendWithdraw} fired. + * @return recipientChannelId Optional redirect target. */ + function getWithdrawIntent(bytes32 _channelId) + external + view + returns (address receiver, uint256 amount, uint256 requestTime, bytes32 recipientChannelId); + + // ========================================================================= + // LedgerBalanceLimit — per-channel deposit caps + // ========================================================================= /** - * LedgerBalanceLimit related functions and events ********* + * @notice Set the per-channel maximum deposit for one or more tokens. + * @dev Owner-only. Limits are enforced by {deposit} / {openChannel} when enabled. + * @param _tokenAddrs Token addresses (`address(0)` for ETH). + * @param _limits New limits, indexed identically to `_tokenAddrs`. */ function setBalanceLimits(address[] calldata _tokenAddrs, uint256[] calldata _limits) external; + /// @notice Disable balance-limit enforcement for all tokens (owner only). function disableBalanceLimits() external; + /// @notice Re-enable balance-limit enforcement for all tokens (owner only). function enableBalanceLimits() external; + /// @notice Configured per-channel limit for a specific token (`address(0)` for ETH). function getBalanceLimit(address _tokenAddr) external view returns (uint256); + /// @notice Whether balance-limit enforcement is currently enabled globally. function getBalanceLimitsEnabled() external view returns (bool); - /** - * End of LedgerBalanceLimit related functions and events ********* - */ + + // ========================================================================= + // LedgerMigrate — peer-controlled cross-version migration + // ========================================================================= /** - * LedgerMigrate related functions and events ********* + * @notice Called on the *old* ledger by a new ledger to begin migration. + * @dev Verifies the co-signed migration request, transfers wallet operatorship to + * the new ledger, and marks the channel `Migrated`. + * @param _migrationRequest ABI-encoded `PbChain.ChannelMigrationRequest`. + * @return The migrated channel id. */ function migrateChannelTo(bytes calldata _migrationRequest) external returns (bytes32); + /** + * @notice Called on the *new* ledger to import a channel from a previous ledger version. + * @dev Orchestrates the migration end-to-end: validates the co-signed request, + * invokes `migrateChannelTo` on the old ledger, verifies operatorship has been + * transferred, and imports the channel state. The channel returns to `Operable` + * on the new ledger. + * @param _fromLedgerAddr Address of the previous ledger version. + * @param _migrationRequest ABI-encoded `PbChain.ChannelMigrationRequest`. + */ function migrateChannelFrom(address _fromLedgerAddr, bytes calldata _migrationRequest) external; + /// @notice Emitted on the old ledger when a channel is migrated out. event MigrateChannelTo(bytes32 indexed channelId, address indexed newLedgerAddr); + /// @notice Emitted on the new ledger when a channel is migrated in. event MigrateChannelFrom(bytes32 indexed channelId, address indexed oldLedgerAddr); - /** - * End of LedgerMigrate related functions and events ********* - */ } diff --git a/src/lib/interface/ICelerWallet.sol b/src/lib/interface/ICelerWallet.sol index ce52fc4..dd7bdc5 100644 --- a/src/lib/interface/ICelerWallet.sol +++ b/src/lib/interface/ICelerWallet.sol @@ -3,16 +3,61 @@ pragma solidity ^0.8.20; /** * @title CelerWallet interface + * @notice Multi-owner, multi-token wallet that holds funds for every channel in the + * AgentPay network. A CelerWallet has two distinct roles: a set of *owners* (the + * channel peers, recipients of withdrawals) and a single *operator* (typically a + * CelerLedger contract version) authorized to move funds. Operatorship is the pivot + * point for cooperative migration to a new ledger version — see + * {transferOperatorship} and {proposeNewOperator}. */ interface ICelerWallet { + /** + * @notice Create a new wallet. + * @dev `walletId = keccak256(walletContract, msg.sender, _nonce)`. Reverts if the + * derived id is already in use or if `_operator == address(0)`. + * @param _owners Owners of the wallet (typically the two channel peers). + * @param _operator Initial operator authorized to move funds. + * @param _nonce Caller-supplied nonce, used in the wallet-id derivation. + * @return Id of the created wallet. + */ function create(address[] calldata _owners, address _operator, bytes32 _nonce) external returns (bytes32); + /** + * @notice Deposit `msg.value` ETH into a wallet's ETH balance. + * @param _walletId Wallet to deposit into. + */ function depositETH(bytes32 _walletId) external payable; + /** + * @notice Deposit ERC-20 tokens into a wallet (caller must have approved this contract). + * @param _walletId Wallet to deposit into. + * @param _tokenAddress ERC-20 token address. + * @param _amount Amount of tokens to pull from `msg.sender`. + */ function depositERC20(bytes32 _walletId, address _tokenAddress, uint256 _amount) external; + /** + * @notice Withdraw funds from a wallet to a receiver who is also an owner. + * @dev Caller must be the wallet's operator. ETH is sent via raw `call`; if the + * ledger ever permits non-EOA peers, the ledger should layer a withdraw-pattern + * on top to avoid griefing. + * @param _walletId Wallet to debit. + * @param _tokenAddress Token to withdraw (`address(0)` for ETH). + * @param _receiver Beneficiary; must be an owner of the wallet. + * @param _amount Amount to withdraw. + */ function withdraw(bytes32 _walletId, address _tokenAddress, address _receiver, uint256 _amount) external; + /** + * @notice Move funds between two wallets sharing the same operator and a common owner. + * @dev Used for off-chain liquidity rebalancing across channels. Both wallets must + * list `_receiver` among their owners. + * @param _fromWalletId Source wallet id. + * @param _toWalletId Destination wallet id. + * @param _tokenAddress Token to move (`address(0)` for ETH). + * @param _receiver Beneficiary owner present in both wallets. + * @param _amount Amount to transfer. + */ function transferToWallet( bytes32 _fromWalletId, bytes32 _toWalletId, @@ -21,30 +66,61 @@ interface ICelerWallet { uint256 _amount ) external; + /** + * @notice Operator transfers operatorship of a wallet to a new operator. + * @dev Migration pivot point: the new operator is typically a newer CelerLedger + * version cooperatively chosen by the peers. + * @param _walletId Wallet whose operatorship moves. + * @param _newOperator New operator address. + */ function transferOperatorship(bytes32 _walletId, address _newOperator) external; + /** + * @notice Manual fallback: wallet owners cooperatively assign a new operator. + * @dev Operatorship changes only when *all* owners have proposed the same address. + * Proposing a different address resets the vote tally. This path bypasses the + * current operator and is intended for use only when the operator contract is + * broken or compromised. + * @param _walletId Wallet whose operatorship is being proposed for change. + * @param _newOperator Proposed new operator. + */ function proposeNewOperator(bytes32 _walletId, address _newOperator) external; + /** + * @notice Emergency token recovery (callable only when the contract is paused). + * @param _tokenAddress Token to drain (`address(0)` for ETH). + * @param _receiver Recipient of drained funds. + * @param _amount Amount to drain. + */ function drainToken(address _tokenAddress, address _receiver, uint256 _amount) external; + /// @notice Owners of `_walletId`. function getWalletOwners(bytes32 _walletId) external view returns (address[] memory); + /// @notice Operator of `_walletId`. function getOperator(bytes32 _walletId) external view returns (address); + /// @notice Token balance of `_walletId` for `_tokenAddress` (`address(0)` for ETH). function getBalance(bytes32 _walletId, address _tokenAddress) external view returns (uint256); + /// @notice Currently proposed new operator for `_walletId`, if any. function getProposedNewOperator(bytes32 _walletId) external view returns (address); + /// @notice Whether `_owner` has voted for the current proposed new operator. function getProposalVote(bytes32 _walletId, address _owner) external view returns (bool); + /// @notice Emitted on wallet creation. event CreateWallet(bytes32 indexed walletId, address[] indexed owners, address indexed operator); + /// @notice Emitted on every successful deposit. event DepositToWallet(bytes32 indexed walletId, address indexed tokenAddress, uint256 amount); + /// @notice Emitted on every successful withdrawal. event WithdrawFromWallet( bytes32 indexed walletId, address indexed tokenAddress, address indexed receiver, uint256 amount ); + /// @notice Emitted on inter-wallet transfers via {transferToWallet}. event TransferToWallet( bytes32 indexed fromWalletId, bytes32 indexed toWalletId, @@ -53,9 +129,12 @@ interface ICelerWallet { uint256 amount ); + /// @notice Emitted whenever a wallet's operator changes (either path). event ChangeOperator(bytes32 indexed walletId, address indexed oldOperator, address indexed newOperator); + /// @notice Emitted when an owner proposes / votes on a new operator. event ProposeNewOperator(bytes32 indexed walletId, address indexed newOperator, address indexed proposer); + /// @notice Emitted on emergency drains via {drainToken}. event DrainToken(address indexed tokenAddress, address indexed receiver, uint256 amount); } diff --git a/src/lib/interface/IEthPool.sol b/src/lib/interface/IEthPool.sol index 252f7bb..db2a890 100644 --- a/src/lib/interface/IEthPool.sol +++ b/src/lib/interface/IEthPool.sol @@ -3,32 +3,87 @@ pragma solidity ^0.8.20; /** * @title EthPool interface + * @notice ERC20-shaped wrapper for native ETH. Lets the rest of the AgentPay system — + * in particular {ICelerLedger.openChannel} and {ICelerLedger.deposit} — pull ETH via a + * uniform `transferFrom` flow regardless of the underlying token type. The pool + * exposes ERC20-style allowance semantics so a depositor can pre-approve the ledger + * to draw funds. */ interface IEthPool { + /** + * @notice Deposit `msg.value` ETH for `_receiver`. + * @param _receiver Account credited with the deposited ETH. + */ function deposit(address _receiver) external payable; + /** + * @notice Withdraw `_value` ETH from `msg.sender`'s pool balance back to its address. + * @param _value Amount of ETH to withdraw. + */ function withdraw(uint256 _value) external; + /** + * @notice Approve `_spender` to draw up to `_value` of `msg.sender`'s ETH balance. + * @param _spender Address authorized to spend. + * @param _value Maximum spendable amount. + * @return Always true (ERC20-style); reverts on invalid input. + */ function approve(address _spender, uint256 _value) external returns (bool); + /** + * @notice Transfer `_value` ETH from `_from`'s pool balance to `_to` (off the pool). + * @dev Decrements `allowance(_from, msg.sender)`; emits both `Approval` and + * `Transfer`. + * @param _from Source account inside the pool. + * @param _to Destination address (receives raw ETH). + * @param _value Amount of ETH to transfer. + * @return Always true on success. + */ function transferFrom(address _from, address payable _to, uint256 _value) external returns (bool); + /** + * @notice Transfer ETH from a pool account directly into a CelerWallet, in one call. + * @dev Decrements `allowance(_from, msg.sender)`. Used by {ICelerLedger.openChannel} + * and friends to fund a channel without the user having to first withdraw from + * the pool. + * @param _from Source account inside the pool. + * @param _walletAddr Target {ICelerWallet} address (must accept `depositETH`). + * @param _walletId Target wallet id within `_walletAddr`. + * @param _value Amount of ETH to forward. + * @return Always true on success. + */ function transferToCelerWallet(address _from, address _walletAddr, bytes32 _walletId, uint256 _value) external returns (bool); + /** + * @notice Increase `_spender`'s allowance from `msg.sender` by `_addedValue`. + * @param _spender Authorized spender. + * @param _addedValue Increment. + * @return Always true on success. + */ function increaseAllowance(address _spender, uint256 _addedValue) external returns (bool); + /** + * @notice Decrease `_spender`'s allowance from `msg.sender` by `_subtractedValue`. + * @param _spender Authorized spender. + * @param _subtractedValue Decrement. + * @return Always true on success. + */ function decreaseAllowance(address _spender, uint256 _subtractedValue) external returns (bool); + /// @notice Pool balance of `_owner`. function balanceOf(address _owner) external view returns (uint256); + /// @notice Remaining allowance `_owner` has granted `_spender`. function allowance(address _owner, address _spender) external view returns (uint256); + /// @notice Emitted when ETH is deposited into the pool. event Deposit(address indexed receiver, uint256 value); - // transfer from "from" account inside EthPool to real "to" address outside EthPool + /// @notice Emitted when ETH leaves the pool (`from` is the pool balance debited; `to` receives raw ETH). event Transfer(address indexed from, address indexed to, uint256 value); + /// @notice Emitted on allowance changes (matches ERC20 semantics). event Approval(address indexed owner, address indexed spender, uint256 value); } diff --git a/src/lib/interface/INumericCond.sol b/src/lib/interface/INumericCond.sol index 6420578..2533e13 100644 --- a/src/lib/interface/INumericCond.sol +++ b/src/lib/interface/INumericCond.sol @@ -3,9 +3,23 @@ pragma solidity ^0.8.20; /** * @title NumericCond interface + * @notice Standard interface for app contracts whose outcome resolves to a uint256. + * @dev Numeric counterpart to {IBooleanCond}. Used with the NUMERIC_ADD / NUMERIC_MAX + * / NUMERIC_MIN transfer functions, where the per-condition `getOutcome` values are + * combined to compute the final payment amount. */ interface INumericCond { + /** + * @notice Check whether the condition's outcome is finalized and may be queried. + * @param _query Condition-specific query payload. + * @return True if the outcome is finalized. + */ function isFinalized(bytes calldata _query) external view returns (bool); + /** + * @notice Return the numeric outcome of the finalized condition. + * @param _query Condition-specific query payload. + * @return The condition's numeric outcome. + */ function getOutcome(bytes calldata _query) external view returns (uint256); } diff --git a/src/lib/interface/IPayRegistry.sol b/src/lib/interface/IPayRegistry.sol index 09c655a..2d1abeb 100644 --- a/src/lib/interface/IPayRegistry.sol +++ b/src/lib/interface/IPayRegistry.sol @@ -3,29 +3,89 @@ pragma solidity ^0.8.20; /** * @title PayRegistry interface + * @notice Append-only global record of resolved conditional-payment results. Each + * payment is keyed by `payId = keccak256(payHash, setterAddress)`, where `setterAddress` + * is `msg.sender` of the write — typically a {PayResolver} version. This setter + * namespacing means writes are tamper-resistant: only the resolver explicitly + * designated by the payment source (field 8 of `ConditionalPay`) can produce a + * matching `payId` for that payment. */ interface IPayRegistry { + /** + * @notice Compute the canonical pay id from a pay hash and setter address. + * @param _payHash `keccak256(serializedConditionalPay)`. + * @param _setter Address authorized to set this payment's info (typically a PayResolver). + * @return The pay id used as the registry key. + */ function calculatePayId(bytes32 _payHash, address _setter) external pure returns (bytes32); + /** + * @notice Set the resolved amount for a payment under `msg.sender`'s namespace. + * @param _payHash `keccak256(serializedConditionalPay)`. + * @param _amt Resolved payment amount. + */ function setPayAmount(bytes32 _payHash, uint256 _amt) external; + /** + * @notice Set the resolve deadline for a payment under `msg.sender`'s namespace. + * @param _payHash `keccak256(serializedConditionalPay)`. + * @param _deadline Block number after which the result is finalized. + */ function setPayDeadline(bytes32 _payHash, uint256 _deadline) external; + /** + * @notice Set both the amount and the deadline for a payment in one call. + * @param _payHash `keccak256(serializedConditionalPay)`. + * @param _amt Resolved payment amount. + * @param _deadline Block number after which the result is finalized. + */ function setPayInfo(bytes32 _payHash, uint256 _amt, uint256 _deadline) external; + /** + * @notice Batched variant of {setPayAmount}. + * @param _payHashes List of pay hashes. + * @param _amts Resolved amounts (must match `_payHashes` in length). + */ function setPayAmounts(bytes32[] calldata _payHashes, uint256[] calldata _amts) external; + /** + * @notice Batched variant of {setPayDeadline}. + * @param _payHashes List of pay hashes. + * @param _deadlines Resolve deadlines (must match `_payHashes` in length). + */ function setPayDeadlines(bytes32[] calldata _payHashes, uint256[] calldata _deadlines) external; + /** + * @notice Batched variant of {setPayInfo}. + * @param _payHashes List of pay hashes. + * @param _amts Resolved amounts. + * @param _deadlines Resolve deadlines. + */ function setPayInfos(bytes32[] calldata _payHashes, uint256[] calldata _amts, uint256[] calldata _deadlines) external; + /** + * @notice Bulk-read amounts for use during channel settlement. + * @dev Each pay's stored deadline must be less than or equal to + * `_lastPayResolveDeadline`; otherwise the call reverts. This guards against + * applying not-yet-finalized results during `intendSettle` / `confirmSettle`. + * @param _payIds List of pay ids. + * @param _lastPayResolveDeadline Per-channel cap on the per-pay resolve deadline. + * @return Amounts indexed identically to `_payIds`. + */ function getPayAmounts(bytes32[] calldata _payIds, uint256 _lastPayResolveDeadline) external view returns (uint256[] memory); - function getPayInfo(bytes32 _payId) external view returns (uint256, uint256); + /** + * @notice Read the (amount, deadline) tuple for a single payment. + * @param _payId Pay id. + * @return amount Resolved payment amount. + * @return resolveDeadline Block number after which the result is finalized. + */ + function getPayInfo(bytes32 _payId) external view returns (uint256 amount, uint256 resolveDeadline); + /// @notice Emitted whenever a pay's amount or deadline is written. event PayInfoUpdate(bytes32 indexed payId, uint256 amount, uint256 resolveDeadline); } diff --git a/src/lib/interface/IPayResolver.sol b/src/lib/interface/IPayResolver.sol index f64d3f5..dc86e69 100644 --- a/src/lib/interface/IPayResolver.sol +++ b/src/lib/interface/IPayResolver.sol @@ -3,11 +3,38 @@ pragma solidity ^0.8.20; /** * @title PayResolver interface + * @notice On-chain logic for resolving conditional payments. PayResolver is a + * *versioned* component: each payment specifies which resolver address it trusts + * (field 8 of `ConditionalPay`), and the resolver address is mixed into the + * pay id (`payId = keccak256(payHash, resolverAddress)`). This binds a payment's + * result to the exact resolver version the source designated. + * + * Two resolution modes are supported: + * - {resolvePaymentByConditions} — evaluate every condition on-chain (hash-locks + * via preimages, deployed/virtual contracts via {IBooleanCond}/{INumericCond}) + * and apply the payment's `transfer_func`. + * - {resolvePaymentByVouchedResult} — accept a result co-signed by `pay.src` + * and `pay.dest`, capped at `pay.transferFunc.maxTransfer.receiver.amt`. */ interface IPayResolver { + /** + * @notice Resolve a payment by evaluating its on-chain conditions. + * @dev All conditions must already be finalized; the call reverts otherwise. + * Hash-lock conditions are validated against `_resolvePayRequest.hashPreimages` + * in the order they appear in `pay.conditions`. Virtual-contract conditions + * must already be materialized via the VirtContractResolver. + * @param _resolvePayRequest ABI-encoded `PbChain.ResolvePayByConditionsRequest`. + */ function resolvePaymentByConditions(bytes calldata _resolvePayRequest) external; + /** + * @notice Resolve a payment by submitting an off-chain result co-signed by + * the payment's source and destination. + * @dev The submitted amount must not exceed `pay.transferFunc.maxTransfer.receiver.amt`. + * @param _vouchedPayResult ABI-encoded `PbEntity.VouchedCondPayResult`. + */ function resolvePaymentByVouchedResult(bytes calldata _vouchedPayResult) external; + /// @notice Emitted whenever a payment is resolved on-chain. event ResolvePayment(bytes32 indexed payId, uint256 amount, uint256 resolveDeadline); } diff --git a/src/lib/interface/IRouterRegistry.sol b/src/lib/interface/IRouterRegistry.sol index 6e2c794..4d50eeb 100644 --- a/src/lib/interface/IRouterRegistry.sol +++ b/src/lib/interface/IRouterRegistry.sol @@ -2,20 +2,37 @@ pragma solidity ^0.8.20; /** - * @title RouterRegistry interface for routing + * @title RouterRegistry interface + * @notice Optional global registry where relay-router operators advertise themselves + * to the AgentPay network. The registry stores the latest registration / refresh + * block number per router address; consumers may use it to discover live routers + * off-chain. */ interface IRouterRegistry { + /// @notice Type of a {RouterUpdated} event. enum RouterOperation { Add, Remove, Refresh } + /** + * @notice Register `msg.sender` as a router; reverts if already registered. + * @dev Stores the current `block.number` against the caller's address. + */ function registerRouter() external; + /** + * @notice Deregister `msg.sender`; reverts if not currently registered. + */ function deregisterRouter() external; + /** + * @notice Refresh `msg.sender`'s stored block number; reverts if not registered. + * @dev Used by routers to signal liveness without removing/re-adding. + */ function refreshRouter() external; + /// @notice Emitted on every register / deregister / refresh. event RouterUpdated(RouterOperation indexed op, address indexed routerAddress); } diff --git a/src/lib/interface/IVirtContractResolver.sol b/src/lib/interface/IVirtContractResolver.sol index ae39f79..9e29b9f 100644 --- a/src/lib/interface/IVirtContractResolver.sol +++ b/src/lib/interface/IVirtContractResolver.sol @@ -3,11 +3,29 @@ pragma solidity ^0.8.20; /** * @title VirtContractResolver interface + * @notice Materializes off-chain ("virtual") contracts on-chain when a dispute requires + * them. Maps a deterministic virtual address — `keccak256(code, nonce)` — to the real + * on-chain address produced by deploying that bytecode via `CREATE`. Once deployed, + * PayResolver can query the contract through {IBooleanCond} or {INumericCond}. */ interface IVirtContractResolver { + /** + * @notice Deploy a virtual contract on-chain under its deterministic virtual address. + * @dev Reverts if a contract has already been deployed under (`_code`, `_nonce`) + * or if the underlying `CREATE` fails. + * @param _code Bytecode of the virtual contract. + * @param _nonce Nonce that, together with `_code`, derives the virtual address. + * @return True on successful deployment (reverts otherwise). + */ function deploy(bytes calldata _code, uint256 _nonce) external returns (bool); + /** + * @notice Resolve a virtual address to its deployed on-chain address. + * @param _virtAddr `keccak256(code, nonce)` virtual address. + * @return The deployed contract address, or `address(0)` if not yet deployed. + */ function resolve(bytes32 _virtAddr) external view returns (address); + /// @notice Emitted when a virtual contract is materialized on-chain. event Deploy(bytes32 indexed virtAddr); } diff --git a/src/lib/ledgerlib/LedgerBalanceLimit.sol b/src/lib/ledgerlib/LedgerBalanceLimit.sol index 928dd04..6a72798 100644 --- a/src/lib/ledgerlib/LedgerBalanceLimit.sol +++ b/src/lib/ledgerlib/LedgerBalanceLimit.sol @@ -4,8 +4,10 @@ pragma solidity ^0.8.20; import "./LedgerStruct.sol"; /** - * @title Ledger Balance Limit Library - * @notice CelerLedger library about balance limits + * @title LedgerBalanceLimit + * @notice Library implementing optional per-token per-channel deposit caps. Enabled + * by default in {CelerLedger}'s constructor; the owner can configure individual + * limits or disable enforcement globally. */ library LedgerBalanceLimit { /** diff --git a/src/lib/ledgerlib/LedgerChannel.sol b/src/lib/ledgerlib/LedgerChannel.sol index 98d04ea..fefbba5 100644 --- a/src/lib/ledgerlib/LedgerChannel.sol +++ b/src/lib/ledgerlib/LedgerChannel.sol @@ -8,9 +8,12 @@ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; /** - * @title Ledger Channel Library - * @notice CelerLedger library about Channel struct - * @dev this can be included in LedgerOperation to save some gas, keep this for now for clearness. + * @title LedgerChannel + * @notice Library of `Channel` view helpers and per-channel state derivations used + * by {CelerLedger}'s getters. Also hosts shared signature-verification helpers used + * by other ledger libraries. + * @dev Could be folded into LedgerOperation for marginal gas savings; kept separate + * for code clarity. */ library LedgerChannel { using ECDSA for bytes32; diff --git a/src/lib/ledgerlib/LedgerMigrate.sol b/src/lib/ledgerlib/LedgerMigrate.sol index 48c2ff4..49c89b0 100644 --- a/src/lib/ledgerlib/LedgerMigrate.sol +++ b/src/lib/ledgerlib/LedgerMigrate.sol @@ -9,8 +9,13 @@ import "../data/PbChain.sol"; import "../data/PbEntity.sol"; /** - * @title Ledger Migrate Library - * @notice CelerLedger library about channel migration + * @title LedgerMigrate + * @notice Library implementing peer-controlled cross-version channel migration. The + * new ledger's `migrateChannelFrom` orchestrates the flow end-to-end: validating + * the co-signed migration request, calling `migrateChannelTo` on the old ledger, + * verifying the wallet operatorship transfer, and importing the channel state. + * Migration outranks `intendSettle` — a `Settling` channel returns to `Operable` + * on the new ledger. */ library LedgerMigrate { using LedgerChannel for LedgerStruct.Channel; diff --git a/src/lib/ledgerlib/LedgerOperation.sol b/src/lib/ledgerlib/LedgerOperation.sol index be62e86..18a40a3 100644 --- a/src/lib/ledgerlib/LedgerOperation.sol +++ b/src/lib/ledgerlib/LedgerOperation.sol @@ -10,8 +10,12 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /** - * @title Ledger Operation Library - * @notice CelerLedger library of basic ledger operations + * @title LedgerOperation + * @notice Library implementing the channel-lifecycle flows for CelerLedger: open, + * deposit, snapshot, withdraw (cooperative + unilateral), and settle (cooperative + + * unilateral). Attached to `LedgerStruct.Ledger` via `using ... for ...` in + * {CelerLedger}; do not deploy directly. Library functions cannot be `payable` but + * read `msg.value` from the calling contract's context. */ library LedgerOperation { using SafeERC20 for IERC20; diff --git a/src/lib/ledgerlib/LedgerStruct.sol b/src/lib/ledgerlib/LedgerStruct.sol index fe795c8..5325659 100644 --- a/src/lib/ledgerlib/LedgerStruct.sol +++ b/src/lib/ledgerlib/LedgerStruct.sol @@ -7,10 +7,18 @@ import "../interface/IPayRegistry.sol"; import "../data/PbEntity.sol"; /** - * @title Ledger Struct Library - * @notice CelerLedger library defining all used structs + * @title LedgerStruct + * @notice Shared struct and enum definitions used across all CelerLedger libraries. + * No logic — types only. Field semantics map onto the protobuf messages defined in + * `proto/entity.proto`; field numbers in those `.proto` files are noted alongside the + * Solidity counterparts where relevant. */ library LedgerStruct { + /** + * @notice Lifecycle status of a channel inside a CelerLedger instance. + * @dev `Uninitialized` is the implicit default when a channel id is not present in + * `Ledger.channelMap`. State transitions: see `docs/architecture-summary.md`. + */ enum ChannelStatus { Uninitialized, Operable, @@ -19,24 +27,32 @@ library LedgerStruct { Migrated } + /** + * @notice Snapshot of a peer's simplex state as last accepted on-chain. + * @dev Mirrors fields 3, 4, 6, 7 of `entity.proto::SimplexPaymentChannel`. + * Only the cumulative *transferOut* is tracked: the inverse direction's + * transferOut is recorded on the other peer's PeerState. + */ struct PeerState { uint256 seqNum; - // balance sent out to the other peer of the channel, no need to record amtIn + // Cumulative balance sent to the other peer; monotonically increasing. uint256 transferOut; bytes32 nextPayIdListHash; uint256 lastPayResolveDeadline; uint256 pendingPayOut; } + /// @notice Per-peer profile: account info + deposit/withdraw history + simplex state. struct PeerProfile { address peerAddr; - // the (monotone increasing) amount that this peer deposit into this channel + // Cumulative deposits into this channel; monotonically increasing. uint256 deposit; - // the (monotone increasing) amount that this peer withdraw from this channel + // Cumulative withdrawals from this channel; monotonically increasing. uint256 withdrawal; PeerState state; } + /// @notice Active unilateral withdraw intent (if any) for a channel. struct WithdrawIntent { address receiver; uint256 amount; @@ -44,35 +60,43 @@ library LedgerStruct { bytes32 recipientChannelId; } - // Channel is a representation of the state channel between peers which puts the funds - // in CelerWallet and is hosted by a CelerLedger. The status of a state channel can - // be migrated from one CelerLedger instance to another CelerLedger instance with probably - // different operation logic. + /** + * @notice On-chain representation of a duplex state channel between two peers. + * @dev Funds physically reside in {ICelerWallet}; this struct holds only state + * and metadata. Peers may cooperatively migrate a channel to a new CelerLedger + * version, in which case `status = Migrated` and `migratedTo` is set on the old + * ledger. + */ struct Channel { - // the time after which peers can confirmSettle and before which peers can intendSettle + // Block number after which peers may call confirmSettle, and before which + // peers may still call intendSettle. uint256 settleFinalizedTime; uint256 disputeTimeout; PbEntity.TokenInfo token; ChannelStatus status; - // record the new CelerLedger address after channel migration + // Address of the successor CelerLedger after migration, if any. address migratedTo; - // only support 2-peer channel for now + // Two-peer channels only. PeerProfile[2] peerProfiles; uint256 cooperativeWithdrawSeqNum; WithdrawIntent withdrawIntent; } - // Ledger is a host to record and operate the activities of many state - // channels with specific operation logic. + /** + * @notice Top-level ledger storage: many channels under one operation logic. + * @dev Held in CelerLedger as a single private state variable. Each Ledger + * binds to one CelerWallet (asset custody), one PayRegistry (resolved-pay + * results), and one EthPool (ETH wrapper). + */ struct Ledger { - // ChannelStatus => number of channels + // ChannelStatus value => number of channels currently in that status. mapping(uint256 => uint256) channelStatusNums; IEthPool ethPool; IPayRegistry payRegistry; ICelerWallet celerWallet; - // per-channel balance limits for different tokens + // Per-token per-channel deposit caps. mapping(address => uint256) balanceLimits; - // whether balance limits of all tokens have been enabled + // Whether balance-limit enforcement is currently active. bool balanceLimitsEnabled; mapping(bytes32 => Channel) channelMap; } diff --git a/test/CelerLedger.ERC20.t.sol b/test/CelerLedger.ERC20.t.sol new file mode 100644 index 0000000..16f004d --- /dev/null +++ b/test/CelerLedger.ERC20.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {LedgerTestBase} from "./utils/LedgerTestBase.t.sol"; +import {LedgerStruct} from "../src/lib/ledgerlib/LedgerStruct.sol"; + +/** + * @title CelerLedger ERC20-channel tests + * @notice Unit tests for `CelerLedger`'s ERC-20-channel paths. Focuses on + * ERC-20-specific behavior — open with / without funds, balance-limit gates, + * deposit (including by a non-peer third party), `msg.value` rejection, + * cooperative settle, and intend + confirm settle distributing tokens. + * ETH-side flows are validated in `CelerLedger.ETH.t.sol`. + */ +contract CelerLedgerErc20Test is LedgerTestBase { + function setUp() public override { + super.setUp(); + + // Fund both peers with the example ERC20 token and approve the ledger. + erc20.transfer(peer0, 1_000_000); + erc20.transfer(peer1, 1_000_000); + + vm.prank(peer0); + erc20.approve(address(celerLedger), type(uint256).max); + vm.prank(peer1); + erc20.approve(address(celerLedger), type(uint256).max); + } + + function test_openErc20Channel_zeroDeposit_succeeds() public { + uint256 deadline = openDeadlineCursor++; + (bytes memory request,, bytes32 channelId) = _buildOpenErc20(address(erc20), [uint256(0), 0], deadline); + celerLedger.openChannel(request); + + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Operable)); + assertEq(celerLedger.getTokenContract(channelId), address(erc20)); + assertEq(celerLedger.getTotalBalance(channelId), 0); + } + + function test_openErc20Channel_withFunds_revertsBeforeBalanceLimit() public { + uint256 deadline = openDeadlineCursor++; + (bytes memory request,,) = _buildOpenErc20(address(erc20), [uint256(100), 200], deadline); + vm.expectRevert(bytes("Balance exceeds limit")); + celerLedger.openChannel(request); + } + + function test_openErc20Channel_withFunds_succeedsAfterBalanceLimit() public { + _setErc20BalanceLimit(1_000_000); + uint256 deadline = openDeadlineCursor++; + (bytes memory request,, bytes32 channelId) = _buildOpenErc20(address(erc20), [uint256(100), 200], deadline); + celerLedger.openChannel(request); + + assertEq(celerLedger.getTotalBalance(channelId), 300); + // Balances pulled from peer0 and peer1. + assertEq(erc20.balanceOf(peer0), 1_000_000 - 100); + assertEq(erc20.balanceOf(peer1), 1_000_000 - 200); + } + + function test_deposit_byPeer_succeeds() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openZeroErc20Channel(); + + vm.prank(peer0); + celerLedger.deposit(channelId, peer0, 25); + + assertEq(celerLedger.getTotalBalance(channelId), 25); + } + + function test_deposit_byNonPeerThirdParty_succeeds() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openZeroErc20Channel(); + + // Stranger has no tokens — fund and approve. + erc20.transfer(stranger, 1000); + vm.prank(stranger); + erc20.approve(address(celerLedger), 1000); + + vm.prank(stranger); + celerLedger.deposit(channelId, peer0, 25); + + assertEq(celerLedger.getTotalBalance(channelId), 25); + } + + function test_deposit_overBalanceLimit_reverts() public { + _setErc20BalanceLimit(50); + bytes32 channelId = _openZeroErc20Channel(); + + vm.expectRevert(bytes("Balance exceeds limit")); + vm.prank(peer0); + celerLedger.deposit(channelId, peer0, 100); + } + + function test_deposit_withMsgValueNonZero_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openZeroErc20Channel(); + + // ERC20 channel deposit must not have msg.value. + vm.deal(peer0, 1 ether); + vm.expectRevert(bytes("msg.value is not 0")); + vm.prank(peer0); + celerLedger.deposit{value: 1}(channelId, peer0, 25); + } + + function test_cooperativeSettle_distributesErc20() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedErc20Channel([uint256(200), 0]); + + bytes memory request = _buildCoopSettle(channelId, 1, [uint256(120), 80], block.number + 1000); + + uint256 peer0Before = erc20.balanceOf(peer0); + uint256 peer1Before = erc20.balanceOf(peer1); + celerLedger.cooperativeSettle(request); + + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Closed)); + assertEq(erc20.balanceOf(peer0), peer0Before + 120); + assertEq(erc20.balanceOf(peer1), peer1Before + 80); + } + + function test_intendSettle_thenConfirmSettle_noPays_distributesErc20() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedErc20Channel([uint256(200), 0]); + + bytes memory s0 = _buildSignedSimplex(channelId, peer0, 1, 0); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + + vm.prank(peer0); + celerLedger.intendSettle(array); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + uint256 peer0Before = erc20.balanceOf(peer0); + celerLedger.confirmSettle(channelId); + + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Closed)); + assertEq(erc20.balanceOf(peer0), peer0Before + 200); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + function _setErc20BalanceLimit(uint256 _limit) internal { + address[] memory tokens = new address[](1); + tokens[0] = address(erc20); + uint256[] memory limits = new uint256[](1); + limits[0] = _limit; + celerLedger.setBalanceLimits(tokens, limits); + } + + function _openZeroErc20Channel() internal returns (bytes32) { + uint256 deadline = openDeadlineCursor++; + (bytes memory request,, bytes32 channelId) = _buildOpenErc20(address(erc20), [uint256(0), 0], deadline); + celerLedger.openChannel(request); + return channelId; + } + + function _openFundedErc20Channel(uint256[2] memory _amounts) internal returns (bytes32) { + uint256 deadline = openDeadlineCursor++; + (bytes memory request,, bytes32 channelId) = _buildOpenErc20(address(erc20), _amounts, deadline); + celerLedger.openChannel(request); + return channelId; + } +} diff --git a/test/CelerLedger.ETH.t.sol b/test/CelerLedger.ETH.t.sol new file mode 100644 index 0000000..8a51ee4 --- /dev/null +++ b/test/CelerLedger.ETH.t.sol @@ -0,0 +1,1044 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {LedgerTestBase} from "./utils/LedgerTestBase.t.sol"; +import {LedgerStruct} from "../src/lib/ledgerlib/LedgerStruct.sol"; +import {Fixtures} from "./utils/Fixtures.sol"; +import {SignUtil} from "./utils/SignUtil.sol"; + +/** + * @title CelerLedger ETH-channel tests + * @notice Comprehensive unit tests for `CelerLedger`'s ETH-channel paths. + * Covers the channel state machine, balance-limit admin, deposit, cooperative + * and unilateral withdraw, snapshot states, intend / confirm / cooperative + * settle, `clearPays` segment chaining, the recipient-channel rebalance + * withdraw path, and the invalid-balance settle-recovery path. + * + * @dev Sections: + * 1. Channel state machine + * 2. Open channel + * 3. Balance-limit admin + * 4. Deposit + * 5. Cooperative withdraw + * 6. Unilateral withdraw + * 7. Snapshot states + * 8. Cooperative settle + * 9. Unilateral settle / clear pays / confirm settle + * 10. State / migration getters + * 11. Connection getters + * 12. Internal helpers + */ +contract CelerLedgerEthTest is LedgerTestBase { + // ========================================================================= + // 1. Channel state machine + // ========================================================================= + + function test_uninitializedChannel_returnsStatusZero() public view { + bytes32 unknownId = bytes32(uint256(0x123)); + assertEq(uint256(celerLedger.getChannelStatus(unknownId)), uint256(LedgerStruct.ChannelStatus.Uninitialized)); + } + + // ========================================================================= + // 2. Open channel + // ========================================================================= + + function test_openChannel_zeroDeposit_succeeds() public { + bytes32 channelId = _openZeroEthChannel(); + + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Operable)); + assertEq(celerLedger.getTokenContract(channelId), address(0)); + assertEq(celerLedger.getTotalBalance(channelId), 0); + } + + function test_openChannel_afterDeadline_reverts() public { + // Roll forward so any small openDeadline is already past. + vm.roll(100); + (bytes memory request,,) = _buildOpenEth([uint256(0), 0], 0, 1); + + vm.expectRevert(bytes("Open deadline passed")); + celerLedger.openChannel(request); + } + + function test_openChannel_sameInitializerTwice_reverts() public { + // Build with a fixed deadline so both attempts use the same initializer hash. + uint256 deadline = openDeadlineCursor++; + (bytes memory request,,) = _buildOpenEth([uint256(0), 0], 0, deadline); + celerLedger.openChannel(request); + + vm.expectRevert(bytes("Occupied wallet id")); + celerLedger.openChannel(request); + } + + function test_openChannel_withFunds_revertsBeforeBalanceLimit() public { + // Default: balance limits enabled but limit for ETH is unset (== 0). + (bytes memory request,,) = _buildOpenEth([uint256(100), 200], 0, openDeadlineCursor++); + + vm.expectRevert(bytes("Balance exceeds limit")); + vm.prank(peer0); + celerLedger.openChannel{value: 100}(request); + } + + function test_openChannel_withFunds_succeedsAfterBalanceLimit() public { + _setEthBalanceLimit(1_000_000); + + bytes32 channelId = _openFundedEthChannel([uint256(100), 200]); + + assertEq(celerLedger.getTotalBalance(channelId), 300); + (, uint256[2] memory deposits,) = celerLedger.getBalanceMap(channelId); + assertEq(deposits[0], 100); + assertEq(deposits[1], 200); + } + + // ========================================================================= + // 3. Balance-limit admin + // ========================================================================= + + function test_setBalanceLimits_revertsForNonOwner() public { + address[] memory tokens = new address[](1); + tokens[0] = address(0); + uint256[] memory limits = new uint256[](1); + limits[0] = 1_000_000; + + vm.expectRevert(); + vm.prank(stranger); + celerLedger.setBalanceLimits(tokens, limits); + } + + function test_setBalanceLimits_storesLimit() public { + _setEthBalanceLimit(1_000_000); + assertEq(celerLedger.getBalanceLimit(address(0)), 1_000_000); + } + + function test_disableBalanceLimits_revertsForNonOwner() public { + vm.expectRevert(); + vm.prank(stranger); + celerLedger.disableBalanceLimits(); + } + + function test_enableBalanceLimits_revertsForNonOwner() public { + celerLedger.disableBalanceLimits(); + vm.expectRevert(); + vm.prank(stranger); + celerLedger.enableBalanceLimits(); + } + + function test_disableBalanceLimits_allowsLargeDeposit() public { + _setEthBalanceLimit(50); + bytes32 channelId = _openZeroEthChannel(); + + celerLedger.disableBalanceLimits(); + assertEq(celerLedger.getBalanceLimitsEnabled(), false); + + vm.prank(peer0); + celerLedger.deposit{value: 1000}(channelId, peer0, 0); + assertEq(celerLedger.getTotalBalance(channelId), 1000); + } + + // ========================================================================= + // 4. Deposit + // ========================================================================= + + function test_deposit_viaMsgValue_succeeds() public { + _setEthBalanceLimit(1_000_000); + bytes32 channelId = _openZeroEthChannel(); + + vm.prank(peer0); + celerLedger.deposit{value: 50}(channelId, peer0, 0); + + assertEq(celerLedger.getTotalBalance(channelId), 50); + } + + function test_deposit_viaEthPool_succeeds() public { + _setEthBalanceLimit(1_000_000); + bytes32 channelId = _openZeroEthChannel(); + + vm.prank(peer0); + celerLedger.deposit(channelId, peer0, 100); + + assertEq(celerLedger.getTotalBalance(channelId), 100); + } + + function test_deposit_byStranger_succeeds() public { + _setEthBalanceLimit(1_000_000); + bytes32 channelId = _openZeroEthChannel(); + + vm.deal(stranger, 1 ether); + vm.prank(stranger); + celerLedger.deposit{value: 25}(channelId, peer0, 0); + + assertEq(celerLedger.getTotalBalance(channelId), 25); + } + + function test_deposit_overBalanceLimit_reverts() public { + _setEthBalanceLimit(100); + bytes32 channelId = _openZeroEthChannel(); + + vm.expectRevert(bytes("Balance exceeds limit")); + vm.prank(peer0); + celerLedger.deposit{value: 200}(channelId, peer0, 0); + } + + function test_deposit_toNonPeer_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openZeroEthChannel(); + + vm.deal(stranger, 1 ether); + vm.expectRevert(bytes("Nonexist peer")); + vm.prank(stranger); + celerLedger.deposit{value: 25}(channelId, stranger, 0); + } + + function test_depositInBatch_succeeds() public { + celerLedger.disableBalanceLimits(); + bytes32 ch1 = _openZeroEthChannel(); + bytes32 ch2 = _openZeroEthChannel(); + + bytes32[] memory ids = new bytes32[](2); + ids[0] = ch1; + ids[1] = ch2; + address[] memory receivers = new address[](2); + receivers[0] = peer0; + receivers[1] = peer0; + uint256[] memory amounts = new uint256[](2); + amounts[0] = 30; + amounts[1] = 70; + + vm.prank(peer0); + celerLedger.depositInBatch(ids, receivers, amounts); + + assertEq(celerLedger.getTotalBalance(ch1), 30); + assertEq(celerLedger.getTotalBalance(ch2), 70); + } + + function test_depositInBatch_lengthMismatch_reverts() public { + bytes32 channelId = _openZeroEthChannel(); + + bytes32[] memory ids = new bytes32[](2); + ids[0] = channelId; + ids[1] = channelId; + address[] memory receivers = new address[](1); + receivers[0] = peer0; + uint256[] memory amounts = new uint256[](2); + + vm.expectRevert(bytes("Lengths do not match")); + celerLedger.depositInBatch(ids, receivers, amounts); + } + + // ========================================================================= + // 5. Cooperative withdraw + // ========================================================================= + + function test_cooperativeWithdraw_succeeds() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + bytes memory request = _buildCoopWithdraw(channelId, 1, peer0, 100, block.number + 1000, bytes32(0)); + + uint256 peer0BalBefore = peer0.balance; + celerLedger.cooperativeWithdraw(request); + + assertEq(peer0.balance, peer0BalBefore + 100); + assertEq(celerLedger.getTotalBalance(channelId), 100); + assertEq(celerLedger.getCooperativeWithdrawSeqNum(channelId), 1); + } + + function test_cooperativeWithdraw_toRecipientChannel_movesFundsAcrossChannels() public { + celerLedger.disableBalanceLimits(); + bytes32 srcChannel = _openFundedEthChannel([uint256(200), 0]); + bytes32 dstChannel = _openZeroEthChannel(); + + bytes memory request = _buildCoopWithdraw(srcChannel, 1, peer0, 80, block.number + 1000, dstChannel); + celerLedger.cooperativeWithdraw(request); + + // Source channel debited 80; destination channel credited 80 to peer0. + assertEq(celerLedger.getTotalBalance(srcChannel), 200 - 80); + assertEq(celerLedger.getTotalBalance(dstChannel), 80); + + (, uint256[2] memory dstDeposits,) = celerLedger.getBalanceMap(dstChannel); + assertEq(dstDeposits[0], 80); + assertEq(dstDeposits[1], 0); + } + + function test_cooperativeWithdraw_expiredDeadline_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.roll(2); + bytes memory request = _buildCoopWithdraw(channelId, 1, peer0, 100, 1, bytes32(0)); + + vm.expectRevert(bytes("Withdraw deadline passed")); + celerLedger.cooperativeWithdraw(request); + } + + function test_cooperativeWithdraw_outOfOrderSeqNum_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // First withdraw at seqNum=1 (must be exactly current+1). + bytes memory r1 = _buildCoopWithdraw(channelId, 1, peer0, 50, block.number + 1000, bytes32(0)); + celerLedger.cooperativeWithdraw(r1); + + // Try seqNum=1 again (should be 2 next) — reverts. + bytes memory r2 = _buildCoopWithdraw(channelId, 1, peer0, 50, block.number + 1000, bytes32(0)); + vm.expectRevert(bytes("seqNum error")); + celerLedger.cooperativeWithdraw(r2); + } + + function test_cooperativeWithdraw_toMismatchedTokenChannel_reverts() public { + celerLedger.disableBalanceLimits(); + + // Fund an ERC20 channel to use as a (mismatched) recipient. + erc20.transfer(peer0, 1_000); + erc20.transfer(peer1, 1_000); + vm.prank(peer0); + erc20.approve(address(celerLedger), type(uint256).max); + vm.prank(peer1); + erc20.approve(address(celerLedger), type(uint256).max); + + uint256 deadline = openDeadlineCursor++; + (bytes memory openReq,, bytes32 erc20Channel) = _buildOpenErc20(address(erc20), [uint256(0), 0], deadline); + celerLedger.openChannel(openReq); + + bytes32 ethChannel = _openFundedEthChannel([uint256(200), 0]); + bytes memory request = _buildCoopWithdraw(ethChannel, 1, peer0, 50, block.number + 1000, erc20Channel); + + vm.expectRevert(bytes("Token mismatch of recipient channel")); + celerLedger.cooperativeWithdraw(request); + } + + function test_cooperativeWithdraw_badSignatures_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + Fixtures.CooperativeWithdrawInfo memory w = Fixtures.CooperativeWithdrawInfo({ + channelId: channelId, + seqNum: 1, + withdrawAccount: peer0, + withdrawAmount: 50, + withdrawDeadline: block.number + 1000, + recipientChannelId: bytes32(0) + }); + bytes memory body = Fixtures.encCooperativeWithdrawInfo(w); + + // Sign with a non-peer key for the first signature. + (, uint256 strangerPk) = makeAddrAndKey("strangerPk"); + bytes[] memory sigs = new bytes[](2); + sigs[0] = SignUtil.sign(strangerPk, body); + sigs[1] = SignUtil.sign(peer1Pk, body); + bytes memory request = Fixtures.encCooperativeWithdrawRequest(body, sigs); + + vm.expectRevert(bytes("Check co-sigs failed")); + celerLedger.cooperativeWithdraw(request); + } + + // ========================================================================= + // 6. Unilateral withdraw + // ========================================================================= + + function test_intendWithdraw_succeeds_emitsEvent() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.prank(peer0); + celerLedger.intendWithdraw(channelId, 50, bytes32(0)); + + (address receiver, uint256 amount,, bytes32 recipient) = celerLedger.getWithdrawIntent(channelId); + assertEq(receiver, peer0); + assertEq(amount, 50); + assertEq(recipient, bytes32(0)); + } + + function test_intendWithdraw_byNonPeer_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.expectRevert(); + vm.prank(stranger); + celerLedger.intendWithdraw(channelId, 50, bytes32(0)); + } + + function test_intendWithdraw_pendingExists_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.prank(peer0); + celerLedger.intendWithdraw(channelId, 30, bytes32(0)); + + vm.expectRevert(bytes("Pending withdraw intent exists")); + vm.prank(peer1); + celerLedger.intendWithdraw(channelId, 50, bytes32(0)); + } + + function test_intendWithdraw_confirmAfterTimeout_succeeds() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.prank(peer0); + celerLedger.intendWithdraw(channelId, 50, bytes32(0)); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + + uint256 peer0BalBefore = peer0.balance; + celerLedger.confirmWithdraw(channelId); + + assertEq(peer0.balance, peer0BalBefore + 50); + assertEq(celerLedger.getTotalBalance(channelId), 150); + } + + function test_intendWithdraw_toRecipientChannel_succeeds() public { + celerLedger.disableBalanceLimits(); + bytes32 srcChannel = _openFundedEthChannel([uint256(200), 0]); + bytes32 dstChannel = _openZeroEthChannel(); + + vm.prank(peer0); + celerLedger.intendWithdraw(srcChannel, 60, dstChannel); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + celerLedger.confirmWithdraw(srcChannel); + + assertEq(celerLedger.getTotalBalance(srcChannel), 140); + assertEq(celerLedger.getTotalBalance(dstChannel), 60); + } + + function test_confirmWithdraw_beforeDisputeWindow_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.prank(peer0); + celerLedger.intendWithdraw(channelId, 50, bytes32(0)); + + vm.expectRevert(bytes("Dispute not timeout")); + celerLedger.confirmWithdraw(channelId); + } + + function test_confirmWithdraw_doubleConfirm_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.prank(peer0); + celerLedger.intendWithdraw(channelId, 50, bytes32(0)); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + celerLedger.confirmWithdraw(channelId); + + vm.expectRevert(); + celerLedger.confirmWithdraw(channelId); + } + + function test_vetoWithdraw_clearsIntent() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + vm.prank(peer0); + celerLedger.intendWithdraw(channelId, 50, bytes32(0)); + + vm.prank(peer1); + celerLedger.vetoWithdraw(channelId); + + (address receiver,,,) = celerLedger.getWithdrawIntent(channelId); + assertEq(receiver, address(0)); + } + + // ========================================================================= + // 7. Snapshot states + // ========================================================================= + + function test_snapshotStates_recordsLatest() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + bytes memory simplex = _buildSignedSimplex(channelId, peer0, 1, 50); + bytes memory array = _wrapStateArray(simplex, ""); + celerLedger.snapshotStates(array); + + (, uint256[2] memory seqs) = celerLedger.getStateSeqNumMap(channelId); + assertEq(seqs[0], 1); + (, uint256[2] memory transferOuts) = celerLedger.getTransferOutMap(channelId); + assertEq(transferOuts[0], 50); + } + + function test_snapshotStates_multiChannel_emitsPerChannel() public { + celerLedger.disableBalanceLimits(); + bytes32 ch1 = _openFundedEthChannel([uint256(100), 0]); + bytes32 ch2 = _openFundedEthChannel([uint256(150), 0]); + bytes32 first = ch1 < ch2 ? ch1 : ch2; + bytes32 second = ch1 < ch2 ? ch2 : ch1; + + bytes memory s0 = _buildSignedSimplex(first, peer0, 1, 10); + bytes memory s1 = _buildSignedSimplex(second, peer0, 1, 20); + bytes[] memory states = new bytes[](2); + states[0] = s0; + states[1] = s1; + bytes memory array = Fixtures.encSignedSimplexStateArray(states); + + celerLedger.snapshotStates(array); + + (, uint256[2] memory firstSeqs) = celerLedger.getStateSeqNumMap(first); + (, uint256[2] memory secondSeqs) = celerLedger.getStateSeqNumMap(second); + assertEq(firstSeqs[0], 1); + assertEq(secondSeqs[0], 1); + } + + function test_snapshotStates_nonAscendingChannelIds_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 ch1 = _openFundedEthChannel([uint256(100), 0]); + bytes32 ch2 = _openFundedEthChannel([uint256(150), 0]); + bytes32 high = ch1 > ch2 ? ch1 : ch2; + bytes32 low = ch1 > ch2 ? ch2 : ch1; + + bytes memory sHigh = _buildSignedSimplex(high, peer0, 1, 0); + bytes memory sLow = _buildSignedSimplex(low, peer0, 1, 0); + bytes[] memory states = new bytes[](2); + states[0] = sHigh; + states[1] = sLow; + bytes memory array = Fixtures.encSignedSimplexStateArray(states); + + vm.expectRevert(bytes("Non-ascending channelIds")); + celerLedger.snapshotStates(array); + } + + function test_snapshotStates_badSignature_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(100), 0]); + + Fixtures.SimplexState memory s = Fixtures.SimplexState({ + channelId: channelId, + peerFrom: peer0, + seqNum: 1, + transferAmount: 10, + pendingPayIds: bytes(""), + lastPayResolveDeadline: 0, + totalPendingAmount: 0 + }); + bytes memory simplex = Fixtures.encSimplexPaymentChannel(s); + + // Sign with a non-peer key for the first signature. + (, uint256 strangerPk) = makeAddrAndKey("strangerPk"); + bytes[] memory sigs = new bytes[](2); + sigs[0] = SignUtil.sign(strangerPk, simplex); + sigs[1] = SignUtil.sign(peer1Pk, simplex); + bytes memory signed = Fixtures.encSignedSimplexState(simplex, sigs); + + bytes[] memory states = new bytes[](1); + states[0] = signed; + bytes memory array = Fixtures.encSignedSimplexStateArray(states); + + vm.expectRevert(bytes("Check co-sigs failed")); + celerLedger.snapshotStates(array); + } + + // ========================================================================= + // 8. Cooperative settle + // ========================================================================= + + function test_cooperativeSettle_closesChannel_distributesBalance() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + bytes memory request = _buildCoopSettle(channelId, 1, [uint256(120), 80], block.number + 1000); + + uint256 peer0Before = peer0.balance; + uint256 peer1Before = peer1.balance; + celerLedger.cooperativeSettle(request); + + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Closed)); + assertEq(peer0.balance, peer0Before + 120); + assertEq(peer1.balance, peer1Before + 80); + } + + function test_cooperativeSettle_balanceSumMismatch_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Total = 200 but settleBalance = 100 + 50 = 150 → revert. + bytes memory request = _buildCoopSettle(channelId, 1, [uint256(100), 50], block.number + 1000); + + vm.expectRevert(bytes("Balance sum mismatch")); + celerLedger.cooperativeSettle(request); + } + + function test_cooperativeSettle_expiredDeadline_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Deadline = 1, then roll past. + bytes memory request = _buildCoopSettle(channelId, 1, [uint256(120), 80], 1); + vm.roll(2); + + vm.expectRevert(bytes("Settle deadline passed")); + celerLedger.cooperativeSettle(request); + } + + function test_cooperativeSettle_lowSeqNum_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Bump peer0's seqNum to 1 via snapshot first. + bytes memory simplex = _buildSignedSimplex(channelId, peer0, 1, 0); + celerLedger.snapshotStates(_wrapStateArray(simplex, "")); + + // settleInfo seqNum must be > both peer seqNums; 1 fails (peer0 already 1). + bytes memory request = _buildCoopSettle(channelId, 1, [uint256(120), 80], block.number + 1000); + vm.expectRevert(bytes("seqNum error")); + celerLedger.cooperativeSettle(request); + } + + function test_cooperativeSettle_singleSignature_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + Fixtures.CooperativeSettleInfo memory s = Fixtures.CooperativeSettleInfo({ + channelId: channelId, + seqNum: 1, + settleAccounts: [peer0, peer1], + settleAmounts: [uint256(120), 80], + settleDeadline: block.number + 1000 + }); + bytes memory body = Fixtures.encCooperativeSettleInfo(s); + bytes[] memory sigs = new bytes[](1); + sigs[0] = SignUtil.sign(peer0Pk, body); + bytes memory request = Fixtures.encCooperativeSettleRequest(body, sigs); + + vm.expectRevert(bytes("Check co-sigs failed")); + celerLedger.cooperativeSettle(request); + } + + // ========================================================================= + // 9. Unilateral settle / clear pays / confirm settle + // ========================================================================= + + function test_intendSettle_thenConfirmSettle_noPays_closesChannel() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + bytes memory s0 = _buildSignedSimplex(channelId, peer0, 1, 0); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + + // Caller of intendSettle must be a channel peer. + vm.prank(peer0); + celerLedger.intendSettle(array); + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Settling)); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + uint256 peer0Before = peer0.balance; + celerLedger.confirmSettle(channelId); + + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Closed)); + // peer0 deposited 200; with no transferOut, peer0 keeps everything. + assertEq(peer0.balance, peer0Before + 200); + } + + function test_intendSettle_withTransferOut_distributesOnConfirm() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // peer0 transferred 50 to peer1; peer1's simplex is null. + bytes memory s0 = _buildSignedSimplex(channelId, peer0, 1, 50); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + + vm.prank(peer0); + celerLedger.intendSettle(array); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + uint256 peer0Before = peer0.balance; + uint256 peer1Before = peer1.balance; + celerLedger.confirmSettle(channelId); + + // peer0 keeps 200 - 50 = 150; peer1 receives 50. + assertEq(peer0.balance, peer0Before + 150); + assertEq(peer1.balance, peer1Before + 50); + } + + function test_intendSettle_multiChannelBatch_putsAllSettling() public { + celerLedger.disableBalanceLimits(); + bytes32 ch1 = _openFundedEthChannel([uint256(100), 0]); + bytes32 ch2 = _openFundedEthChannel([uint256(150), 0]); + + bytes32 firstId = ch1 < ch2 ? ch1 : ch2; + bytes32 secondId = ch1 < ch2 ? ch2 : ch1; + + bytes memory s0a = _buildSignedSimplex(firstId, peer0, 1, 0); + bytes memory s0b = _buildSignedSimplex(firstId, peer1, 1, 0); + bytes memory s1a = _buildSignedSimplex(secondId, peer0, 1, 0); + bytes memory s1b = _buildSignedSimplex(secondId, peer1, 1, 0); + + bytes[] memory states = new bytes[](4); + states[0] = s0a; + states[1] = s0b; + states[2] = s1a; + states[3] = s1b; + bytes memory array = Fixtures.encSignedSimplexStateArray(states); + + vm.prank(peer0); + celerLedger.intendSettle(array); + + assertEq(uint256(celerLedger.getChannelStatus(firstId)), uint256(LedgerStruct.ChannelStatus.Settling)); + assertEq(uint256(celerLedger.getChannelStatus(secondId)), uint256(LedgerStruct.ChannelStatus.Settling)); + } + + function test_intendSettle_nullState_singleSignedByOnePeer() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Both states null (seqNum=0), each signed by exactly one peer. + bytes memory s0 = _buildNullSimplex(channelId, peer0Pk); + bytes memory s1 = _buildNullSimplex(channelId, peer1Pk); + bytes memory array = _wrapStateArray(s0, s1); + + vm.prank(peer0); + celerLedger.intendSettle(array); + + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Settling)); + } + + function test_intendSettle_challengeWithHigherSeqNum_overrides() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // First intendSettle at seqNum=1 (peer0 transferred 30). + bytes memory s0v1 = _buildSignedSimplex(channelId, peer0, 1, 30); + bytes memory s1null = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array1 = _wrapStateArray(s0v1, s1null); + vm.prank(peer0); + celerLedger.intendSettle(array1); + + // Counterparty challenges with seqNum=2 showing peer0 actually transferred 60. + bytes memory s0v2 = _buildSignedSimplex(channelId, peer0, 2, 60); + bytes memory s1null2 = _buildSignedSimplex(channelId, peer1, 2, 0); + bytes memory array2 = _wrapStateArray(s0v2, s1null2); + vm.prank(peer1); + celerLedger.intendSettle(array2); + + (, uint256[2] memory transferOuts) = celerLedger.getTransferOutMap(channelId); + assertEq(transferOuts[0], 60); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + uint256 peer1Before = peer1.balance; + celerLedger.confirmSettle(channelId); + assertEq(peer1.balance, peer1Before + 60); + } + + function test_intendSettle_withPendingPays_clearsAndDistributes() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Resolve a single hash-lock-only pay to max amount on the registry. + bytes32 payId = _resolveSingleHashLockPay(25, "preimage", 1); + + // peer0's simplex state references this pay in pending list. + bytes memory signedSimplexWithPays = _buildSimplexWithSinglePay(channelId, payId, 25); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(signedSimplexWithPays, s1); + + // Roll past the pay's onchain resolve deadline so getPayAmounts reads it. + vm.roll(block.number + 10); + + vm.prank(peer0); + celerLedger.intendSettle(array); + + (, uint256[2] memory transferOuts) = celerLedger.getTransferOutMap(channelId); + assertEq(transferOuts[0], 25); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + uint256 peer1Before = peer1.balance; + celerLedger.confirmSettle(channelId); + + assertEq(peer1.balance, peer1Before + 25); + } + + function test_clearPays_multiSegmentList_processesSecondSegment() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Resolve two pays so we have two distinct pay ids. + bytes32 payId1 = _resolveSingleHashLockPay(15, "preimage1", 1); + bytes32 payId2 = _resolveSingleHashLockPay(20, "preimage2", 2); + + // Build a tail PayIdList containing payId2. + bytes32[] memory tailIds = new bytes32[](1); + tailIds[0] = payId2; + bytes memory tailList = Fixtures.encPayIdList(tailIds, bytes32(0)); + bytes32 tailHash = keccak256(tailList); + + // Build the head PayIdList containing payId1, pointing at the tail. + bytes32[] memory headIds = new bytes32[](1); + headIds[0] = payId1; + bytes memory headList = Fixtures.encPayIdList(headIds, tailHash); + + // peer0's simplex state references the head list. Total pending = 35. + bytes memory signedSimplex = _buildSimplexWithPayList(channelId, headList, 35); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(signedSimplex, s1); + + vm.roll(block.number + 10); + + // intendSettle clears the head list. + vm.prank(peer0); + celerLedger.intendSettle(array); + + (, uint256[2] memory transferOuts1) = celerLedger.getTransferOutMap(channelId); + assertEq(transferOuts1[0], 15); + (, bytes32[2] memory nextHashes) = celerLedger.getNextPayIdListHashMap(channelId); + assertEq(nextHashes[0], tailHash); + + // Public clearPays processes the tail. + celerLedger.clearPays(channelId, peer0, tailList); + + (, uint256[2] memory transferOuts2) = celerLedger.getTransferOutMap(channelId); + assertEq(transferOuts2[0], 35); + } + + function test_clearPays_wrongStatus_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openZeroEthChannel(); + + bytes32[] memory ids = new bytes32[](1); + ids[0] = bytes32(uint256(1)); + bytes memory list = Fixtures.encPayIdList(ids, bytes32(0)); + + // Channel is Operable, not Settling. + vm.expectRevert(bytes("Channel status error")); + celerLedger.clearPays(channelId, peer0, list); + } + + function test_clearPays_listHashMismatch_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Put channel in Settling with no pending pays. + bytes memory s0 = _buildSignedSimplex(channelId, peer0, 1, 0); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + vm.prank(peer0); + celerLedger.intendSettle(array); + + // Try to clear an arbitrary list — peer's nextPayIdListHash is bytes32(0). + bytes32[] memory ids = new bytes32[](1); + ids[0] = bytes32(uint256(1)); + bytes memory list = Fixtures.encPayIdList(ids, bytes32(0)); + + vm.expectRevert(bytes("List hash mismatch")); + celerLedger.clearPays(channelId, peer0, list); + } + + function test_confirmSettle_beforeFinalizedTime_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + bytes memory s0 = _buildSignedSimplex(channelId, peer0, 1, 0); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + vm.prank(peer0); + celerLedger.intendSettle(array); + + vm.expectRevert(bytes("Settle is not finalized")); + celerLedger.confirmSettle(channelId); + } + + function test_confirmSettle_wrongStatus_reverts() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + + // Channel is Operable, not Settling. + vm.expectRevert(bytes("Channel status error")); + celerLedger.confirmSettle(channelId); + } + + function test_confirmSettle_invalidBalance_resetsToOperable() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(100), 0]); + + // Construct a settle scenario where peer0's transferOut > peer0's deposit. + // settleBalance underflows in `_validateSettleBalance` and returns invalid. + // peer0 deposit = 100, transferOut = 200. + bytes memory s0 = _buildSignedSimplex(channelId, peer0, 1, 200); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + + vm.prank(peer0); + celerLedger.intendSettle(array); + + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + celerLedger.confirmSettle(channelId); + + // Channel must reset to Operable, not Closed. + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Operable)); + // Per-peer state should have been wiped. + (, uint256[2] memory transferOuts) = celerLedger.getTransferOutMap(channelId); + assertEq(transferOuts[0], 0); + assertEq(transferOuts[1], 0); + } + + // ========================================================================= + // 10. State / migration getters + // ========================================================================= + + function test_getChannelStatusNum_tracksOperableCount() public { + assertEq(celerLedger.getChannelStatusNum(uint256(LedgerStruct.ChannelStatus.Operable)), 0); + + _openZeroEthChannel(); + assertEq(celerLedger.getChannelStatusNum(uint256(LedgerStruct.ChannelStatus.Operable)), 1); + + _openZeroEthChannel(); + assertEq(celerLedger.getChannelStatusNum(uint256(LedgerStruct.ChannelStatus.Operable)), 2); + } + + function test_getTokenContract_andTokenType_forEthChannel() public { + bytes32 channelId = _openZeroEthChannel(); + assertEq(celerLedger.getTokenContract(channelId), address(0)); + // PbEntity.TokenType.ETH = 1 + assertEq(uint256(celerLedger.getTokenType(channelId)), 1); + } + + function test_getCooperativeWithdrawSeqNum_returnsLatestSeq() public { + celerLedger.disableBalanceLimits(); + bytes32 channelId = _openFundedEthChannel([uint256(200), 0]); + assertEq(celerLedger.getCooperativeWithdrawSeqNum(channelId), 0); + + bytes memory request = _buildCoopWithdraw(channelId, 1, peer0, 50, block.number + 1000, bytes32(0)); + celerLedger.cooperativeWithdraw(request); + + assertEq(celerLedger.getCooperativeWithdrawSeqNum(channelId), 1); + } + + function test_getMigratedTo_zeroIfNotMigrated() public { + bytes32 channelId = _openZeroEthChannel(); + assertEq(celerLedger.getMigratedTo(channelId), address(0)); + } + + function test_getDisputeTimeout_returnsConfigured() public { + bytes32 channelId = _openZeroEthChannel(); + assertEq(celerLedger.getDisputeTimeout(channelId), DISPUTE_TIMEOUT); + } + + function test_getStateSeqNumMap_initiallyZero() public { + bytes32 channelId = _openZeroEthChannel(); + (address[2] memory addrs, uint256[2] memory seqs) = celerLedger.getStateSeqNumMap(channelId); + assertEq(addrs[0], peer0); + assertEq(addrs[1], peer1); + assertEq(seqs[0], 0); + assertEq(seqs[1], 0); + } + + function test_getTransferOutMap_initiallyZero() public { + bytes32 channelId = _openZeroEthChannel(); + (, uint256[2] memory transferOuts) = celerLedger.getTransferOutMap(channelId); + assertEq(transferOuts[0], 0); + assertEq(transferOuts[1], 0); + } + + function test_getNextPayIdListHashMap_initiallyZero() public { + bytes32 channelId = _openZeroEthChannel(); + (, bytes32[2] memory hashes) = celerLedger.getNextPayIdListHashMap(channelId); + assertEq(hashes[0], bytes32(0)); + assertEq(hashes[1], bytes32(0)); + } + + function test_getLastPayResolveDeadlineMap_initiallyZero() public { + bytes32 channelId = _openZeroEthChannel(); + (, uint256[2] memory deadlines) = celerLedger.getLastPayResolveDeadlineMap(channelId); + assertEq(deadlines[0], 0); + assertEq(deadlines[1], 0); + } + + function test_getPendingPayOutMap_initiallyZero() public { + bytes32 channelId = _openZeroEthChannel(); + (, uint256[2] memory pending) = celerLedger.getPendingPayOutMap(channelId); + assertEq(pending[0], 0); + assertEq(pending[1], 0); + } + + // ========================================================================= + // 11. Connection getters + // ========================================================================= + + function test_getEthPool_returnsConfiguredPool() public view { + assertEq(celerLedger.getEthPool(), address(ethPool)); + } + + function test_getPayRegistry_returnsConfiguredRegistry() public view { + assertEq(celerLedger.getPayRegistry(), address(payRegistry)); + } + + function test_getCelerWallet_returnsConfiguredWallet() public view { + assertEq(celerLedger.getCelerWallet(), address(celerWallet)); + } + + function test_getBalanceLimit_returnsZeroByDefault() public view { + assertEq(celerLedger.getBalanceLimit(address(0)), 0); + } + + // ========================================================================= + // 12. Internal helpers + // ========================================================================= + + function _setEthBalanceLimit(uint256 _limit) internal { + address[] memory tokens = new address[](1); + tokens[0] = address(0); + uint256[] memory limits = new uint256[](1); + limits[0] = _limit; + celerLedger.setBalanceLimits(tokens, limits); + } + + /// @dev Resolve a single hash-lock-only ConditionalPay (peer0 → peer1) at max + /// amount. Returns the resulting pay id. + function _resolveSingleHashLockPay(uint256 _maxAmount, bytes memory _preimage, uint256 _payTimestamp) + internal + returns (bytes32) + { + Fixtures.Condition[] memory conds = new Fixtures.Condition[](1); + conds[0] = Fixtures.condHashLock(keccak256(_preimage)); + + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: _payTimestamp, + src: peer0, + dest: peer1, + conditions: conds, + logicType: 0, + maxAmount: _maxAmount, + resolveDeadline: 9_999_999, + resolveTimeout: 5, + payResolver: address(payResolver) + }); + bytes memory payBytes = Fixtures.encConditionalPay(pay); + + bytes[] memory preimages = new bytes[](1); + preimages[0] = _preimage; + payResolver.resolvePaymentByConditions(Fixtures.encResolvePayByConditionsRequest(payBytes, preimages)); + + return keccak256(abi.encodePacked(keccak256(payBytes), address(payResolver))); + } + + /// @dev Build a co-signed simplex state for peer0 with a single-pay PayIdList. + function _buildSimplexWithSinglePay(bytes32 _channelId, bytes32 _payId, uint256 _totalPending) + internal + view + returns (bytes memory) + { + bytes32[] memory ids = new bytes32[](1); + ids[0] = _payId; + return _buildSimplexWithPayList(_channelId, Fixtures.encPayIdList(ids, bytes32(0)), _totalPending); + } + + /// @dev Build a co-signed simplex state for peer0 with a custom PayIdList payload. + function _buildSimplexWithPayList(bytes32 _channelId, bytes memory _payIdList, uint256 _totalPending) + internal + view + returns (bytes memory) + { + Fixtures.SimplexState memory s = Fixtures.SimplexState({ + channelId: _channelId, + peerFrom: peer0, + seqNum: 1, + transferAmount: 0, + pendingPayIds: _payIdList, + lastPayResolveDeadline: block.number + 1000, + totalPendingAmount: _totalPending + }); + bytes memory simplex = Fixtures.encSimplexPaymentChannel(s); + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, simplex); + return Fixtures.encSignedSimplexState(simplex, sigs); + } +} diff --git a/test/CelerLedger.Migrate.t.sol b/test/CelerLedger.Migrate.t.sol new file mode 100644 index 0000000..462a75f --- /dev/null +++ b/test/CelerLedger.Migrate.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {LedgerTestBase} from "./utils/LedgerTestBase.t.sol"; +import {LedgerStruct} from "../src/lib/ledgerlib/LedgerStruct.sol"; +import {CelerLedger} from "../src/CelerLedger.sol"; +import {Fixtures} from "./utils/Fixtures.sol"; +import {SignUtil} from "./utils/SignUtil.sol"; + +/** + * @title CelerLedger migration tests + * @notice Unit tests for peer-controlled cross-version channel migration. + * Deploys two CelerLedger versions sharing the same CelerWallet, opens a + * channel on the old ledger, and migrates it to the new one. Covers both + * Operable and Settling source states for ETH and ERC-20 channels, plus the + * expired-deadline and wrong-from-ledger revert paths. + */ +contract CelerLedgerMigrateTest is LedgerTestBase { + /// @notice The old ledger is `celerLedger` from {LedgerTestBase}; deploy a sibling. + CelerLedger internal celerLedgerNew; + + function setUp() public override { + super.setUp(); + celerLedgerNew = new CelerLedger(address(ethPool), address(payRegistry), address(celerWallet)); + vm.label(address(celerLedgerNew), "CelerLedgerNew"); + + // Disable balance limits on both for simplicity. + celerLedger.disableBalanceLimits(); + celerLedgerNew.disableBalanceLimits(); + + // Approve the new ledger from the EthPool too. + vm.prank(peer0); + ethPool.approve(address(celerLedgerNew), type(uint256).max); + vm.prank(peer1); + ethPool.approve(address(celerLedgerNew), type(uint256).max); + + // Fund both peers with ERC20 + approve both ledgers. + erc20.transfer(peer0, 1_000_000); + erc20.transfer(peer1, 1_000_000); + vm.prank(peer0); + erc20.approve(address(celerLedger), type(uint256).max); + vm.prank(peer1); + erc20.approve(address(celerLedger), type(uint256).max); + } + + // ------------------------------------------------------------------------- + // Operable channel migration + // ------------------------------------------------------------------------- + + function test_migrate_operableEthChannel_succeeds() public { + bytes32 channelId = _openFundedEthChannel([uint256(100), 200]); + _assertOperatorOnChannel(channelId, address(celerLedger)); + + _migrate(channelId); + + _assertMigratedFromOldToNew(channelId); + } + + function test_migrate_operableErc20Channel_succeeds() public { + bytes32 channelId = _openFundedErc20Channel([uint256(100), 200]); + _assertOperatorOnChannel(channelId, address(celerLedger)); + + _migrate(channelId); + + _assertMigratedFromOldToNew(channelId); + } + + // ------------------------------------------------------------------------- + // Settling channel migration (migration outranks intendSettle) + // ------------------------------------------------------------------------- + + function test_migrate_settlingEthChannel_returnsToOperableOnNewLedger() public { + bytes32 channelId = _openFundedEthChannel([uint256(100), 200]); + + // intendSettle on the old ledger to put the channel into Settling. + bytes memory s0 = _buildSignedSimplex(channelId, peer0, 1, 0); + bytes memory s1 = _buildSignedSimplex(channelId, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + vm.prank(peer0); + celerLedger.intendSettle(array); + assertEq(uint256(celerLedger.getChannelStatus(channelId)), uint256(LedgerStruct.ChannelStatus.Settling)); + + _migrate(channelId); + + _assertMigratedFromOldToNew(channelId); + } + + // ------------------------------------------------------------------------- + // Negative cases + // ------------------------------------------------------------------------- + + function test_migrate_pastDeadline_reverts() public { + bytes32 channelId = _openFundedEthChannel([uint256(100), 200]); + + bytes memory request = _buildMigrationRequest(channelId, address(celerLedger), address(celerLedgerNew), 1); + + vm.roll(2); + vm.expectRevert(bytes("Passed migration deadline")); + celerLedgerNew.migrateChannelFrom(address(celerLedger), request); + } + + function test_migrate_wrongFromLedger_reverts() public { + bytes32 channelId = _openFundedEthChannel([uint256(100), 200]); + + // Migration request claims a different fromLedger (not address(celerLedger)). + bytes memory request = _buildMigrationRequest(channelId, stranger, address(celerLedgerNew), 99_999_999); + + vm.expectRevert(bytes("From ledger address is not this")); + celerLedgerNew.migrateChannelFrom(address(celerLedger), request); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + function _openFundedErc20Channel(uint256[2] memory _amounts) internal returns (bytes32) { + uint256 deadline = openDeadlineCursor++; + (bytes memory request,, bytes32 channelId) = _buildOpenErc20(address(erc20), _amounts, deadline); + celerLedger.openChannel(request); + return channelId; + } + + function _buildMigrationRequest(bytes32 _channelId, address _fromLedger, address _toLedger, uint256 _deadline) + internal + view + returns (bytes memory) + { + Fixtures.ChannelMigrationInfo memory info = Fixtures.ChannelMigrationInfo({ + channelId: _channelId, + fromLedger: _fromLedger, + toLedger: _toLedger, + migrationDeadline: _deadline + }); + bytes memory body = Fixtures.encChannelMigrationInfo(info); + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, body); + return Fixtures.encChannelMigrationRequest(body, sigs); + } + + function _migrate(bytes32 _channelId) internal { + bytes memory request = + _buildMigrationRequest(_channelId, address(celerLedger), address(celerLedgerNew), block.number + 100_000); + celerLedgerNew.migrateChannelFrom(address(celerLedger), request); + } + + function _assertOperatorOnChannel(bytes32 _channelId, address _expectedOperator) internal view { + assertEq(celerWallet.getOperator(_channelId), _expectedOperator); + } + + function _assertMigratedFromOldToNew(bytes32 _channelId) internal view { + // Wallet operator is now the new ledger. + _assertOperatorOnChannel(_channelId, address(celerLedgerNew)); + + // Old ledger marks the channel Migrated and records migratedTo. + assertEq(uint256(celerLedger.getChannelStatus(_channelId)), uint256(LedgerStruct.ChannelStatus.Migrated)); + assertEq(celerLedger.getMigratedTo(_channelId), address(celerLedgerNew)); + + // New ledger has the channel as Operable. + assertEq(uint256(celerLedgerNew.getChannelStatus(_channelId)), uint256(LedgerStruct.ChannelStatus.Operable)); + + // Channel-level metadata round-trips. + (uint256 oldDispute, uint256 oldType, address oldToken,) = celerLedger.getChannelMigrationArgs(_channelId); + (uint256 newDispute, uint256 newType, address newToken,) = celerLedgerNew.getChannelMigrationArgs(_channelId); + assertEq(oldDispute, newDispute); + assertEq(oldType, newType); + assertEq(oldToken, newToken); + + // Per-peer balances round-trip. + (, uint256[2] memory oldDeposits, uint256[2] memory oldWithdrawals) = celerLedger.getBalanceMap(_channelId); + (, uint256[2] memory newDeposits, uint256[2] memory newWithdrawals) = celerLedgerNew.getBalanceMap(_channelId); + assertEq(oldDeposits[0], newDeposits[0]); + assertEq(oldDeposits[1], newDeposits[1]); + assertEq(oldWithdrawals[0], newWithdrawals[0]); + assertEq(oldWithdrawals[1], newWithdrawals[1]); + } +} diff --git a/test/CelerWallet.t.sol b/test/CelerWallet.t.sol new file mode 100644 index 0000000..c90b63c --- /dev/null +++ b/test/CelerWallet.t.sol @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {CelerWallet} from "../src/CelerWallet.sol"; +import {WalletTestHelper} from "../src/helper/WalletTestHelper.sol"; +import {ERC20ExampleToken} from "../src/helper/ERC20ExampleToken.sol"; + +/** + * @title CelerWallet tests + * @notice Unit tests for the multi-owner / multi-token wallet that holds funds + * for every channel in the AgentPay network. Covers wallet creation, ETH / + * ERC-20 deposits and withdrawals, inter-wallet transfer, operator transfer + * (direct + multi-owner proposal), pause / drain controls, and getter behavior. + * + * @dev Pause control runs through `Ownable` — the contract owner is the pauser. + */ +contract CelerWalletTest is Test { + // ========================================================================= + // Setup + // ========================================================================= + + CelerWallet internal wallet; + WalletTestHelper internal walletHelper; + ERC20ExampleToken internal token; + + address internal owner; // contract owner (deployer); pauses the wallet + address internal owner0 = makeAddr("owner0"); // wallet owner #1 + address internal owner1 = makeAddr("owner1"); // wallet owner #2 + address internal operator = makeAddr("operator"); + address internal newOperator = makeAddr("newOperator"); + address internal stranger = makeAddr("stranger"); + + address[] internal walletOwners; + + bytes32 internal walletId; + bytes32 internal walletId2; + + event Paused(address account); + event ChangeOperator(bytes32 indexed walletId, address indexed oldOperator, address indexed newOperator); + event ProposeNewOperator(bytes32 indexed walletId, address indexed newOperator, address indexed proposer); + event DepositToWallet(bytes32 indexed walletId, address indexed tokenAddress, uint256 amount); + event WithdrawFromWallet( + bytes32 indexed walletId, address indexed tokenAddress, address indexed receiver, uint256 amount + ); + event TransferToWallet( + bytes32 indexed fromWalletId, + bytes32 indexed toWalletId, + address indexed tokenAddress, + address receiver, + uint256 amount + ); + event DrainToken(address indexed tokenAddress, address indexed receiver, uint256 amount); + + function setUp() public { + owner = address(this); + token = new ERC20ExampleToken(); + wallet = new CelerWallet(); + walletHelper = new WalletTestHelper(address(wallet)); + + walletOwners.push(owner0); + walletOwners.push(owner1); + + // Create two wallets sharing the same owner pair, and seed the first with + // ETH + ERC-20 deposits so that withdraw / transfer / drain cases have + // funds to operate on. + walletHelper.create(walletOwners, operator, 0); + walletId = _walletIdViaHelper(0); + + vm.deal(operator, 10 ether); + vm.prank(operator); + wallet.depositETH{value: 100}(walletId); + + token.transfer(owner0, 100_000); + vm.prank(owner0); + token.approve(address(wallet), 100_000); + vm.prank(owner0); + wallet.depositERC20(walletId, address(token), 200); + + walletHelper.create(walletOwners, operator, 1); + walletId2 = _walletIdViaHelper(1); + } + + /// @dev Replicates `WalletTestHelper.create` + `CelerWallet.create` id derivation: + /// helper hashes the user nonce into `bytes32 n`, then the wallet derives + /// `id = keccak256(walletAddr, helperAddr, n)`. + function _walletIdViaHelper(uint256 _nonce) internal view returns (bytes32) { + bytes32 n = keccak256(abi.encodePacked(_nonce)); + return keccak256(abi.encodePacked(address(wallet), address(walletHelper), n)); + } + + // ========================================================================= + // Initial state & wallet creation + // ========================================================================= + + function test_initialState_ownerIsDeployer_unpaused() public view { + assertEq(wallet.owner(), owner); + assertEq(wallet.paused(), false); + } + + function test_walletNum_incrementsOnEachCreate() public view { + assertEq(wallet.walletNum(), 2); + } + + function test_create_revertsForZeroOperator() public { + address[] memory owners = new address[](2); + owners[0] = owner0; + owners[1] = owner1; + vm.expectRevert(bytes("New operator is address(0)")); + wallet.create(owners, address(0), bytes32(uint256(99))); + } + + function test_create_revertsForDuplicateId() public { + address[] memory owners = new address[](2); + owners[0] = owner0; + owners[1] = owner1; + wallet.create(owners, operator, bytes32(uint256(42))); + vm.expectRevert(bytes("Occupied wallet id")); + wallet.create(owners, operator, bytes32(uint256(42))); + } + + function test_getWalletOwners_returnsBothOwners() public view { + address[] memory owners = wallet.getWalletOwners(walletId); + assertEq(owners.length, 2); + assertEq(owners[0], owner0); + assertEq(owners[1], owner1); + } + + // ========================================================================= + // Deposits + // ========================================================================= + + function test_depositETH_emitsEvent_creditsBalance() public { + vm.deal(stranger, 1 ether); + vm.expectEmit(true, true, false, true, address(wallet)); + emit DepositToWallet(walletId, address(0), 25); + vm.prank(stranger); + wallet.depositETH{value: 25}(walletId); + + assertEq(wallet.getBalance(walletId, address(0)), 100 + 25); + } + + function test_depositERC20_emitsEvent_pullsTokens() public { + token.transfer(stranger, 1000); + vm.prank(stranger); + token.approve(address(wallet), 1000); + + vm.expectEmit(true, true, false, true, address(wallet)); + emit DepositToWallet(walletId, address(token), 50); + vm.prank(stranger); + wallet.depositERC20(walletId, address(token), 50); + + assertEq(wallet.getBalance(walletId, address(token)), 200 + 50); + } + + // ========================================================================= + // Withdrawals + // ========================================================================= + + function test_withdraw_succeeds_emitsEvent() public { + vm.expectEmit(true, true, true, true, address(wallet)); + emit WithdrawFromWallet(walletId, address(token), owner0, 80); + vm.prank(operator); + wallet.withdraw(walletId, address(token), owner0, 80); + + assertEq(wallet.getBalance(walletId, address(token)), 200 - 80); + assertEq(token.balanceOf(owner0), 100_000 - 200 + 80); + } + + function test_withdraw_revertsForNonOperator() public { + vm.expectRevert(bytes("msg.sender is not operator")); + vm.prank(stranger); + wallet.withdraw(walletId, address(token), owner0, 50); + } + + function test_withdraw_revertsForNonOwnerReceiver() public { + vm.expectRevert(bytes("Given address is not wallet owner")); + vm.prank(operator); + wallet.withdraw(walletId, address(token), stranger, 50); + } + + // ========================================================================= + // Inter-wallet transfer + // ========================================================================= + + function test_transferToWallet_succeeds_emitsEvent() public { + vm.expectEmit(true, true, true, true, address(wallet)); + emit TransferToWallet(walletId, walletId2, address(token), owner0, 50); + vm.prank(operator); + wallet.transferToWallet(walletId, walletId2, address(token), owner0, 50); + + assertEq(wallet.getBalance(walletId, address(token)), 200 - 50); + assertEq(wallet.getBalance(walletId2, address(token)), 50); + } + + function test_transferToWallet_revertsForReceiverNotInBoth() public { + vm.expectRevert(bytes("Given address is not wallet owner")); + vm.prank(operator); + wallet.transferToWallet(walletId, walletId2, address(token), stranger, 50); + } + + // ========================================================================= + // Operator transfer — direct path & multi-owner proposal + // ========================================================================= + + function test_transferOperatorship_byOperator_succeeds_emitsEvent() public { + vm.expectEmit(true, true, true, false, address(wallet)); + emit ChangeOperator(walletId, operator, newOperator); + vm.prank(operator); + wallet.transferOperatorship(walletId, newOperator); + + assertEq(wallet.getOperator(walletId), newOperator); + } + + function test_transferOperatorship_revertsForNonOperator() public { + vm.expectRevert(bytes("msg.sender is not operator")); + vm.prank(stranger); + wallet.transferOperatorship(walletId, newOperator); + } + + function test_proposeNewOperator_unanimous_changesOperator() public { + // First owner proposes — operator does not change yet. + vm.expectEmit(true, true, true, false, address(wallet)); + emit ProposeNewOperator(walletId, newOperator, owner0); + vm.prank(owner0); + wallet.proposeNewOperator(walletId, newOperator); + + assertEq(wallet.getOperator(walletId), operator); + + // Second owner agrees → operator changes; vote tally is then cleared. + vm.expectEmit(true, true, true, false, address(wallet)); + emit ProposeNewOperator(walletId, newOperator, owner1); + vm.expectEmit(true, true, true, false, address(wallet)); + emit ChangeOperator(walletId, operator, newOperator); + vm.prank(owner1); + wallet.proposeNewOperator(walletId, newOperator); + + assertEq(wallet.getOperator(walletId), newOperator); + assertEq(wallet.getProposalVote(walletId, owner0), false); + assertEq(wallet.getProposalVote(walletId, owner1), false); + } + + function test_proposeNewOperator_differentProposalResetsVotes() public { + vm.prank(owner0); + wallet.proposeNewOperator(walletId, newOperator); + assertEq(wallet.getProposalVote(walletId, owner0), true); + + // owner1 proposes a different address — the prior tally is wiped. + address otherCandidate = makeAddr("otherCandidate"); + vm.prank(owner1); + wallet.proposeNewOperator(walletId, otherCandidate); + + assertEq(wallet.getProposalVote(walletId, owner0), false); + assertEq(wallet.getProposalVote(walletId, owner1), true); + assertEq(wallet.getProposedNewOperator(walletId), otherCandidate); + assertEq(wallet.getOperator(walletId), operator); + } + + function test_proposeNewOperator_revertsForZeroAddress() public { + vm.expectRevert(bytes("New operator is address(0)")); + vm.prank(owner0); + wallet.proposeNewOperator(walletId, address(0)); + } + + function test_proposeNewOperator_revertsForNonOwner() public { + vm.expectRevert(bytes("Given address is not wallet owner")); + vm.prank(stranger); + wallet.proposeNewOperator(walletId, newOperator); + } + + function test_proposeNewOperator_succeedsEvenWhenPaused() public { + wallet.pause(); + + vm.expectEmit(true, true, true, false, address(wallet)); + emit ProposeNewOperator(walletId, stranger, owner0); + vm.prank(owner0); + wallet.proposeNewOperator(walletId, stranger); + + assertEq(wallet.getProposedNewOperator(walletId), stranger); + } + + // ========================================================================= + // Pause / unpause / drain + // ========================================================================= + + function test_pause_succeedsForOwner_emitsPaused() public { + vm.expectEmit(false, false, false, true, address(wallet)); + emit Paused(owner); + + wallet.pause(); + + assertTrue(wallet.paused()); + } + + function test_pause_revertsForNonOwner() public { + vm.expectRevert(); + vm.prank(stranger); + wallet.pause(); + } + + function test_unpause_byOwner_resumesOperations() public { + wallet.pause(); + wallet.unpause(); + assertFalse(wallet.paused()); + + // Deposits work again after unpause. + vm.deal(stranger, 1 ether); + vm.prank(stranger); + wallet.depositETH{value: 5}(walletId); + assertEq(wallet.getBalance(walletId, address(0)), 100 + 5); + } + + function test_unpause_revertsForNonOwner() public { + wallet.pause(); + vm.expectRevert(); + vm.prank(stranger); + wallet.unpause(); + } + + function test_paused_blocksDepositsAndOperatorActions() public { + wallet.pause(); + + address[] memory localOwners = new address[](2); + localOwners[0] = owner0; + localOwners[1] = owner1; + vm.expectRevert(); + walletHelper.create(localOwners, operator, 99); + + vm.deal(operator, 1 ether); + vm.expectRevert(); + vm.prank(operator); + wallet.depositETH{value: 100}(walletId); + + vm.expectRevert(); + vm.prank(owner0); + wallet.depositERC20(walletId, address(token), 200); + + vm.expectRevert(); + vm.prank(operator); + wallet.withdraw(walletId, address(token), owner0, 100); + + vm.expectRevert(); + vm.prank(operator); + wallet.transferToWallet(walletId, walletId2, address(token), owner0, 50); + + vm.expectRevert(); + vm.prank(operator); + wallet.transferOperatorship(walletId, stranger); + } + + function test_drainToken_revertsWhenNotPaused() public { + vm.expectRevert(); + wallet.drainToken(address(0), owner, 100); + + vm.expectRevert(); + wallet.drainToken(address(token), owner, 200); + } + + function test_drainToken_succeedsWhenPaused() public { + // Use an EOA recipient so the ETH transfer succeeds. + address drainRecipient = makeAddr("drainRecipient"); + + wallet.pause(); + + vm.expectEmit(true, true, false, true, address(wallet)); + emit DrainToken(address(0), drainRecipient, 100); + wallet.drainToken(address(0), drainRecipient, 100); + assertEq(drainRecipient.balance, 100); + + vm.expectEmit(true, true, false, true, address(wallet)); + emit DrainToken(address(token), stranger, 200); + wallet.drainToken(address(token), stranger, 200); + assertEq(token.balanceOf(stranger), 200); + } + + // ========================================================================= + // Getters (revert paths) + // ========================================================================= + + function test_getProposalVote_revertsForNonOwner() public { + vm.expectRevert(bytes("Given address is not wallet owner")); + wallet.getProposalVote(walletId, stranger); + } + + function test_getProposedNewOperator_zeroByDefault() public view { + assertEq(wallet.getProposedNewOperator(walletId), address(0)); + } +} diff --git a/test/EthPool.t.sol b/test/EthPool.t.sol new file mode 100644 index 0000000..7c5ada0 --- /dev/null +++ b/test/EthPool.t.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {EthPool} from "../src/EthPool.sol"; + +/** + * @title EthPool tests + * @notice Unit tests for {EthPool}. Verifies the ERC20-shaped wrapper for + * native ETH: deposit, withdraw, allowance management, and `transferFrom`. + */ +contract EthPoolTest is Test { + EthPool internal pool; + + address payable internal alice; + address payable internal bob; + address payable internal recipient = payable(address(0x123456789)); + + event Deposit(address indexed receiver, uint256 value); + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function setUp() public { + pool = new EthPool(); + alice = payable(makeAddr("alice")); + bob = payable(makeAddr("bob")); + + // Fund alice and bob with ETH so they can interact with the pool. + vm.deal(alice, 10 ether); + vm.deal(bob, 10 ether); + } + + // ------------------------------------------------------------------------- + // deposit + // ------------------------------------------------------------------------- + + function test_deposit_creditsReceiver_emitsEvent() public { + vm.expectEmit(true, false, false, true, address(pool)); + emit Deposit(bob, 100); + + vm.prank(alice); + pool.deposit{value: 100}(bob); + + assertEq(pool.balanceOf(bob), 100); + } + + function test_deposit_revertsForZeroReceiver() public { + vm.expectRevert(bytes("Receiver address is 0")); + pool.deposit{value: 1}(address(0)); + } + + // ------------------------------------------------------------------------- + // withdraw + // ------------------------------------------------------------------------- + + function test_withdraw_failsWithoutDeposit() public { + // Solidity 0.8 will revert with arithmetic underflow when subtracting from 0. + vm.expectRevert(); + vm.prank(alice); + pool.withdraw(100); + } + + function test_withdraw_succeedsAfterDeposit_emitsTransfer() public { + // Deposit 100 to alice's balance. + vm.prank(alice); + pool.deposit{value: 100}(alice); + + uint256 aliceBalanceBefore = alice.balance; + + vm.expectEmit(true, true, false, true, address(pool)); + emit Transfer(alice, alice, 100); + + vm.prank(alice); + pool.withdraw(100); + + assertEq(pool.balanceOf(alice), 0); + assertEq(alice.balance, aliceBalanceBefore + 100); + } + + // ------------------------------------------------------------------------- + // approve / allowance + // ------------------------------------------------------------------------- + + function test_approve_setsAllowance_emitsEvent() public { + vm.expectEmit(true, true, false, true, address(pool)); + emit Approval(alice, bob, 200); + + vm.prank(alice); + bool ok = pool.approve(bob, 200); + + assertTrue(ok); + assertEq(pool.allowance(alice, bob), 200); + } + + function test_approve_revertsForZeroSpender() public { + vm.expectRevert(bytes("Spender address is 0")); + vm.prank(alice); + pool.approve(address(0), 100); + } + + // ------------------------------------------------------------------------- + // transferFrom + // ------------------------------------------------------------------------- + + function test_transferFrom_movesEthAndDecrementsAllowance() public { + // alice deposits 200 and approves bob for 200. + vm.prank(alice); + pool.deposit{value: 200}(alice); + vm.prank(alice); + pool.approve(bob, 200); + + uint256 recipientBefore = recipient.balance; + + // bob transfers 150 from alice's pool balance to recipient. + vm.expectEmit(true, true, false, true, address(pool)); + emit Approval(alice, bob, 50); // remaining allowance after decrement + vm.expectEmit(true, true, false, true, address(pool)); + emit Transfer(alice, recipient, 150); + + vm.prank(bob); + bool ok = pool.transferFrom(alice, recipient, 150); + assertTrue(ok); + + assertEq(pool.balanceOf(alice), 50); + assertEq(pool.allowance(alice, bob), 50); + assertEq(recipient.balance, recipientBefore + 150); + } + + function test_transferFrom_revertsWhenAllowanceTooSmall() public { + // alice deposits 200, approves bob for only 50. + vm.prank(alice); + pool.deposit{value: 200}(alice); + vm.prank(alice); + pool.approve(bob, 50); + + vm.expectRevert(); + vm.prank(bob); + pool.transferFrom(alice, recipient, 100); + } + + // ------------------------------------------------------------------------- + // increaseAllowance / decreaseAllowance + // ------------------------------------------------------------------------- + + function test_increaseAllowance_addsToCurrent() public { + vm.prank(alice); + pool.approve(bob, 50); + + vm.expectEmit(true, true, false, true, address(pool)); + emit Approval(alice, bob, 100); + + vm.prank(alice); + bool ok = pool.increaseAllowance(bob, 50); + + assertTrue(ok); + assertEq(pool.allowance(alice, bob), 100); + } + + function test_decreaseAllowance_subtractsFromCurrent() public { + vm.prank(alice); + pool.approve(bob, 100); + + vm.expectEmit(true, true, false, true, address(pool)); + emit Approval(alice, bob, 20); + + vm.prank(alice); + bool ok = pool.decreaseAllowance(bob, 80); + + assertTrue(ok); + assertEq(pool.allowance(alice, bob), 20); + } + + // ------------------------------------------------------------------------- + // Round-trip fuzz + // ------------------------------------------------------------------------- + + function testFuzz_depositWithdraw_roundTripPreservesBalance(uint96 _amount) public { + vm.assume(_amount > 0); + vm.deal(alice, uint256(_amount)); + + uint256 aliceEthBefore = alice.balance; + + vm.prank(alice); + pool.deposit{value: _amount}(alice); + assertEq(pool.balanceOf(alice), _amount); + + vm.prank(alice); + pool.withdraw(_amount); + assertEq(pool.balanceOf(alice), 0); + assertEq(alice.balance, aliceEthBefore); + } +} diff --git a/test/GasReport.t.sol b/test/GasReport.t.sol new file mode 100644 index 0000000..f4e20d3 --- /dev/null +++ b/test/GasReport.t.sol @@ -0,0 +1,888 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {VmSafe} from "forge-std/Vm.sol"; +import {LedgerTestBase} from "./utils/LedgerTestBase.t.sol"; +import {Fixtures} from "./utils/Fixtures.sol"; +import {SignUtil} from "./utils/SignUtil.sol"; +import {CelerLedger} from "../src/CelerLedger.sol"; +import {CelerWallet} from "../src/CelerWallet.sol"; +import {EthPool} from "../src/EthPool.sol"; +import {PayRegistry} from "../src/PayRegistry.sol"; +import {PayResolver} from "../src/PayResolver.sol"; +import {VirtContractResolver} from "../src/VirtContractResolver.sol"; +import {BooleanCondMock} from "../src/helper/BooleanCondMock.sol"; + +/** + * @title Gas report generator + * @notice Produces human-readable gas reports under [`gas_logs/`](../gas_logs/), + * one file per top-level component. Each report lists per-call gas for the + * operations a reviewer typically cares about; `fine_granularity/` adds scaling + * sweeps for the operations whose cost is parameterized (pay-list size, batch + * size). + * + * @dev Top-level reports (per-call gas): + * - `gas_logs/CelerLedger-ETH.txt` + * - `gas_logs/CelerLedger-ERC20.txt` + * - `gas_logs/CelerLedger-Migrate.txt` + * - `gas_logs/EthPool.txt` + * - `gas_logs/PayResolver.txt` + * - `gas_logs/VirtContractResolver.txt` + * + * Fine-granularity scaling sweeps: + * - `gas_logs/fine_granularity/IntendSettle-OneState.txt` + * - `gas_logs/fine_granularity/ClearPays.txt` + * - `gas_logs/fine_granularity/DepositEthInBatch.txt` + * + * @dev Run via: + * forge test --match-contract GasReport -vv + * Each test writes one report file. Measurements use `gasleft()` deltas around + * the operation under test, which closely tracks the actual gas cost. When + * running under `forge coverage`, file writes are skipped so coverage does not + * dirty the committed `gas_logs/` baselines with instrumentation-inflated numbers. + */ +contract GasReport is LedgerTestBase { + function setUp() public override { + super.setUp(); + celerLedger.disableBalanceLimits(); + + erc20.transfer(peer0, 1_000_000_000); + erc20.transfer(peer1, 1_000_000_000); + vm.prank(peer0); + erc20.approve(address(celerLedger), type(uint256).max); + vm.prank(peer1); + erc20.approve(address(celerLedger), type(uint256).max); + } + + // ========================================================================= + // Top-level report — CelerLedger ETH + // ========================================================================= + + function test_writeReport_CelerLedgerEth() public { + string memory s = "********** Gas Measurement: CelerLedger ETH **********\n\n"; + s = string.concat(s, "***** Deploy Gas Used *****\n"); + s = string.concat(s, _deployRow("VirtContractResolver", _measureDeploy_virt())); + s = string.concat(s, _deployRow("EthPool", _measureDeploy_ethPool())); + s = string.concat(s, _deployRow("PayRegistry", _measureDeploy_payRegistry())); + s = string.concat(s, _deployRow("CelerWallet", _measureDeploy_celerWallet())); + s = string.concat(s, _deployRow("PayResolver", _measureDeploy_payResolver())); + s = string.concat(s, _deployRow("CelerLedger", _measureDeploy_celerLedger())); + + s = string.concat(s, "\n***** Function Calls Gas Used *****\n"); + s = string.concat(s, _row("openChannel() with zero deposit", _measure_openChannel_zeroDeposit())); + s = string.concat(s, _row("openChannel() using EthPool and msg.value", _measure_openChannel_funded())); + s = string.concat(s, _row("setBalanceLimits()", _measure_setBalanceLimits())); + s = string.concat(s, _row("disableBalanceLimits()", _measure_disableBalanceLimits())); + s = string.concat(s, _row("enableBalanceLimits()", _measure_enableBalanceLimits())); + s = string.concat(s, _row("deposit() via msg.value", _measure_deposit_msgValue())); + s = string.concat(s, _row("deposit() via EthPool", _measure_deposit_ethPool())); + s = string.concat(s, _row("depositInBatch() with 5 deposits", _measure_depositInBatch_5())); + s = string.concat(s, _row("intendWithdraw()", _measure_intendWithdraw())); + s = string.concat(s, _row("vetoWithdraw()", _measure_vetoWithdraw())); + s = string.concat(s, _row("confirmWithdraw()", _measure_confirmWithdraw())); + s = string.concat(s, _row("cooperativeWithdraw()", _measure_cooperativeWithdraw())); + s = string.concat(s, _row("snapshotStates() with one non-null simplex state", _measure_snapshotStates())); + s = string.concat(s, _row("intendSettle() with a null state", _measure_intendSettle_nullState())); + s = string.concat( + s, _row("intendSettle() with two 2-payment-hashList states", _measure_intendSettle_twoStatesTwoPays()) + ); + s = string.concat(s, _row("clearPays() with 2 payments", _measure_clearPays_twoPayments())); + s = string.concat(s, _row("confirmSettle()", _measure_confirmSettle())); + s = string.concat(s, _row("cooperativeSettle()", _measure_cooperativeSettle())); + + _writeReport("gas_logs/CelerLedger-ETH.txt", s); + } + + // ========================================================================= + // Top-level report — CelerLedger ERC20 + // ========================================================================= + + function test_writeReport_CelerLedgerErc20() public { + string memory s = "********** Gas Measurement: CelerLedger ERC20 **********\n\n"; + s = string.concat(s, "***** Function Calls Gas Used *****\n"); + s = string.concat(s, _row("openChannel() with zero deposit", _measure_openChannel_erc20_zero())); + s = string.concat(s, _row("openChannel() with non-zero ERC20 deposits", _measure_openChannel_erc20_funded())); + s = string.concat(s, _row("deposit()", _measure_deposit_erc20())); + s = string.concat(s, _row("cooperativeWithdraw()", _measure_cooperativeWithdraw_erc20())); + s = string.concat(s, _row("cooperativeSettle()", _measure_cooperativeSettle_erc20())); + _writeReport("gas_logs/CelerLedger-ERC20.txt", s); + } + + // ========================================================================= + // Top-level report — Migration + // ========================================================================= + + function test_writeReport_CelerLedgerMigrate() public { + string memory s = "********** Gas Measurement: CelerLedger Migration **********\n\n"; + s = string.concat(s, "***** Function Calls Gas Used *****\n"); + s = string.concat(s, _row("migrateChannelFrom() an Operable ETH channel", _measure_migrate_operableEth())); + s = string.concat(s, _row("migrateChannelFrom() a Settling ETH channel", _measure_migrate_settlingEth())); + s = string.concat(s, _row("migrateChannelFrom() an Operable ERC20 channel", _measure_migrate_operableErc20())); + _writeReport("gas_logs/CelerLedger-Migrate.txt", s); + } + + // ========================================================================= + // Top-level report — PayResolver / VirtContractResolver / EthPool + // ========================================================================= + + function test_writeReport_PayResolver() public { + string memory s = "********** Gas Measurement: PayResolver **********\n\n"; + s = string.concat(s, "***** Function Calls Gas Used *****\n"); + s = string.concat(s, _row("resolvePaymentByConditions()", _measure_resolveByConditions())); + s = string.concat(s, _row("resolvePaymentByVouchedResult()", _measure_resolveByVouchedResult())); + _writeReport("gas_logs/PayResolver.txt", s); + } + + function test_writeReport_VirtContractResolver() public { + string memory s = "********** Gas Measurement: VirtContractResolver **********\n\n"; + s = string.concat(s, "***** Function Calls Gas Used *****\n"); + s = string.concat(s, _row("deploy() - BooleanCondMock", _measure_virtDeploy())); + _writeReport("gas_logs/VirtContractResolver.txt", s); + } + + function test_writeReport_EthPool() public { + string memory s = "********** Gas Measurement: EthPool **********\n\n"; + s = string.concat(s, "***** Function Calls Gas Used *****\n"); + s = string.concat(s, _row("deposit()", _measure_ethPool_deposit())); + s = string.concat(s, _row("withdraw()", _measure_ethPool_withdraw())); + s = string.concat(s, _row("approve()", _measure_ethPool_approve())); + s = string.concat(s, _row("transferFrom()", _measure_ethPool_transferFrom())); + s = string.concat(s, _row("increaseAllowance()", _measure_ethPool_increaseAllowance())); + s = string.concat(s, _row("decreaseAllowance()", _measure_ethPool_decreaseAllowance())); + _writeReport("gas_logs/EthPool.txt", s); + } + + // ========================================================================= + // Fine-granularity report — intendSettle one state with N pays + // ========================================================================= + + function test_writeReport_FineGran_IntendSettleOneState() public { + // Representative sweep covering small / medium / large pay-list sizes. + // The set is intentionally short for quick refresh; expand here if a + // tighter regression curve is needed. + uint256[] memory sizes = new uint256[](7); + sizes[0] = 1; + sizes[1] = 5; + sizes[2] = 10; + sizes[3] = 25; + sizes[4] = 50; + sizes[5] = 100; + sizes[6] = 200; + + string memory s = "********** Gas Measurement of intendSettle() one state with multi pays **********\n\n"; + s = string.concat(s, "pay number in head payIdList\tused gas\n"); + + for (uint256 i = 0; i < sizes.length; i++) { + uint256 gasUsed = _measure_intendSettle_oneState_nPays(sizes[i]); + s = string.concat(s, vm.toString(sizes[i]), "\t", vm.toString(gasUsed), "\n"); + } + + _writeReport("gas_logs/fine_granularity/IntendSettle-OneState.txt", s); + } + + // ========================================================================= + // Fine-granularity report — depositInBatch + // ========================================================================= + + function test_writeReport_FineGran_DepositEthInBatch() public { + uint256[] memory sizes = new uint256[](6); + sizes[0] = 1; + sizes[1] = 5; + sizes[2] = 10; + sizes[3] = 25; + sizes[4] = 50; + sizes[5] = 75; + + string memory s = "********** Gas Measurement of depositInBatch() - ETH **********\n\n"; + s = string.concat(s, "batch size\tused gas\n"); + + for (uint256 i = 0; i < sizes.length; i++) { + uint256 gasUsed = _measure_depositInBatch_n(sizes[i]); + s = string.concat(s, vm.toString(sizes[i]), "\t", vm.toString(gasUsed), "\n"); + } + + _writeReport("gas_logs/fine_granularity/DepositEthInBatch.txt", s); + } + + // ========================================================================= + // Fine-granularity report — clearPays (multi-segment list) + // ========================================================================= + + function test_writeReport_FineGran_ClearPays() public { + uint256[] memory sizes = new uint256[](6); + sizes[0] = 1; + sizes[1] = 5; + sizes[2] = 10; + sizes[3] = 25; + sizes[4] = 50; + sizes[5] = 100; + + string memory s = "********** Gas Measurement of clearPays() - N pays per following list **********\n\n"; + s = string.concat(s, "pay number per following payIdList\tused gas\n"); + + for (uint256 i = 0; i < sizes.length; i++) { + uint256 gasUsed = _measure_clearPays_n(sizes[i]); + s = string.concat(s, vm.toString(sizes[i]), "\t", vm.toString(gasUsed), "\n"); + } + + _writeReport("gas_logs/fine_granularity/ClearPays.txt", s); + } + + // ========================================================================= + // Formatting helpers + // ========================================================================= + + function _row(string memory _label, uint256 _gas) internal pure returns (string memory) { + return string.concat(_label, ": ", vm.toString(_gas), "\n"); + } + + function _deployRow(string memory _name, uint256 _gas) internal pure returns (string memory) { + return string.concat(_name, " Deploy Gas: ", vm.toString(_gas), "\n"); + } + + function _writeReport(string memory _path, string memory _data) internal { + // Coverage disables optimizer/viaIR and inflates gas numbers, so do not + // overwrite the committed regression baselines in that context. + if (vm.isContext(VmSafe.ForgeContext.Coverage)) { + return; + } + + vm.writeFile(_path, _data); + } + + // ========================================================================= + // Per-op measurement helpers + // ========================================================================= + // Pattern: snapshot → setup → measure (gasleft delta) → revert. The snapshot + // bracket isolates each measurement so accumulated state from earlier ops + // doesn't leak into the next gas number. + + function _measureDeploy_virt() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + uint256 g0 = gasleft(); + new VirtContractResolver(); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measureDeploy_ethPool() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + uint256 g0 = gasleft(); + new EthPool(); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measureDeploy_payRegistry() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + uint256 g0 = gasleft(); + new PayRegistry(); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measureDeploy_celerWallet() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + uint256 g0 = gasleft(); + new CelerWallet(); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measureDeploy_payResolver() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + uint256 g0 = gasleft(); + new PayResolver(address(payRegistry), address(virtResolver)); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measureDeploy_celerLedger() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + uint256 g0 = gasleft(); + new CelerLedger(address(ethPool), address(payRegistry), address(celerWallet)); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_openChannel_zeroDeposit() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory request,,) = _buildOpenEth([uint256(0), 0], 0, openDeadlineCursor++); + uint256 g0 = gasleft(); + celerLedger.openChannel(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_openChannel_funded() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory request,,) = _buildOpenEth([uint256(100), 200], 0, openDeadlineCursor++); + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.openChannel{value: 100}(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_openChannel_erc20_zero() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory request,,) = _buildOpenErc20(address(erc20), [uint256(0), 0], openDeadlineCursor++); + uint256 g0 = gasleft(); + celerLedger.openChannel(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_openChannel_erc20_funded() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory request,,) = _buildOpenErc20(address(erc20), [uint256(100), 200], openDeadlineCursor++); + uint256 g0 = gasleft(); + celerLedger.openChannel(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_setBalanceLimits() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + celerLedger.enableBalanceLimits(); + address[] memory tokens = new address[](1); + tokens[0] = address(0); + uint256[] memory limits = new uint256[](1); + limits[0] = 1_000_000; + uint256 g0 = gasleft(); + celerLedger.setBalanceLimits(tokens, limits); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_disableBalanceLimits() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + celerLedger.enableBalanceLimits(); + uint256 g0 = gasleft(); + celerLedger.disableBalanceLimits(); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_enableBalanceLimits() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + // Already disabled in setUp. + uint256 g0 = gasleft(); + celerLedger.enableBalanceLimits(); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_deposit_msgValue() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openZeroEthChannel(); + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.deposit{value: 50}(ch, peer0, 0); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_deposit_ethPool() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openZeroEthChannel(); + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.deposit(ch, peer0, 100); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_deposit_erc20() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory request,, bytes32 ch) = _buildOpenErc20(address(erc20), [uint256(0), 0], openDeadlineCursor++); + celerLedger.openChannel(request); + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.deposit(ch, peer0, 100); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_depositInBatch_5() internal returns (uint256 g) { + return _measure_depositInBatch_n(5); + } + + function _measure_depositInBatch_n(uint256 _n) internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32[] memory ids = new bytes32[](_n); + address[] memory receivers = new address[](_n); + uint256[] memory amounts = new uint256[](_n); + for (uint256 i = 0; i < _n; i++) { + ids[i] = _openZeroEthChannel(); + receivers[i] = peer0; + amounts[i] = 30; + } + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.depositInBatch(ids, receivers, amounts); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_intendWithdraw() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.intendWithdraw(ch, 50, bytes32(0)); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_vetoWithdraw() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + vm.prank(peer0); + celerLedger.intendWithdraw(ch, 50, bytes32(0)); + vm.prank(peer1); + uint256 g0 = gasleft(); + celerLedger.vetoWithdraw(ch); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_confirmWithdraw() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + vm.prank(peer0); + celerLedger.intendWithdraw(ch, 50, bytes32(0)); + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + uint256 g0 = gasleft(); + celerLedger.confirmWithdraw(ch); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_cooperativeWithdraw() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + bytes memory request = _buildCoopWithdraw(ch, 1, peer0, 100, block.number + 1000, bytes32(0)); + uint256 g0 = gasleft(); + celerLedger.cooperativeWithdraw(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_cooperativeWithdraw_erc20() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory openReq,, bytes32 ch) = _buildOpenErc20(address(erc20), [uint256(200), 0], openDeadlineCursor++); + celerLedger.openChannel(openReq); + bytes memory request = _buildCoopWithdraw(ch, 1, peer0, 100, block.number + 1000, bytes32(0)); + uint256 g0 = gasleft(); + celerLedger.cooperativeWithdraw(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_snapshotStates() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + bytes memory simplex = _buildSignedSimplex(ch, peer0, 1, 50); + bytes memory array = _wrapStateArray(simplex, ""); + uint256 g0 = gasleft(); + celerLedger.snapshotStates(array); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_intendSettle_nullState() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + bytes memory s0 = _buildNullSimplex(ch, peer0Pk); + bytes memory s1 = _buildNullSimplex(ch, peer1Pk); + bytes memory array = _wrapStateArray(s0, s1); + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.intendSettle(array); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_intendSettle_twoStatesTwoPays() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 200]); + + bytes32 payA0 = _resolveHashLockPay(10, "p1", 1); + bytes32 payA1 = _resolveHashLockPay(15, "p2", 2); + bytes32 payB0 = _resolveHashLockPay(5, "p3", 3); + bytes32 payB1 = _resolveHashLockPay(8, "p4", 4); + + bytes32[] memory aIds = new bytes32[](2); + aIds[0] = payA0; + aIds[1] = payA1; + bytes32[] memory bIds = new bytes32[](2); + bIds[0] = payB0; + bIds[1] = payB1; + + bytes memory aListBytes = Fixtures.encPayIdList(aIds, bytes32(0)); + bytes memory bListBytes = Fixtures.encPayIdList(bIds, bytes32(0)); + + bytes memory s0 = _buildSimplexWithPayList(ch, peer0, 1, aListBytes, 25); + bytes memory s1 = _buildSimplexWithPayList(ch, peer1, 1, bListBytes, 13); + bytes memory array = _wrapStateArray(s0, s1); + + vm.roll(block.number + 10); + + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.intendSettle(array); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_intendSettle_oneState_nPays(uint256 _n) internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200_000_000), 0]); + + bytes32[] memory payIds = new bytes32[](_n); + uint256 totalPending = 0; + for (uint256 i = 0; i < _n; i++) { + uint256 amt = 10 + i; + payIds[i] = _resolveHashLockPay(amt, abi.encodePacked("p", vm.toString(i)), i + 1); + totalPending += amt; + } + bytes memory list = Fixtures.encPayIdList(payIds, bytes32(0)); + + bytes memory s0 = _buildSimplexWithPayList(ch, peer0, 1, list, totalPending); + bytes memory s1 = _buildSignedSimplex(ch, peer1, 1, 0); + bytes memory array = _wrapStateArray(s0, s1); + + vm.roll(block.number + 10); + + vm.prank(peer0); + uint256 g0 = gasleft(); + celerLedger.intendSettle(array); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_clearPays_twoPayments() internal returns (uint256 g) { + return _measure_clearPays_n(2); + } + + function _measure_clearPays_n(uint256 _n) internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200_000_000), 0]); + + // Head list: 1 pay; tail list: _n pays. + bytes32 headPayId = _resolveHashLockPay(10, "head", 1); + bytes32[] memory tailIds = new bytes32[](_n); + uint256 tailTotal = 0; + for (uint256 i = 0; i < _n; i++) { + uint256 amt = 5 + i; + tailIds[i] = _resolveHashLockPay(amt, abi.encodePacked("t", vm.toString(i)), 1000 + i + 1); + tailTotal += amt; + } + + bytes memory tailList = Fixtures.encPayIdList(tailIds, bytes32(0)); + bytes32 tailHash = keccak256(tailList); + + bytes32[] memory headIds = new bytes32[](1); + headIds[0] = headPayId; + bytes memory headList = Fixtures.encPayIdList(headIds, tailHash); + + bytes memory s0 = _buildSimplexWithPayList(ch, peer0, 1, headList, 10 + tailTotal); + bytes memory s1 = _buildSignedSimplex(ch, peer1, 1, 0); + + vm.roll(block.number + 10); + + vm.prank(peer0); + celerLedger.intendSettle(_wrapStateArray(s0, s1)); + + uint256 g0 = gasleft(); + celerLedger.clearPays(ch, peer0, tailList); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_confirmSettle() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + bytes memory s0 = _buildSignedSimplex(ch, peer0, 1, 0); + bytes memory s1 = _buildSignedSimplex(ch, peer1, 1, 0); + vm.prank(peer0); + celerLedger.intendSettle(_wrapStateArray(s0, s1)); + vm.roll(block.number + DISPUTE_TIMEOUT + 1); + uint256 g0 = gasleft(); + celerLedger.confirmSettle(ch); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_cooperativeSettle() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(200), 0]); + bytes memory request = _buildCoopSettle(ch, 1, [uint256(120), 80], block.number + 1000); + uint256 g0 = gasleft(); + celerLedger.cooperativeSettle(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_cooperativeSettle_erc20() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory openReq,, bytes32 ch) = _buildOpenErc20(address(erc20), [uint256(200), 0], openDeadlineCursor++); + celerLedger.openChannel(openReq); + bytes memory request = _buildCoopSettle(ch, 1, [uint256(120), 80], block.number + 1000); + uint256 g0 = gasleft(); + celerLedger.cooperativeSettle(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_resolveByConditions() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + Fixtures.Condition[] memory conds = new Fixtures.Condition[](1); + conds[0] = Fixtures.condHashLock(keccak256("preimage")); + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: 1, + src: peer0, + dest: peer1, + conditions: conds, + logicType: 0, + maxAmount: 10, + resolveDeadline: 9_999_999, + resolveTimeout: 5, + payResolver: address(payResolver) + }); + bytes memory payBytes = Fixtures.encConditionalPay(pay); + bytes[] memory preimages = new bytes[](1); + preimages[0] = bytes("preimage"); + bytes memory request = Fixtures.encResolvePayByConditionsRequest(payBytes, preimages); + + uint256 g0 = gasleft(); + payResolver.resolvePaymentByConditions(request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_resolveByVouchedResult() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + Fixtures.Condition[] memory numConds = new Fixtures.Condition[](2); + numConds[0] = Fixtures.condDeployedNumeric(makeAddr("numericMock"), 10); + numConds[1] = Fixtures.condDeployedNumeric(makeAddr("numericMock"), 25); + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: 2, + src: peer0, + dest: peer1, + conditions: numConds, + logicType: 3, + maxAmount: 100, + resolveDeadline: 9_999_999, + resolveTimeout: 10, + payResolver: address(payResolver) + }); + bytes memory payBytes = Fixtures.encConditionalPay(pay); + bytes memory result = Fixtures.encCondPayResult(payBytes, 20); + bytes memory sigSrc = SignUtil.sign(peer0Pk, result); + bytes memory sigDest = SignUtil.sign(peer1Pk, result); + bytes memory vouched = Fixtures.encVouchedCondPayResult(result, sigSrc, sigDest); + + uint256 g0 = gasleft(); + payResolver.resolvePaymentByVouchedResult(vouched); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_virtDeploy() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes memory mockBytecode = type(BooleanCondMock).creationCode; + uint256 g0 = gasleft(); + virtResolver.deploy(mockBytecode, 12_345); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_migrate_operableEth() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(100), 200]); + CelerLedger newLedger = _deployNewLedgerSiblingAndApprove(); + bytes memory request = + _buildMigrationRequest(ch, address(celerLedger), address(newLedger), block.number + 100_000); + + uint256 g0 = gasleft(); + newLedger.migrateChannelFrom(address(celerLedger), request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_migrate_settlingEth() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + bytes32 ch = _openFundedEthChannel([uint256(100), 200]); + bytes memory s0 = _buildSignedSimplex(ch, peer0, 1, 0); + bytes memory s1 = _buildSignedSimplex(ch, peer1, 1, 0); + vm.prank(peer0); + celerLedger.intendSettle(_wrapStateArray(s0, s1)); + + CelerLedger newLedger = _deployNewLedgerSiblingAndApprove(); + bytes memory request = + _buildMigrationRequest(ch, address(celerLedger), address(newLedger), block.number + 100_000); + + uint256 g0 = gasleft(); + newLedger.migrateChannelFrom(address(celerLedger), request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_migrate_operableErc20() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + (bytes memory openReq,, bytes32 ch) = _buildOpenErc20(address(erc20), [uint256(100), 200], openDeadlineCursor++); + celerLedger.openChannel(openReq); + + CelerLedger newLedger = _deployNewLedgerSiblingAndApprove(); + bytes memory request = + _buildMigrationRequest(ch, address(celerLedger), address(newLedger), block.number + 100_000); + + uint256 g0 = gasleft(); + newLedger.migrateChannelFrom(address(celerLedger), request); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_ethPool_deposit() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + vm.deal(stranger, 1 ether); + vm.prank(stranger); + uint256 g0 = gasleft(); + ethPool.deposit{value: 100}(peer0); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_ethPool_withdraw() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + vm.deal(peer0, 1 ether); + vm.prank(peer0); + ethPool.deposit{value: 100}(peer0); + vm.prank(peer0); + uint256 g0 = gasleft(); + ethPool.withdraw(100); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_ethPool_approve() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + vm.prank(peer0); + uint256 g0 = gasleft(); + ethPool.approve(stranger, 200); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_ethPool_transferFrom() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + vm.deal(peer0, 1 ether); + vm.prank(peer0); + ethPool.deposit{value: 200}(peer0); + vm.prank(peer0); + ethPool.approve(stranger, 200); + vm.prank(stranger); + uint256 g0 = gasleft(); + ethPool.transferFrom(peer0, payable(makeAddr("recipient")), 150); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_ethPool_increaseAllowance() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + vm.prank(peer0); + ethPool.approve(stranger, 50); + vm.prank(peer0); + uint256 g0 = gasleft(); + ethPool.increaseAllowance(stranger, 50); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + function _measure_ethPool_decreaseAllowance() internal returns (uint256 g) { + uint256 snap = vm.snapshotState(); + vm.prank(peer0); + ethPool.approve(stranger, 100); + vm.prank(peer0); + uint256 g0 = gasleft(); + ethPool.decreaseAllowance(stranger, 80); + g = g0 - gasleft(); + vm.revertToState(snap); + } + + // ========================================================================= + // Common reusable helpers (PayIdList / migration / hash-lock pay). + // ========================================================================= + + function _resolveHashLockPay(uint256 _maxAmount, bytes memory _preimage, uint256 _payTimestamp) + internal + returns (bytes32) + { + Fixtures.Condition[] memory conds = new Fixtures.Condition[](1); + conds[0] = Fixtures.condHashLock(keccak256(_preimage)); + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: _payTimestamp, + src: peer0, + dest: peer1, + conditions: conds, + logicType: 0, + maxAmount: _maxAmount, + resolveDeadline: 9_999_999, + resolveTimeout: 5, + payResolver: address(payResolver) + }); + bytes memory payBytes = Fixtures.encConditionalPay(pay); + bytes[] memory preimages = new bytes[](1); + preimages[0] = _preimage; + payResolver.resolvePaymentByConditions(Fixtures.encResolvePayByConditionsRequest(payBytes, preimages)); + return keccak256(abi.encodePacked(keccak256(payBytes), address(payResolver))); + } + + function _buildSimplexWithPayList( + bytes32 _channelId, + address _peerFrom, + uint256 _seqNum, + bytes memory _payIdList, + uint256 _totalPending + ) internal view returns (bytes memory) { + Fixtures.SimplexState memory s = Fixtures.SimplexState({ + channelId: _channelId, + peerFrom: _peerFrom, + seqNum: _seqNum, + transferAmount: 0, + pendingPayIds: _payIdList, + lastPayResolveDeadline: block.number + 1000, + totalPendingAmount: _totalPending + }); + bytes memory simplex = Fixtures.encSimplexPaymentChannel(s); + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, simplex); + return Fixtures.encSignedSimplexState(simplex, sigs); + } + + function _deployNewLedgerSiblingAndApprove() internal returns (CelerLedger newLedger) { + newLedger = new CelerLedger(address(ethPool), address(payRegistry), address(celerWallet)); + newLedger.disableBalanceLimits(); + vm.prank(peer0); + ethPool.approve(address(newLedger), type(uint256).max); + vm.prank(peer1); + ethPool.approve(address(newLedger), type(uint256).max); + } + + function _buildMigrationRequest(bytes32 _channelId, address _fromLedger, address _toLedger, uint256 _deadline) + internal + view + returns (bytes memory) + { + Fixtures.ChannelMigrationInfo memory info = Fixtures.ChannelMigrationInfo({ + channelId: _channelId, + fromLedger: _fromLedger, + toLedger: _toLedger, + migrationDeadline: _deadline + }); + bytes memory body = Fixtures.encChannelMigrationInfo(info); + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, body); + return Fixtures.encChannelMigrationRequest(body, sigs); + } +} diff --git a/test/PayRegistry.t.sol b/test/PayRegistry.t.sol new file mode 100644 index 0000000..6b6ecb4 --- /dev/null +++ b/test/PayRegistry.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {PayRegistry} from "../src/PayRegistry.sol"; + +/** + * @title PayRegistry tests + * @notice Unit tests for {PayRegistry}. Covers the namespaced `payId` derivation, + * per-pay setters (`setPayAmount` / `setPayDeadline` / `setPayInfo`) plus their + * batched variants, multi-setter independence, the bulk `getPayAmounts` read + * path used during channel settlement, and `payInfoMap`'s public getter. + */ +contract PayRegistryTest is Test { + PayRegistry internal registry; + + address internal setterA = makeAddr("setterA"); + address internal setterB = makeAddr("setterB"); + + bytes32 internal payHash1 = keccak256("pay-1"); + bytes32 internal payHash2 = keccak256("pay-2"); + + event PayInfoUpdate(bytes32 indexed payId, uint256 amount, uint256 resolveDeadline); + + function setUp() public { + registry = new PayRegistry(); + } + + // ------------------------------------------------------------------------- + // calculatePayId + // ------------------------------------------------------------------------- + + function test_calculatePayId_matchesSpec() public view { + bytes32 expected = keccak256(abi.encodePacked(payHash1, setterA)); + assertEq(registry.calculatePayId(payHash1, setterA), expected); + } + + function test_calculatePayId_differsBySetter() public view { + bytes32 idA = registry.calculatePayId(payHash1, setterA); + bytes32 idB = registry.calculatePayId(payHash1, setterB); + assertTrue(idA != idB); + } + + // ------------------------------------------------------------------------- + // setPayAmount / setPayDeadline / setPayInfo + // ------------------------------------------------------------------------- + + function test_setPayAmount_writesAmount_emitsEvent() public { + bytes32 expectedId = registry.calculatePayId(payHash1, setterA); + + vm.expectEmit(true, false, false, true, address(registry)); + emit PayInfoUpdate(expectedId, 100, 0); + + vm.prank(setterA); + registry.setPayAmount(payHash1, 100); + + (uint256 amount, uint256 deadline) = registry.getPayInfo(expectedId); + assertEq(amount, 100); + assertEq(deadline, 0); + } + + function test_setPayDeadline_writesDeadline_emitsEvent() public { + bytes32 expectedId = registry.calculatePayId(payHash1, setterA); + + vm.expectEmit(true, false, false, true, address(registry)); + emit PayInfoUpdate(expectedId, 0, 999); + + vm.prank(setterA); + registry.setPayDeadline(payHash1, 999); + + (uint256 amount, uint256 deadline) = registry.getPayInfo(expectedId); + assertEq(amount, 0); + assertEq(deadline, 999); + } + + function test_setPayInfo_writesBoth_emitsEvent() public { + bytes32 expectedId = registry.calculatePayId(payHash1, setterA); + + vm.expectEmit(true, false, false, true, address(registry)); + emit PayInfoUpdate(expectedId, 42, 1000); + + vm.prank(setterA); + registry.setPayInfo(payHash1, 42, 1000); + + (uint256 amount, uint256 deadline) = registry.getPayInfo(expectedId); + assertEq(amount, 42); + assertEq(deadline, 1000); + } + + function test_differentSetters_writeIndependentEntries() public { + vm.prank(setterA); + registry.setPayInfo(payHash1, 1, 100); + + vm.prank(setterB); + registry.setPayInfo(payHash1, 2, 200); + + (uint256 amountA, uint256 deadlineA) = registry.getPayInfo(registry.calculatePayId(payHash1, setterA)); + (uint256 amountB, uint256 deadlineB) = registry.getPayInfo(registry.calculatePayId(payHash1, setterB)); + + assertEq(amountA, 1); + assertEq(deadlineA, 100); + assertEq(amountB, 2); + assertEq(deadlineB, 200); + } + + // ------------------------------------------------------------------------- + // Batched setters + // ------------------------------------------------------------------------- + + function test_setPayAmounts_batched() public { + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = payHash1; + hashes[1] = payHash2; + uint256[] memory amts = new uint256[](2); + amts[0] = 11; + amts[1] = 22; + + vm.prank(setterA); + registry.setPayAmounts(hashes, amts); + + (uint256 amount1,) = registry.getPayInfo(registry.calculatePayId(payHash1, setterA)); + (uint256 amount2,) = registry.getPayInfo(registry.calculatePayId(payHash2, setterA)); + assertEq(amount1, 11); + assertEq(amount2, 22); + } + + function test_setPayAmounts_revertsIfLengthMismatch() public { + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = payHash1; + hashes[1] = payHash2; + uint256[] memory amts = new uint256[](1); + amts[0] = 11; + + vm.expectRevert(bytes("Lengths do not match")); + vm.prank(setterA); + registry.setPayAmounts(hashes, amts); + } + + function test_setPayDeadlines_batched() public { + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = payHash1; + hashes[1] = payHash2; + uint256[] memory deadlines = new uint256[](2); + deadlines[0] = 500; + deadlines[1] = 600; + + vm.prank(setterA); + registry.setPayDeadlines(hashes, deadlines); + + (, uint256 deadline1) = registry.getPayInfo(registry.calculatePayId(payHash1, setterA)); + (, uint256 deadline2) = registry.getPayInfo(registry.calculatePayId(payHash2, setterA)); + assertEq(deadline1, 500); + assertEq(deadline2, 600); + } + + function test_setPayInfos_batched() public { + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = payHash1; + hashes[1] = payHash2; + uint256[] memory amts = new uint256[](2); + amts[0] = 1; + amts[1] = 2; + uint256[] memory deadlines = new uint256[](2); + deadlines[0] = 10; + deadlines[1] = 20; + + vm.prank(setterA); + registry.setPayInfos(hashes, amts, deadlines); + + (uint256 a1, uint256 d1) = registry.getPayInfo(registry.calculatePayId(payHash1, setterA)); + (uint256 a2, uint256 d2) = registry.getPayInfo(registry.calculatePayId(payHash2, setterA)); + assertEq(a1, 1); + assertEq(d1, 10); + assertEq(a2, 2); + assertEq(d2, 20); + } + + function test_setPayInfos_revertsIfLengthMismatch() public { + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = payHash1; + hashes[1] = payHash2; + uint256[] memory amts = new uint256[](2); + uint256[] memory deadlines = new uint256[](1); + + vm.expectRevert(bytes("Lengths do not match")); + vm.prank(setterA); + registry.setPayInfos(hashes, amts, deadlines); + } + + // ------------------------------------------------------------------------- + // getPayAmounts (used during channel settlement) + // ------------------------------------------------------------------------- + + function test_getPayAmounts_returnsResolvedAmounts_whenDeadlinePassed() public { + // Set a pay with amount 50, deadline at block N. + vm.prank(setterA); + registry.setPayInfo(payHash1, 50, block.number + 5); + + bytes32[] memory ids = new bytes32[](1); + ids[0] = registry.calculatePayId(payHash1, setterA); + + // Roll past the per-pay deadline so the pay is finalized. + vm.roll(block.number + 6); + + uint256[] memory amounts = registry.getPayAmounts(ids, block.number); + assertEq(amounts.length, 1); + assertEq(amounts[0], 50); + } + + function test_getPayAmounts_revertsIfPayNotFinalized() public { + vm.prank(setterA); + registry.setPayInfo(payHash1, 50, block.number + 100); + + bytes32[] memory ids = new bytes32[](1); + ids[0] = registry.calculatePayId(payHash1, setterA); + + // Per-pay deadline is in the future; should revert. + vm.expectRevert(bytes("Payment is not finalized")); + registry.getPayAmounts(ids, block.number); + } + + function test_getPayAmounts_unsetPay_passesIfChannelDeadlineExceeded() public { + // No setPayInfo for payHash1 → resolveDeadline == 0 → falls through to the + // channel-level lastPayResolveDeadline check. + bytes32[] memory ids = new bytes32[](1); + ids[0] = registry.calculatePayId(payHash1, setterA); + + // Channel-level lastPayResolveDeadline is in the past → ok to read. + vm.roll(block.number + 10); + uint256[] memory amounts = registry.getPayAmounts(ids, block.number - 1); + assertEq(amounts[0], 0); + } + + function test_getPayAmounts_unsetPay_revertsIfChannelDeadlineNotExceeded() public { + bytes32[] memory ids = new bytes32[](1); + ids[0] = registry.calculatePayId(payHash1, setterA); + + // Channel-level lastPayResolveDeadline is in the future → revert. + vm.expectRevert(bytes("Payment is not finalized")); + registry.getPayAmounts(ids, block.number + 100); + } + + // ------------------------------------------------------------------------- + // payInfoMap auto-getter + // ------------------------------------------------------------------------- + + function test_payInfoMap_publicGetter() public { + vm.prank(setterA); + registry.setPayInfo(payHash1, 7, 8); + + (uint256 amount, uint256 deadline) = registry.payInfoMap(registry.calculatePayId(payHash1, setterA)); + assertEq(amount, 7); + assertEq(deadline, 8); + } +} diff --git a/test/PayResolver.t.sol b/test/PayResolver.t.sol new file mode 100644 index 0000000..bae0c14 --- /dev/null +++ b/test/PayResolver.t.sol @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {PayResolver} from "../src/PayResolver.sol"; +import {PayRegistry} from "../src/PayRegistry.sol"; +import {VirtContractResolver} from "../src/VirtContractResolver.sol"; +import {BooleanCondMock} from "../src/helper/BooleanCondMock.sol"; +import {NumericCondMock} from "../src/helper/NumericCondMock.sol"; +import {Fixtures} from "./utils/Fixtures.sol"; +import {SignUtil} from "./utils/SignUtil.sol"; + +/** + * @title PayResolver tests + * @notice Unit tests for on-chain conditional-payment resolution. Covers + * BOOLEAN_AND / BOOLEAN_OR + NUMERIC_ADD / NUMERIC_MAX / NUMERIC_MIN logic + * types over hash-lock and deployed/virtual condition contracts, vouched-result + * resolution, deadline / timeout reverts, and amount-monotonicity rules. + * + * @dev Sections: + * 1. Resolve by conditions — boolean logic + * 2. Resolve by vouched result + * 3. Deadline / timeout reverts + * 4. Hash-lock preimage failure + * 5. Numeric logic (ADD / MAX / MIN) + * 6. No-contract-condition shortcut (immediate finalization at max) + * 7. Update amount → max collapses onchain deadline + * 8. VIRTUAL_CONTRACT condition path + * + * @dev `_conditions(_type)` shorthands used by the tests below: + * - type 0: [hashLock, deployedFalse, deployedFalse] + * - type 1: [hashLock, deployedFalse, deployedTrue] + * - type 2: [hashLock, deployedTrue, deployedFalse] + * - type 3: [hashLock, deployedTrue, deployedTrue] + * - type 4: [hashLock, deployedTrue, hashLock] + * - type 5: [hashLock, numeric10, numeric25] + * - type 6: [hashLock] + */ +contract PayResolverTest is Test { + PayResolver internal payResolver; + PayRegistry internal payRegistry; + VirtContractResolver internal virtResolver; + BooleanCondMock internal boolMock; + NumericCondMock internal numMock; + + // src / dest of conditional payments. Use deterministic keypairs so vouched + // results can be co-signed. + address internal payerSrc; + uint256 internal payerSrcPk; + address internal payerDest; + uint256 internal payerDestPk; + + bytes internal constant TRUE_PREIMAGE = hex"123456"; + bytes internal constant FALSE_PREIMAGE = hex"654321"; + uint256 internal constant RESOLVE_TIMEOUT = 10; + uint256 internal constant RESOLVE_DEADLINE = 9_999_999; + + // Note: only logic types 0, 1, 3, 4, 5 are implemented (BOOLEAN_AND, + // BOOLEAN_OR, NUMERIC_ADD, NUMERIC_MAX, NUMERIC_MIN). BOOLEAN_CIRCUIT is reserved. + + event ResolvePayment(bytes32 indexed payId, uint256 amount, uint256 resolveDeadline); + + function setUp() public { + virtResolver = new VirtContractResolver(); + payRegistry = new PayRegistry(); + payResolver = new PayResolver(address(payRegistry), address(virtResolver)); + boolMock = new BooleanCondMock(); + numMock = new NumericCondMock(); + + (payerSrc, payerSrcPk) = makeAddrAndKey("payerSrc"); + (payerDest, payerDestPk) = makeAddrAndKey("payerDest"); + } + + // ------------------------------------------------------------------------- + // Helper builders + // ------------------------------------------------------------------------- + + function _hashLockTrue() internal pure returns (bytes32) { + return keccak256(TRUE_PREIMAGE); + } + + function _conditions(uint8 _type) internal view returns (Fixtures.Condition[] memory cs) { + if (_type == 0) { + cs = new Fixtures.Condition[](3); + cs[0] = Fixtures.condHashLock(_hashLockTrue()); + cs[1] = Fixtures.condDeployedBoolean(address(boolMock), false); + cs[2] = Fixtures.condDeployedBoolean(address(boolMock), false); + } else if (_type == 1) { + cs = new Fixtures.Condition[](3); + cs[0] = Fixtures.condHashLock(_hashLockTrue()); + cs[1] = Fixtures.condDeployedBoolean(address(boolMock), false); + cs[2] = Fixtures.condDeployedBoolean(address(boolMock), true); + } else if (_type == 2) { + cs = new Fixtures.Condition[](3); + cs[0] = Fixtures.condHashLock(_hashLockTrue()); + cs[1] = Fixtures.condDeployedBoolean(address(boolMock), true); + cs[2] = Fixtures.condDeployedBoolean(address(boolMock), false); + } else if (_type == 3) { + cs = new Fixtures.Condition[](3); + cs[0] = Fixtures.condHashLock(_hashLockTrue()); + cs[1] = Fixtures.condDeployedBoolean(address(boolMock), true); + cs[2] = Fixtures.condDeployedBoolean(address(boolMock), true); + } else if (_type == 4) { + cs = new Fixtures.Condition[](3); + cs[0] = Fixtures.condHashLock(_hashLockTrue()); + cs[1] = Fixtures.condDeployedBoolean(address(boolMock), true); + cs[2] = Fixtures.condHashLock(_hashLockTrue()); + } else if (_type == 5) { + cs = new Fixtures.Condition[](3); + cs[0] = Fixtures.condHashLock(_hashLockTrue()); + cs[1] = Fixtures.condDeployedNumeric(address(numMock), 10); + cs[2] = Fixtures.condDeployedNumeric(address(numMock), 25); + } else if (_type == 6) { + cs = new Fixtures.Condition[](1); + cs[0] = Fixtures.condHashLock(_hashLockTrue()); + } else { + revert("unknown condition type"); + } + } + + function _buildPay( + uint256 _payTimestamp, + uint8 _condType, + uint256 _logicType, + uint256 _maxAmount, + uint256 _resolveDeadline + ) internal view returns (bytes memory) { + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: _payTimestamp, + src: payerSrc, + dest: payerDest, + conditions: _conditions(_condType), + logicType: _logicType, + maxAmount: _maxAmount, + resolveDeadline: _resolveDeadline, + resolveTimeout: RESOLVE_TIMEOUT, + payResolver: address(payResolver) + }); + return Fixtures.encConditionalPay(pay); + } + + function _resolveByConditions(bytes memory _payBytes, bytes memory _preimage) internal { + bytes[] memory preimages = new bytes[](1); + preimages[0] = _preimage; + bytes memory request = Fixtures.encResolvePayByConditionsRequest(_payBytes, preimages); + payResolver.resolvePaymentByConditions(request); + } + + function _resolveByConditionsTwo(bytes memory _payBytes, bytes memory _preimage0, bytes memory _preimage1) + internal + { + bytes[] memory preimages = new bytes[](2); + preimages[0] = _preimage0; + preimages[1] = _preimage1; + bytes memory request = Fixtures.encResolvePayByConditionsRequest(_payBytes, preimages); + payResolver.resolvePaymentByConditions(request); + } + + function _vouched(bytes memory _payBytes, uint256 _amount) internal view returns (bytes memory) { + bytes memory result = Fixtures.encCondPayResult(_payBytes, _amount); + bytes memory sigSrc = SignUtil.sign(payerSrcPk, result); + bytes memory sigDest = SignUtil.sign(payerDestPk, result); + return Fixtures.encVouchedCondPayResult(result, sigSrc, sigDest); + } + + function _payId(bytes memory _payBytes) internal view returns (bytes32) { + return keccak256(abi.encodePacked(keccak256(_payBytes), address(payResolver))); + } + + // ------------------------------------------------------------------------- + // Resolve by conditions — boolean logic + // ------------------------------------------------------------------------- + + function test_resolveByConditions_booleanAnd_allTrue_resolvesToMax() public { + bytes memory payBytes = _buildPay(1, 3, 0, 10, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 10, block.number); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + function test_resolveByConditions_booleanAnd_someFalse_resolvesToZero() public { + bytes memory payBytes = _buildPay(2, 1, 0, 20, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 0, block.number + RESOLVE_TIMEOUT); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + function test_resolveByConditions_booleanOr_someTrue_resolvesToMax() public { + bytes memory payBytes = _buildPay(3, 2, 1, 30, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 30, block.number); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + function test_resolveByConditions_booleanOr_allFalse_resolvesToZero() public { + bytes memory payBytes = _buildPay(4, 0, 1, 30, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 0, block.number + RESOLVE_TIMEOUT); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + // ------------------------------------------------------------------------- + // Resolve by vouched result + // ------------------------------------------------------------------------- + + function test_resolveByVouchedResult_succeeds_setsAmountAndChallengeWindow() public { + bytes memory payBytes = _buildPay(0, 5, 3, 100, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 20, block.number + RESOLVE_TIMEOUT); + + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + } + + function test_resolveByVouchedResult_higherAmount_replacesPriorResult() public { + bytes memory payBytes = _buildPay(0, 5, 3, 100, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + // First resolve at 20 (deadline = N + RESOLVE_TIMEOUT). + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + uint256 firstDeadline = block.number + RESOLVE_TIMEOUT; + + // Second resolve at 25; deadline must be unchanged because amount < max. + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 25, firstDeadline); + + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 25)); + } + + function test_resolveByConditions_higherAmount_replacesPriorResult() public { + bytes memory payBytes = _buildPay(0, 5, 3, 100, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + uint256 firstDeadline = block.number + RESOLVE_TIMEOUT; + + // NUMERIC_ADD over [hashLock, num10, num25] = 35. Higher than 20 → updates, + // deadline preserved (still partial vs max=100). + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 35, firstDeadline); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + function test_resolveByVouchedResult_lowerAmount_reverts() public { + bytes memory payBytes = _buildPay(0, 5, 3, 100, RESOLVE_DEADLINE); + + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + // Now the on-chain amount is 20. A vouched result with lower amount must revert. + // Resolve via conditions to bump to 35 first, then try a vouched 30: + _resolveByConditions(payBytes, TRUE_PREIMAGE); + + vm.expectRevert(bytes("New amount is not larger")); + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 30)); + } + + function test_resolveByVouchedResult_exceedingMax_reverts() public { + bytes memory payBytes = _buildPay(0, 5, 3, 100, RESOLVE_DEADLINE); + + vm.expectRevert(bytes("Exceed max transfer amount")); + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 200)); + } + + // ------------------------------------------------------------------------- + // Deadline / timeout reverts + // ------------------------------------------------------------------------- + + function test_resolveByConditions_pastResolveDeadline_reverts() public { + // resolveDeadline = 1; roll past it. + bytes memory payBytes = _buildPay(5, 1, 0, 10, 1); + vm.roll(2); + + vm.expectRevert(bytes("Passed pay resolve deadline in condPay msg")); + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + function test_resolveByVouchedResult_pastResolveDeadline_reverts() public { + bytes memory payBytes = _buildPay(6, 1, 0, 100, 1); + vm.roll(2); + + vm.expectRevert(bytes("Passed pay resolve deadline in condPay msg")); + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + } + + function test_resolveByVouchedResult_pastOnchainDeadline_reverts() public { + bytes memory payBytes = _buildPay(7, 1, 0, 100, RESOLVE_DEADLINE); + + // First resolve sets onchain deadline to N + RESOLVE_TIMEOUT. + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + + // Roll past the onchain resolve deadline. + vm.roll(block.number + RESOLVE_TIMEOUT + 1); + + vm.expectRevert(bytes("Passed onchain resolve pay deadline")); + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 30)); + } + + function test_resolveByConditions_pastOnchainDeadline_reverts() public { + bytes memory payBytes = _buildPay(8, 1, 0, 200, RESOLVE_DEADLINE); + + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + vm.roll(block.number + RESOLVE_TIMEOUT + 1); + + vm.expectRevert(bytes("Passed onchain resolve pay deadline")); + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + // ------------------------------------------------------------------------- + // Hash-lock failure + // ------------------------------------------------------------------------- + + function test_resolveByConditions_falseHashLock_reverts() public { + // type 4: [hashLock, deployedTrue, hashLock] + bytes memory payBytes = _buildPay(9, 4, 1, 200, RESOLVE_DEADLINE); + + vm.expectRevert(bytes("Wrong preimage")); + _resolveByConditionsTwo(payBytes, TRUE_PREIMAGE, FALSE_PREIMAGE); + } + + // ------------------------------------------------------------------------- + // Numeric logic + // ------------------------------------------------------------------------- + + function test_resolveByConditions_numericAdd_sumsOutcomes() public { + bytes memory payBytes = _buildPay(10, 5, 3, 50, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 35, block.number + RESOLVE_TIMEOUT); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + function test_resolveByConditions_numericMax_picksHighest() public { + bytes memory payBytes = _buildPay(11, 5, 4, 50, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 25, block.number + RESOLVE_TIMEOUT); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + function test_resolveByConditions_numericMin_picksLowest() public { + bytes memory payBytes = _buildPay(12, 5, 5, 50, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 10, block.number + RESOLVE_TIMEOUT); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + // ------------------------------------------------------------------------- + // No contract conditions → max amount + immediate finalization + // ------------------------------------------------------------------------- + + function test_resolveByConditions_hashLockOnly_resolvesToMax_anyLogic() public { + // BOOLEAN_CIRCUIT (logicType=2) is reserved/unimplemented; skip it. + uint256[5] memory logicTypes = [uint256(0), 1, 3, 4, 5]; + + for (uint256 i = 0; i < logicTypes.length; i++) { + bytes memory payBytes = _buildPay(100 + i, 6, logicTypes[i], 50, RESOLVE_DEADLINE); + bytes32 expectedPayId = _payId(payBytes); + + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 50, block.number); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + } + + // ------------------------------------------------------------------------- + // Updating amount = max sets onchain deadline to current block + // ------------------------------------------------------------------------- + + function test_updatedAmountEqualsMax_setsOnchainDeadlineToCurrentBlock() public { + bytes memory payBytes = _buildPay(0, 5, 3, 35, RESOLVE_DEADLINE); // max = 35 + bytes32 expectedPayId = _payId(payBytes); + + // First: vouched 20 → partial, deadline = block + timeout. + payResolver.resolvePaymentByVouchedResult(_vouched(payBytes, 20)); + + // Second: resolve by conditions → 35 (= max). Deadline must collapse to + // current block. + vm.roll(block.number + 2); + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 35, block.number); + + _resolveByConditions(payBytes, TRUE_PREIMAGE); + } + + // ------------------------------------------------------------------------- + // VIRTUAL_CONTRACT condition path + // ------------------------------------------------------------------------- + + function test_resolveByConditions_virtualContract_resolvesToMax() public { + // Deploy a BooleanCondMock at a deterministic "virtual address" via + // VirtContractResolver. The mock decodes its outcome from the query bytes. + bytes memory mockBytecode = type(BooleanCondMock).creationCode; + uint256 nonce = 12345; + bytes32 virtAddr = keccak256(abi.encodePacked(mockBytecode, nonce)); + virtResolver.deploy(mockBytecode, nonce); + + // Build a ConditionalPay whose only condition is a VIRTUAL_CONTRACT + // condition with outcome=true. With BOOLEAN_AND, this resolves to max. + Fixtures.Condition[] memory conds = new Fixtures.Condition[](1); + conds[0] = Fixtures.condVirtual(virtAddr, abi.encodePacked(bytes1(0x01))); + + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: 1, + src: payerSrc, + dest: payerDest, + conditions: conds, + logicType: 0, // BOOLEAN_AND + maxAmount: 50, + resolveDeadline: RESOLVE_DEADLINE, + resolveTimeout: RESOLVE_TIMEOUT, + payResolver: address(payResolver) + }); + bytes memory payBytes = Fixtures.encConditionalPay(pay); + bytes32 expectedPayId = keccak256(abi.encodePacked(keccak256(payBytes), address(payResolver))); + + bytes[] memory preimages = new bytes[](0); + vm.expectEmit(true, false, false, true, address(payResolver)); + emit ResolvePayment(expectedPayId, 50, block.number); + + payResolver.resolvePaymentByConditions(Fixtures.encResolvePayByConditionsRequest(payBytes, preimages)); + + (uint256 amount, uint256 deadline) = payRegistry.getPayInfo(expectedPayId); + assertEq(amount, 50); + assertEq(deadline, block.number); + } +} diff --git a/test/RouterRegistry.t.sol b/test/RouterRegistry.t.sol new file mode 100644 index 0000000..09f508f --- /dev/null +++ b/test/RouterRegistry.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {RouterRegistry} from "../src/RouterRegistry.sol"; +import {IRouterRegistry} from "../src/lib/interface/IRouterRegistry.sol"; + +/** + * @title RouterRegistry tests + * @notice Unit tests for {RouterRegistry}. Exercises the optional relay-router + * self-advertisement registry: register / deregister / refresh and their + * revert paths, plus `routerInfo` readback semantics. + */ +contract RouterRegistryTest is Test { + RouterRegistry internal registry; + + address internal router0 = makeAddr("router0"); + address internal router1 = makeAddr("router1"); + + event RouterUpdated(IRouterRegistry.RouterOperation indexed op, address indexed routerAddress); + + function setUp() public { + registry = new RouterRegistry(); + } + + // ------------------------------------------------------------------------- + // registerRouter + // ------------------------------------------------------------------------- + + function test_registerRouter_succeedsForNewAddress_emitsAdd() public { + vm.expectEmit(true, true, false, false, address(registry)); + emit RouterUpdated(IRouterRegistry.RouterOperation.Add, router0); + + vm.prank(router0); + registry.registerRouter(); + + assertEq(registry.routerInfo(router0), block.number); + } + + function test_registerRouter_revertsForAlreadyRegistered() public { + vm.prank(router0); + registry.registerRouter(); + + vm.expectRevert(bytes("Router address already exists")); + vm.prank(router0); + registry.registerRouter(); + } + + // ------------------------------------------------------------------------- + // deregisterRouter + // ------------------------------------------------------------------------- + + function test_deregisterRouter_succeedsForRegistered_emitsRemove() public { + vm.prank(router0); + registry.registerRouter(); + + vm.expectEmit(true, true, false, false, address(registry)); + emit RouterUpdated(IRouterRegistry.RouterOperation.Remove, router0); + + vm.prank(router0); + registry.deregisterRouter(); + + assertEq(registry.routerInfo(router0), 0); + } + + function test_deregisterRouter_revertsForUnregistered() public { + vm.expectRevert(bytes("Router address does not exist")); + vm.prank(router0); + registry.deregisterRouter(); + } + + // ------------------------------------------------------------------------- + // refreshRouter + // ------------------------------------------------------------------------- + + function test_refreshRouter_updatesBlockNumber_emitsRefresh() public { + vm.prank(router0); + registry.registerRouter(); + uint256 firstBlock = block.number; + + // Advance and refresh. + vm.roll(firstBlock + 50); + + vm.expectEmit(true, true, false, false, address(registry)); + emit RouterUpdated(IRouterRegistry.RouterOperation.Refresh, router0); + + vm.prank(router0); + registry.refreshRouter(); + + assertEq(registry.routerInfo(router0), firstBlock + 50); + } + + function test_refreshRouter_revertsForUnregistered() public { + vm.expectRevert(bytes("Router address does not exist")); + vm.prank(router0); + registry.refreshRouter(); + } + + // ------------------------------------------------------------------------- + // routerInfo public getter + // ------------------------------------------------------------------------- + + function test_routerInfo_returnsBlockNumberForRegistered() public { + vm.prank(router0); + registry.registerRouter(); + + assertEq(registry.routerInfo(router0), block.number); + } + + function test_routerInfo_returnsZeroForUnregistered() public { + // router1 was never registered. + assertEq(registry.routerInfo(router1), 0); + } +} diff --git a/test/VirtContractResolver.t.sol b/test/VirtContractResolver.t.sol new file mode 100644 index 0000000..1b8d082 --- /dev/null +++ b/test/VirtContractResolver.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {VirtContractResolver} from "../src/VirtContractResolver.sol"; + +/** + * @title VirtContractResolver tests + * @notice Unit tests for {VirtContractResolver}. Exercises the `CREATE`-based + * virtual-contract materialization path: deploy bytecode under a deterministic + * virtual address, resolve known / unknown addresses, and reject re-deploy + * under the same `(code, nonce)`. + */ +contract VirtContractResolverTest is Test { + VirtContractResolver internal resolver; + + // Minimal runtime bytecode that returns success when called. Used as the body + // of a virtual contract for deployment tests. + // + // Deploy code: PUSH1 0x06 (size) DUP1 PUSH1 0x0a (offset) PUSH1 0x00 CODECOPY + // PUSH1 0x00 RETURN + // Runtime: PUSH1 0x01 PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN + bytes internal constant SAMPLE_CODE = hex"60068060093d393df3" hex"6001600052602060f3"; + + event Deploy(bytes32 indexed virtAddr); + + function setUp() public { + resolver = new VirtContractResolver(); + } + + function test_resolve_revertsForUnknownVirtAddr() public { + vm.expectRevert(bytes("Nonexistent virtual address")); + resolver.resolve(bytes32(uint256(1))); + } + + function test_deploy_createsContract_emitsEventAndMapsAddress() public { + bytes32 expectedVirtAddr = keccak256(abi.encodePacked(SAMPLE_CODE, uint256(1024))); + + vm.expectEmit(true, false, false, false, address(resolver)); + emit Deploy(expectedVirtAddr); + + bool ok = resolver.deploy(SAMPLE_CODE, 1024); + assertTrue(ok); + + address deployed = resolver.resolve(expectedVirtAddr); + assertTrue(deployed != address(0)); + // The mapped address must contain the runtime bytecode (non-empty extcodesize). + assertGt(deployed.code.length, 0); + } + + function test_deploy_revertsIfAlreadyDeployed() public { + resolver.deploy(SAMPLE_CODE, 7); + + vm.expectRevert(bytes("Current real address is not 0")); + resolver.deploy(SAMPLE_CODE, 7); + } +} diff --git a/test/utils/Base.t.sol b/test/utils/Base.t.sol new file mode 100644 index 0000000..2f68d9e --- /dev/null +++ b/test/utils/Base.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {CelerWallet} from "../../src/CelerWallet.sol"; +import {CelerLedger} from "../../src/CelerLedger.sol"; +import {EthPool} from "../../src/EthPool.sol"; +import {PayRegistry} from "../../src/PayRegistry.sol"; +import {PayResolver} from "../../src/PayResolver.sol"; +import {VirtContractResolver} from "../../src/VirtContractResolver.sol"; +import {ERC20ExampleToken} from "../../src/helper/ERC20ExampleToken.sol"; + +/** + * @title BaseTest + * @notice Common deployment + helper utilities for AgentPay Foundry tests. Inherit + * this from individual test contracts to get a fully wired contract graph + * (`celerWallet`, `celerLedger`, `ethPool`, `payRegistry`, `payResolver`, + * `virtResolver`, `erc20`) and a few addressing helpers. + * @dev `setUp()` here can be called from a child's own `setUp()` via `super.setUp()`. + * Tests that don't need every dependency may reach into the contracts directly + * rather than calling this — see `RouterRegistry.t.sol` for a minimal example. + */ +contract BaseTest is Test { + // ------------------------------------------------------------------------- + // Deployed contracts + // ------------------------------------------------------------------------- + CelerWallet internal celerWallet; + CelerLedger internal celerLedger; + EthPool internal ethPool; + PayRegistry internal payRegistry; + PayResolver internal payResolver; + VirtContractResolver internal virtResolver; + ERC20ExampleToken internal erc20; + + // ------------------------------------------------------------------------- + // Common test addresses + // ------------------------------------------------------------------------- + address internal admin = address(this); + + // Channel peers — addresses are deterministically sorted (peer0 < peer1) so they + // can be passed directly to functions that require ascending peer order. + address internal peer0; + address internal peer1; + uint256 internal peer0Pk; + uint256 internal peer1Pk; + + // Payment source / destination — used in PayResolver tests. + address internal client0; + address internal client1; + uint256 internal client0Pk; + uint256 internal client1Pk; + + // Generic third-party + address internal stranger; + + function setUp() public virtual { + // Deploy the full contract graph in dependency order. + ethPool = new EthPool(); + payRegistry = new PayRegistry(); + virtResolver = new VirtContractResolver(); + payResolver = new PayResolver(address(payRegistry), address(virtResolver)); + celerWallet = new CelerWallet(); + celerLedger = new CelerLedger(address(ethPool), address(payRegistry), address(celerWallet)); + erc20 = new ERC20ExampleToken(); + + vm.label(address(celerWallet), "CelerWallet"); + vm.label(address(celerLedger), "CelerLedger"); + vm.label(address(ethPool), "EthPool"); + vm.label(address(payRegistry), "PayRegistry"); + vm.label(address(payResolver), "PayResolver"); + vm.label(address(virtResolver), "VirtContractResolver"); + vm.label(address(erc20), "ERC20ExampleToken"); + + // Generate sorted peer pair (peer0 < peer1) for channel tests. + (peer0, peer1, peer0Pk, peer1Pk) = _makeSortedPeerPair("peer0", "peer1"); + (client0, client1, client0Pk, client1Pk) = _makeSortedPeerPair("client0", "client1"); + + stranger = makeAddr("stranger"); + + vm.label(peer0, "peer0"); + vm.label(peer1, "peer1"); + vm.label(client0, "client0"); + vm.label(client1, "client1"); + vm.label(stranger, "stranger"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /// @dev Produce two named addresses sorted ascending by address value, with their + /// private keys. The contracts require peers in ascending order; this avoids + /// ordering bugs in tests. + function _makeSortedPeerPair(string memory _name0, string memory _name1) + internal + returns (address a0, address a1, uint256 pk0, uint256 pk1) + { + (address candidate0, uint256 candidate0Pk) = makeAddrAndKey(_name0); + (address candidate1, uint256 candidate1Pk) = makeAddrAndKey(_name1); + if (candidate0 < candidate1) { + return (candidate0, candidate1, candidate0Pk, candidate1Pk); + } else { + return (candidate1, candidate0, candidate1Pk, candidate0Pk); + } + } + + /// @dev Sort an address pair ascending. Returns `[low, high]`. + function _sortAddrs(address _a, address _b) internal pure returns (address[] memory out) { + out = new address[](2); + if (_a < _b) { + out[0] = _a; + out[1] = _b; + } else { + out[0] = _b; + out[1] = _a; + } + } +} diff --git a/test/utils/Fixtures.sol b/test/utils/Fixtures.sol new file mode 100644 index 0000000..76d1559 --- /dev/null +++ b/test/utils/Fixtures.sol @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Proto} from "./Proto.sol"; + +/** + * @title Fixtures + * @notice Solidity builders for the AgentPay protobuf messages used in Foundry + * tests. Encoding is done via {Proto}; output round-trips through the existing + * `PbEntity` / `PbChain` decoders. + * + * @dev Encoders are organized by source proto file: + * - `entity.proto` messages: AccountAmtPair, TokenInfo, TokenDistribution, + * TokenTransfer, SimplexPaymentChannel, PayIdList, TransferFunction, + * ConditionalPay, CondPayResult, VouchedCondPayResult, Condition, + * CooperativeWithdrawInfo, PaymentChannelInitializer, CooperativeSettleInfo, + * ChannelMigrationInfo. + * - `chain.proto` messages: OpenChannelRequest, CooperativeWithdrawRequest, + * CooperativeSettleRequest, ResolvePayByConditionsRequest, + * SignedSimplexState, SignedSimplexStateArray, ChannelMigrationRequest. + */ +library Fixtures { + using Proto for bytes; + + // ------------------------------------------------------------------------- + // entity.proto + // ------------------------------------------------------------------------- + + /// @notice Encode `entity.AccountAmtPair { account: address, amt: uint256 }`. + function encAccountAmtPair(address _account, uint256 _amt) internal pure returns (bytes memory) { + return Proto.cat(Proto.addressField(1, _account), Proto.uint256FieldExplicit(2, _amt)); + } + + /// @notice Encode `entity.TokenInfo { tokenType: enum, tokenAddress: address }`. + function encTokenInfo(uint256 _tokenType, address _tokenAddress) internal pure returns (bytes memory) { + return Proto.cat(Proto.enumField(1, _tokenType), Proto.addressField(2, _tokenAddress)); + } + + /// @notice Encode `entity.TokenDistribution`. + function encTokenDistribution( + uint256 _tokenType, + address _tokenAddress, + address[2] memory _accounts, + uint256[2] memory _amounts + ) internal pure returns (bytes memory out) { + // field 1: TokenInfo + out = Proto.bytesField(1, encTokenInfo(_tokenType, _tokenAddress)); + // field 2: repeated AccountAmtPair + out = Proto.cat(out, Proto.bytesField(2, encAccountAmtPair(_accounts[0], _amounts[0]))); + out = Proto.cat(out, Proto.bytesField(2, encAccountAmtPair(_accounts[1], _amounts[1]))); + } + + /// @notice Encode `entity.TokenTransfer { token: TokenInfo (optional), receiver: AccountAmtPair }`. + /// @dev If `_tokenType == 0` and `_tokenAddress == 0`, the token field is omitted. + function encTokenTransfer(uint256 _tokenType, address _tokenAddress, address _receiverAccount, uint256 _receiverAmt) + internal + pure + returns (bytes memory out) + { + bytes memory tokenInfo = encTokenInfo(_tokenType, _tokenAddress); + if (tokenInfo.length > 0) { + out = Proto.bytesField(1, tokenInfo); + } + out = Proto.cat(out, Proto.bytesField(2, encAccountAmtPair(_receiverAccount, _receiverAmt))); + } + + /** + * @notice Encode `entity.PayIdList`. + * @param _payIds Pay ids in this list segment. + * @param _nextListHash Hash of the next list segment, or `bytes32(0)` for the tail. + */ + function encPayIdList(bytes32[] memory _payIds, bytes32 _nextListHash) internal pure returns (bytes memory out) { + out = Proto.repeatedBytes32Field(1, _payIds); + if (_nextListHash != bytes32(0)) { + out = Proto.cat(out, Proto.bytes32Field(2, _nextListHash)); + } + } + + /// @notice Encode `entity.SimplexPaymentChannel`. + struct SimplexState { + bytes32 channelId; + address peerFrom; + uint256 seqNum; + uint256 transferAmount; + bytes pendingPayIds; // pre-encoded PayIdList (use {encPayIdList}) + uint256 lastPayResolveDeadline; + uint256 totalPendingAmount; + } + + function encSimplexPaymentChannel(SimplexState memory _s) internal pure returns (bytes memory out) { + out = Proto.bytes32Field(1, _s.channelId); + out = Proto.cat(out, Proto.addressField(2, _s.peerFrom)); + out = Proto.cat(out, Proto.uintField(3, _s.seqNum)); + // Field 4: transfer_to_peer (TokenTransfer with no token field, just receiver=0/amt). + bytes memory transferToPeer = encTokenTransfer(0, address(0), address(0), _s.transferAmount); + if (transferToPeer.length > 0) { + out = Proto.cat(out, Proto.bytesField(4, transferToPeer)); + } + // Field 5: pending_pay_ids (PayIdList — pre-encoded by caller). + if (_s.pendingPayIds.length > 0) { + out = Proto.cat(out, Proto.bytesField(5, _s.pendingPayIds)); + } + out = Proto.cat(out, Proto.uintField(6, _s.lastPayResolveDeadline)); + out = Proto.cat(out, Proto.uint256Field(7, _s.totalPendingAmount)); + } + + /** + * @notice Encode `entity.Condition`. + * @dev `_conditionType`: + * - `0` → HASH_LOCK; uses `_hashLock`. + * - `1` → DEPLOYED_CONTRACT; uses `_deployedAddress` and `_argsQueryOutcome`. + * - `2` → VIRTUAL_CONTRACT; uses `_virtualAddr`, `_argsQueryFinalization`, + * and `_argsQueryOutcome`. + */ + struct Condition { + uint256 conditionType; + bytes32 hashLock; + address deployedAddress; + bytes32 virtualAddr; + bytes argsQueryFinalization; + bytes argsQueryOutcome; + } + + function encCondition(Condition memory _c) internal pure returns (bytes memory out) { + out = Proto.enumField(1, _c.conditionType); + out = Proto.cat(out, Proto.bytes32Field(2, _c.hashLock)); + out = Proto.cat(out, Proto.addressField(3, _c.deployedAddress)); + out = Proto.cat(out, Proto.bytes32Field(4, _c.virtualAddr)); + out = Proto.cat(out, Proto.bytesField(5, _c.argsQueryFinalization)); + out = Proto.cat(out, Proto.bytesField(6, _c.argsQueryOutcome)); + } + + /// @notice Build a HASH_LOCK condition. + function condHashLock(bytes32 _hashLock) internal pure returns (Condition memory c) { + c.conditionType = 0; + c.hashLock = _hashLock; + } + + /// @notice Build a DEPLOYED_CONTRACT condition with one-byte boolean argsQueryOutcome. + function condDeployedBoolean(address _addr, bool _outcome) internal pure returns (Condition memory c) { + c.conditionType = 1; + c.deployedAddress = _addr; + c.argsQueryOutcome = abi.encodePacked(_outcome ? bytes1(0x01) : bytes1(0x00)); + } + + /// @notice Build a DEPLOYED_CONTRACT condition with one-byte numeric argsQueryOutcome. + function condDeployedNumeric(address _addr, uint8 _amount) internal pure returns (Condition memory c) { + c.conditionType = 1; + c.deployedAddress = _addr; + c.argsQueryOutcome = abi.encodePacked(_amount); + } + + /// @notice Build a VIRTUAL_CONTRACT condition. + function condVirtual(bytes32 _virtAddr, bytes memory _argsOutcome) internal pure returns (Condition memory c) { + c.conditionType = 2; + c.virtualAddr = _virtAddr; + c.argsQueryOutcome = _argsOutcome; + } + + /// @notice Encode `entity.TransferFunction`. + function encTransferFunction(uint256 _logicType, uint256 _maxAmount) internal pure returns (bytes memory out) { + out = Proto.enumField(1, _logicType); + out = Proto.cat(out, Proto.bytesField(2, encTokenTransfer(0, address(0), address(0), _maxAmount))); + } + + /// @notice Encode `entity.ConditionalPay`. + struct ConditionalPay { + uint256 payTimestamp; + address src; + address dest; + Condition[] conditions; + uint256 logicType; + uint256 maxAmount; + uint256 resolveDeadline; + uint256 resolveTimeout; + address payResolver; + } + + function encConditionalPay(ConditionalPay memory _p) internal pure returns (bytes memory out) { + out = Proto.uintField(1, _p.payTimestamp); + out = Proto.cat(out, Proto.addressField(2, _p.src)); + out = Proto.cat(out, Proto.addressField(3, _p.dest)); + for (uint256 i = 0; i < _p.conditions.length; i++) { + out = Proto.cat(out, Proto.bytesField(4, encCondition(_p.conditions[i]))); + } + out = Proto.cat(out, Proto.bytesField(5, encTransferFunction(_p.logicType, _p.maxAmount))); + out = Proto.cat(out, Proto.uintField(6, _p.resolveDeadline)); + out = Proto.cat(out, Proto.uintField(7, _p.resolveTimeout)); + out = Proto.cat(out, Proto.addressField(8, _p.payResolver)); + } + + /// @notice Encode `entity.CondPayResult { condPay: bytes, amount: uint256 }`. + function encCondPayResult(bytes memory _condPay, uint256 _amount) internal pure returns (bytes memory out) { + out = Proto.bytesField(1, _condPay); + out = Proto.cat(out, Proto.uint256FieldExplicit(2, _amount)); + } + + /// @notice Encode `entity.VouchedCondPayResult { condPayResult, sigOfSrc, sigOfDest }`. + function encVouchedCondPayResult(bytes memory _condPayResult, bytes memory _sigSrc, bytes memory _sigDest) + internal + pure + returns (bytes memory out) + { + out = Proto.bytesField(1, _condPayResult); + out = Proto.cat(out, Proto.bytesField(2, _sigSrc)); + out = Proto.cat(out, Proto.bytesField(3, _sigDest)); + } + + /// @notice Encode `entity.CooperativeWithdrawInfo`. + struct CooperativeWithdrawInfo { + bytes32 channelId; + uint256 seqNum; + address withdrawAccount; + uint256 withdrawAmount; + uint256 withdrawDeadline; + bytes32 recipientChannelId; + } + + function encCooperativeWithdrawInfo(CooperativeWithdrawInfo memory _w) internal pure returns (bytes memory out) { + out = Proto.bytes32Field(1, _w.channelId); + out = Proto.cat(out, Proto.uintField(2, _w.seqNum)); + out = Proto.cat(out, Proto.bytesField(3, encAccountAmtPair(_w.withdrawAccount, _w.withdrawAmount))); + out = Proto.cat(out, Proto.uintField(4, _w.withdrawDeadline)); + out = Proto.cat(out, Proto.bytes32Field(5, _w.recipientChannelId)); + } + + /// @notice Encode `entity.PaymentChannelInitializer`. + struct PaymentChannelInitializer { + uint256 tokenType; // 1 = ETH, 2 = ERC20 + address tokenAddress; // 0 for ETH + address[2] peers; // ascending order + uint256[2] amounts; + uint256 openDeadline; + uint256 disputeTimeout; + uint256 msgValueReceiver; // peer index + } + + function encPaymentChannelInitializer(PaymentChannelInitializer memory _p) + internal + pure + returns (bytes memory out) + { + out = Proto.bytesField(1, encTokenDistribution(_p.tokenType, _p.tokenAddress, _p.peers, _p.amounts)); + out = Proto.cat(out, Proto.uintField(2, _p.openDeadline)); + out = Proto.cat(out, Proto.uintField(3, _p.disputeTimeout)); + out = Proto.cat(out, Proto.uintField(4, _p.msgValueReceiver)); + } + + /// @notice Encode `entity.CooperativeSettleInfo`. + struct CooperativeSettleInfo { + bytes32 channelId; + uint256 seqNum; + address[2] settleAccounts; + uint256[2] settleAmounts; + uint256 settleDeadline; + } + + function encCooperativeSettleInfo(CooperativeSettleInfo memory _s) internal pure returns (bytes memory out) { + out = Proto.bytes32Field(1, _s.channelId); + out = Proto.cat(out, Proto.uintField(2, _s.seqNum)); + out = Proto.cat(out, Proto.bytesField(3, encAccountAmtPair(_s.settleAccounts[0], _s.settleAmounts[0]))); + out = Proto.cat(out, Proto.bytesField(3, encAccountAmtPair(_s.settleAccounts[1], _s.settleAmounts[1]))); + out = Proto.cat(out, Proto.uintField(4, _s.settleDeadline)); + } + + /// @notice Encode `entity.ChannelMigrationInfo`. + struct ChannelMigrationInfo { + bytes32 channelId; + address fromLedger; + address toLedger; + uint256 migrationDeadline; + } + + function encChannelMigrationInfo(ChannelMigrationInfo memory _m) internal pure returns (bytes memory out) { + out = Proto.bytes32Field(1, _m.channelId); + out = Proto.cat(out, Proto.addressField(2, _m.fromLedger)); + out = Proto.cat(out, Proto.addressField(3, _m.toLedger)); + out = Proto.cat(out, Proto.uintField(4, _m.migrationDeadline)); + } + + // ------------------------------------------------------------------------- + // chain.proto + // ------------------------------------------------------------------------- + + /// @notice Wrap a body in a `(bodyBytes, sigs)` pair: used by all `*Request` + /// messages and `SignedSimplexState`. Each emits field-1 = body, field-2 = sigs. + function encBodyAndSigs(bytes memory _body, bytes[] memory _sigs) internal pure returns (bytes memory out) { + out = Proto.bytesField(1, _body); + out = Proto.cat(out, Proto.repeatedBytesField(2, _sigs)); + } + + /// @notice Encode `chain.OpenChannelRequest`. + function encOpenChannelRequest(bytes memory _initializer, bytes[] memory _sigs) + internal + pure + returns (bytes memory) + { + return encBodyAndSigs(_initializer, _sigs); + } + + /// @notice Encode `chain.CooperativeWithdrawRequest`. + function encCooperativeWithdrawRequest(bytes memory _info, bytes[] memory _sigs) + internal + pure + returns (bytes memory) + { + return encBodyAndSigs(_info, _sigs); + } + + /// @notice Encode `chain.CooperativeSettleRequest`. + function encCooperativeSettleRequest(bytes memory _info, bytes[] memory _sigs) + internal + pure + returns (bytes memory) + { + return encBodyAndSigs(_info, _sigs); + } + + /// @notice Encode `chain.ChannelMigrationRequest`. + function encChannelMigrationRequest(bytes memory _info, bytes[] memory _sigs) + internal + pure + returns (bytes memory) + { + return encBodyAndSigs(_info, _sigs); + } + + /// @notice Encode `chain.ResolvePayByConditionsRequest`. + function encResolvePayByConditionsRequest(bytes memory _condPay, bytes[] memory _hashPreimages) + internal + pure + returns (bytes memory out) + { + out = Proto.bytesField(1, _condPay); + out = Proto.cat(out, Proto.repeatedBytesField(2, _hashPreimages)); + } + + /// @notice Encode `chain.SignedSimplexState`. + function encSignedSimplexState(bytes memory _simplexState, bytes[] memory _sigs) + internal + pure + returns (bytes memory) + { + return encBodyAndSigs(_simplexState, _sigs); + } + + /// @notice Encode `chain.SignedSimplexStateArray`. + function encSignedSimplexStateArray(bytes[] memory _signedSimplexStates) internal pure returns (bytes memory out) { + for (uint256 i = 0; i < _signedSimplexStates.length; i++) { + out = Proto.cat(out, Proto.bytesField(1, _signedSimplexStates[i])); + } + } +} diff --git a/test/utils/LedgerTestBase.t.sol b/test/utils/LedgerTestBase.t.sol new file mode 100644 index 0000000..83bc623 --- /dev/null +++ b/test/utils/LedgerTestBase.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {BaseTest} from "./Base.t.sol"; +import {Fixtures} from "./Fixtures.sol"; +import {SignUtil} from "./SignUtil.sol"; + +/** + * @title LedgerTestBase + * @notice CelerLedger-specific helpers layered on top of {BaseTest}: open-channel + * fixture builders, channelId derivation, and pre-funded peer pool. Used by + * every CelerLedger test file (ETH, ERC20, Migrate). + */ +contract LedgerTestBase is BaseTest { + uint256 internal constant DISPUTE_TIMEOUT = 20; + uint256 internal constant POOL_DEPOSIT = 10_000_000; + + // Each test that opens a channel should bump this so consecutive openInitializers + // hash to distinct values (since they share the same peers). + uint256 internal openDeadlineCursor = 1_000_000; + + function setUp() public virtual override { + super.setUp(); + + // Pre-fund the peers in EthPool so deposits via the pool path work. + vm.deal(peer0, 100 ether); + vm.deal(peer1, 100 ether); + vm.prank(peer0); + ethPool.deposit{value: POOL_DEPOSIT}(peer0); + vm.prank(peer1); + ethPool.deposit{value: POOL_DEPOSIT}(peer1); + + // Approve the ledger to draw from the pool. + vm.prank(peer0); + ethPool.approve(address(celerLedger), type(uint256).max); + vm.prank(peer1); + ethPool.approve(address(celerLedger), type(uint256).max); + } + + // ------------------------------------------------------------------------- + // Channel-id derivation + // ------------------------------------------------------------------------- + + /// @dev Derive the channel id that `openChannel` will produce for a given + /// initializer-bytes payload. Mirrors `CelerWallet.create` id derivation: + /// `keccak256(wallet, ledger, keccak256(initializer))`. + function _deriveChannelId(bytes memory _initializer) internal view returns (bytes32) { + bytes32 nonce = keccak256(_initializer); + return keccak256(abi.encodePacked(address(celerWallet), address(celerLedger), nonce)); + } + + // ------------------------------------------------------------------------- + // Open-channel fixture + // ------------------------------------------------------------------------- + + /// @dev Build an `OpenChannelRequest` for an ETH channel. + /// `_amounts` are the per-peer initial deposits (sorted same as peer addresses). + function _buildOpenEth(uint256[2] memory _amounts, uint256 _msgValueReceiver, uint256 _openDeadline) + internal + view + returns (bytes memory request, bytes memory initializer, bytes32 channelId) + { + Fixtures.PaymentChannelInitializer memory init = Fixtures.PaymentChannelInitializer({ + tokenType: 1, // ETH + tokenAddress: address(0), + peers: [peer0, peer1], + amounts: _amounts, + openDeadline: _openDeadline, + disputeTimeout: DISPUTE_TIMEOUT, + msgValueReceiver: _msgValueReceiver + }); + initializer = Fixtures.encPaymentChannelInitializer(init); + + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, initializer); + request = Fixtures.encOpenChannelRequest(initializer, sigs); + channelId = _deriveChannelId(initializer); + } + + /// @dev Build an `OpenChannelRequest` for an ERC20 channel. + function _buildOpenErc20(address _token, uint256[2] memory _amounts, uint256 _openDeadline) + internal + view + returns (bytes memory request, bytes memory initializer, bytes32 channelId) + { + Fixtures.PaymentChannelInitializer memory init = Fixtures.PaymentChannelInitializer({ + tokenType: 2, // ERC20 + tokenAddress: _token, + peers: [peer0, peer1], + amounts: _amounts, + openDeadline: _openDeadline, + disputeTimeout: DISPUTE_TIMEOUT, + msgValueReceiver: 0 + }); + initializer = Fixtures.encPaymentChannelInitializer(init); + + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, initializer); + request = Fixtures.encOpenChannelRequest(initializer, sigs); + channelId = _deriveChannelId(initializer); + } + + /// @dev Open a zero-deposit ETH channel and return its channel id. + function _openZeroEthChannel() internal returns (bytes32 channelId) { + uint256 deadline = openDeadlineCursor++; + (bytes memory request,, bytes32 derivedId) = _buildOpenEth([uint256(0), 0], 0, deadline); + celerLedger.openChannel(request); + return derivedId; + } + + /// @dev Open a funded ETH channel via msg.value (peer 0 pays). + function _openFundedEthChannel(uint256[2] memory _amounts) internal returns (bytes32 channelId) { + uint256 deadline = openDeadlineCursor++; + (bytes memory request,, bytes32 derivedId) = _buildOpenEth(_amounts, 0, deadline); + // peer0 sends msg.value = amounts[0]; remainder pulled from peer1's EthPool balance. + vm.prank(peer0); + celerLedger.openChannel{value: _amounts[0]}(request); + return derivedId; + } + + // ------------------------------------------------------------------------- + // Cooperative withdraw fixture + // ------------------------------------------------------------------------- + + function _buildCoopWithdraw( + bytes32 _channelId, + uint256 _seqNum, + address _receiver, + uint256 _amount, + uint256 _withdrawDeadline, + bytes32 _recipientChannelId + ) internal view returns (bytes memory) { + Fixtures.CooperativeWithdrawInfo memory w = Fixtures.CooperativeWithdrawInfo({ + channelId: _channelId, + seqNum: _seqNum, + withdrawAccount: _receiver, + withdrawAmount: _amount, + withdrawDeadline: _withdrawDeadline, + recipientChannelId: _recipientChannelId + }); + bytes memory body = Fixtures.encCooperativeWithdrawInfo(w); + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, body); + return Fixtures.encCooperativeWithdrawRequest(body, sigs); + } + + // ------------------------------------------------------------------------- + // Cooperative settle fixture + // ------------------------------------------------------------------------- + + function _buildCoopSettle( + bytes32 _channelId, + uint256 _seqNum, + uint256[2] memory _settleAmounts, + uint256 _settleDeadline + ) internal view returns (bytes memory) { + Fixtures.CooperativeSettleInfo memory s = Fixtures.CooperativeSettleInfo({ + channelId: _channelId, + seqNum: _seqNum, + settleAccounts: [peer0, peer1], + settleAmounts: _settleAmounts, + settleDeadline: _settleDeadline + }); + bytes memory body = Fixtures.encCooperativeSettleInfo(s); + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, body); + return Fixtures.encCooperativeSettleRequest(body, sigs); + } + + // ------------------------------------------------------------------------- + // Simplex state fixture (for snapshotStates / intendSettle) + // ------------------------------------------------------------------------- + + /// @dev Build a co-signed simplex state with no pending pays. + function _buildSignedSimplex(bytes32 _channelId, address _peerFrom, uint256 _seqNum, uint256 _transferAmount) + internal + view + returns (bytes memory) + { + Fixtures.SimplexState memory s = Fixtures.SimplexState({ + channelId: _channelId, + peerFrom: _peerFrom, + seqNum: _seqNum, + transferAmount: _transferAmount, + pendingPayIds: bytes(""), + lastPayResolveDeadline: 0, + totalPendingAmount: 0 + }); + bytes memory simplex = Fixtures.encSimplexPaymentChannel(s); + bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, simplex); + return Fixtures.encSignedSimplexState(simplex, sigs); + } + + /// @dev Build a single-signed null simplex state (seqNum = 0). Used for the + /// side of a duplex channel that has no activity in `intendSettle`. + function _buildNullSimplex(bytes32 _channelId, uint256 _signerPk) internal pure returns (bytes memory) { + Fixtures.SimplexState memory s = Fixtures.SimplexState({ + channelId: _channelId, + peerFrom: address(0), + seqNum: 0, + transferAmount: 0, + pendingPayIds: bytes(""), + lastPayResolveDeadline: 0, + totalPendingAmount: 0 + }); + bytes memory simplex = Fixtures.encSimplexPaymentChannel(s); + bytes[] memory sigs = new bytes[](1); + sigs[0] = SignUtil.sign(_signerPk, simplex); + return Fixtures.encSignedSimplexState(simplex, sigs); + } + + /// @dev Wrap one or two signed simplex states into a `SignedSimplexStateArray`. + function _wrapStateArray(bytes memory _state0, bytes memory _state1) internal pure returns (bytes memory) { + bytes[] memory states; + if (_state1.length == 0) { + states = new bytes[](1); + states[0] = _state0; + } else { + states = new bytes[](2); + states[0] = _state0; + states[1] = _state1; + } + return Fixtures.encSignedSimplexStateArray(states); + } +} diff --git a/test/utils/Proto.sol b/test/utils/Proto.sol new file mode 100644 index 0000000..5269e28 --- /dev/null +++ b/test/utils/Proto.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title Proto + * @notice Minimal protobuf wire-format encoder used by Foundry tests to construct + * the binary messages that AgentPay contracts decode (`PbEntity` / `PbChain`). + * Produces output that round-trips through the existing decoders. + * + * @dev Wire format primer: + * - tag byte = `(fieldNum << 3) | wireType` + * - wire types used here: `0 = Varint`, `2 = LengthDelim` + * - varint = 7 bits per byte, MSB=1 means "more bytes follow" + * - `bytes` / embedded message: tag, then varint length, then payload + * + * @dev Encoding policy: scalar fields with default value (zero) are omitted; + * length-delimited fields are emitted whenever `length > 0`. Callers that need + * to embed a zero-valued bytes field with `length == 0` should not call the + * helper at all (which is what protobuf would do anyway). + */ +library Proto { + uint256 internal constant WIRE_VARINT = 0; + uint256 internal constant WIRE_LEN = 2; + + // ------------------------------------------------------------------------- + // Primitives + // ------------------------------------------------------------------------- + + /// @dev Encode an unsigned varint. + function varint(uint256 _v) internal pure returns (bytes memory out) { + // Worst case: 10 bytes (uint64 max takes 10 bytes; we never go bigger). + bytes memory tmp = new bytes(10); + uint256 i = 0; + while (_v >= 0x80) { + tmp[i] = bytes1(uint8((_v & 0x7F) | 0x80)); + _v >>= 7; + i++; + } + tmp[i] = bytes1(uint8(_v)); + i++; + out = new bytes(i); + for (uint256 j = 0; j < i; j++) { + out[j] = tmp[j]; + } + } + + /// @dev Encode a wire-format key (`(fieldNum << 3) | wireType`) as a varint. + function key(uint256 _fieldNum, uint256 _wireType) internal pure returns (bytes memory) { + return varint((_fieldNum << 3) | _wireType); + } + + /// @dev Encode a length-prefixed payload: `varint(payload.length) | payload`. + function lenPrefixed(bytes memory _payload) internal pure returns (bytes memory) { + return abi.encodePacked(varint(_payload.length), _payload); + } + + /// @dev Concatenate two byte arrays. Solidity 0.8 doesn't have a native operator. + function cat(bytes memory _a, bytes memory _b) internal pure returns (bytes memory) { + return abi.encodePacked(_a, _b); + } + + /// @dev Concatenate three byte arrays. + function cat3(bytes memory _a, bytes memory _b, bytes memory _c) internal pure returns (bytes memory) { + return abi.encodePacked(_a, _b, _c); + } + + // ------------------------------------------------------------------------- + // Per-field-type helpers + // ------------------------------------------------------------------------- + + /// @dev Encode a varint field. Skips entirely if `_v == 0` (proto3 default). + function uintField(uint256 _fieldNum, uint256 _v) internal pure returns (bytes memory) { + if (_v == 0) return ""; + return cat(key(_fieldNum, WIRE_VARINT), varint(_v)); + } + + /// @dev Encode an enum field. Same as `uintField` (also skipped at zero). + function enumField(uint256 _fieldNum, uint256 _v) internal pure returns (bytes memory) { + return uintField(_fieldNum, _v); + } + + /// @dev Encode a length-delimited field (bytes / embedded message). Skips if empty. + function bytesField(uint256 _fieldNum, bytes memory _v) internal pure returns (bytes memory) { + if (_v.length == 0) return ""; + return cat(key(_fieldNum, WIRE_LEN), lenPrefixed(_v)); + } + + /// @dev Encode an `address`-typed `bytes` field (always 20 bytes when present). + function addressField(uint256 _fieldNum, address _v) internal pure returns (bytes memory) { + if (_v == address(0)) return ""; + return bytesField(_fieldNum, abi.encodePacked(_v)); + } + + /// @dev Encode a `bytes32`-typed `bytes` field (always 32 bytes when present). + function bytes32Field(uint256 _fieldNum, bytes32 _v) internal pure returns (bytes memory) { + if (_v == bytes32(0)) return ""; + return bytesField(_fieldNum, abi.encodePacked(_v)); + } + + /** + * @dev Encode a `uint256` value as the length-delimited bytes form used by the + * AgentPay protobuf messages (soltype = "uint256"). Stored big-endian, with + * leading zero bytes stripped except that zero itself is encoded as one + * zero byte. + */ + function uint256ToBytes(uint256 _v) internal pure returns (bytes memory out) { + if (_v == 0) { + out = new bytes(1); + return out; + } + // Find the highest non-zero byte. + uint256 n = 0; + uint256 tmp = _v; + while (tmp != 0) { + n++; + tmp >>= 8; + } + out = new bytes(n); + for (uint256 i = 0; i < n; i++) { + out[n - 1 - i] = bytes1(uint8(_v >> (i * 8))); + } + } + + /// @dev Encode a `uint256` field that uses the "bytes (soltype=uint256)" wire shape. + /// Always emitted when non-zero; for zero the JS encoder still emits a single 0 + /// byte if the field was explicitly set, but our policy is "omit zero" to match + /// proto3 default semantics. Use `bytesField` directly with a forced single-zero + /// byte payload if you need to express "explicit zero". + function uint256Field(uint256 _fieldNum, uint256 _v) internal pure returns (bytes memory) { + if (_v == 0) return ""; + return bytesField(_fieldNum, uint256ToBytes(_v)); + } + + /// @dev Encode a `uint256` field with an *explicit zero* payload (single 0 byte). + /// Some messages depend on this — e.g. `transferToPeer.amt = 0` in checkpoint + /// states needs to be present so the decoder reads zero rather than missing. + function uint256FieldExplicit(uint256 _fieldNum, uint256 _v) internal pure returns (bytes memory) { + return bytesField(_fieldNum, uint256ToBytes(_v)); + } + + /// @dev Encode a repeated length-delimited field. Each element gets its own tag. + /// (Repeated `bytes` and repeated embedded messages aren't packed in proto3.) + function repeatedBytesField(uint256 _fieldNum, bytes[] memory _items) internal pure returns (bytes memory out) { + for (uint256 i = 0; i < _items.length; i++) { + // Allow empty entries — protobuf encodes `bytes ""` as length=0 payload. + out = cat(out, cat(key(_fieldNum, WIRE_LEN), lenPrefixed(_items[i]))); + } + } + + /// @dev Encode a repeated `bytes32` field. Each element gets its own tag. + function repeatedBytes32Field(uint256 _fieldNum, bytes32[] memory _items) + internal + pure + returns (bytes memory out) + { + for (uint256 i = 0; i < _items.length; i++) { + out = cat(out, cat(key(_fieldNum, WIRE_LEN), lenPrefixed(abi.encodePacked(_items[i])))); + } + } +} diff --git a/test/utils/Proto.t.sol b/test/utils/Proto.t.sol new file mode 100644 index 0000000..0045278 --- /dev/null +++ b/test/utils/Proto.t.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Proto} from "./Proto.sol"; +import {Fixtures} from "./Fixtures.sol"; +import {PbEntity} from "../../src/lib/data/PbEntity.sol"; +import {PbChain} from "../../src/lib/data/PbChain.sol"; + +/** + * @title Proto encoder round-trip tests + * @notice Validates that {Fixtures} encoders produce wire-format output that the + * existing `PbEntity` / `PbChain` decoders accept and round-trip without loss. + * These tests are the gold-standard correctness check for the test infrastructure. + */ +contract ProtoTest is Test { + function test_encAccountAmtPair_roundTrips() public pure { + address account = address(0x1234567890123456789012345678901234567890); + uint256 amt = 0x1122334455; + + bytes memory encoded = Fixtures.encAccountAmtPair(account, amt); + PbEntity.AccountAmtPair memory decoded = PbEntity.decAccountAmtPair(encoded); + + assertEq(decoded.account, account, "account"); + assertEq(decoded.amt, amt, "amt"); + } + + function test_encAccountAmtPair_zeroAmt_roundTrips() public pure { + address account = address(0xdead); + bytes memory encoded = Fixtures.encAccountAmtPair(account, 0); + PbEntity.AccountAmtPair memory decoded = PbEntity.decAccountAmtPair(encoded); + + assertEq(decoded.account, account); + assertEq(decoded.amt, 0); + } + + function test_encTokenInfo_eth_roundTrips() public pure { + bytes memory encoded = Fixtures.encTokenInfo(1, address(0)); + PbEntity.TokenInfo memory decoded = PbEntity.decTokenInfo(encoded); + + assertEq(uint256(decoded.tokenType), 1); + assertEq(decoded.tokenAddress, address(0)); + } + + function test_encTokenInfo_erc20_roundTrips() public pure { + address token = address(0x9999999999999999999999999999999999999999); + bytes memory encoded = Fixtures.encTokenInfo(2, token); + PbEntity.TokenInfo memory decoded = PbEntity.decTokenInfo(encoded); + + assertEq(uint256(decoded.tokenType), 2); + assertEq(decoded.tokenAddress, token); + } + + function test_encConditionalPay_roundTrips() public pure { + address src = address(0x1111111111111111111111111111111111111111); + address dest = address(0x2222222222222222222222222222222222222222); + address payResolver = address(0x3333333333333333333333333333333333333333); + + Fixtures.Condition[] memory conds = new Fixtures.Condition[](1); + conds[0] = Fixtures.condHashLock(keccak256("preimage")); + + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: 12345, + src: src, + dest: dest, + conditions: conds, + logicType: 0, // BOOLEAN_AND + maxAmount: 1000, + resolveDeadline: 999999, + resolveTimeout: 5, + payResolver: payResolver + }); + + bytes memory encoded = Fixtures.encConditionalPay(pay); + PbEntity.ConditionalPay memory decoded = PbEntity.decConditionalPay(encoded); + + assertEq(decoded.payTimestamp, 12345); + assertEq(decoded.src, src); + assertEq(decoded.dest, dest); + assertEq(decoded.conditions.length, 1); + assertEq(uint256(decoded.conditions[0].conditionType), 0); // HASH_LOCK + assertEq(decoded.conditions[0].hashLock, keccak256("preimage")); + assertEq(uint256(decoded.transferFunc.logicType), 0); + assertEq(decoded.transferFunc.maxTransfer.receiver.amt, 1000); + assertEq(decoded.resolveDeadline, 999999); + assertEq(decoded.resolveTimeout, 5); + assertEq(decoded.payResolver, payResolver); + } + + function test_encConditionalPay_multipleConditions_roundTrips() public pure { + Fixtures.Condition[] memory conds = new Fixtures.Condition[](3); + conds[0] = Fixtures.condHashLock(bytes32(uint256(0xabc))); + conds[1] = Fixtures.condDeployedBoolean(address(0x4444), true); + conds[2] = Fixtures.condDeployedNumeric(address(0x5555), 42); + + Fixtures.ConditionalPay memory pay = Fixtures.ConditionalPay({ + payTimestamp: 1, + src: address(0xa), + dest: address(0xb), + conditions: conds, + logicType: 1, // BOOLEAN_OR + maxAmount: 999, + resolveDeadline: 100, + resolveTimeout: 5, + payResolver: address(0xc) + }); + + bytes memory encoded = Fixtures.encConditionalPay(pay); + PbEntity.ConditionalPay memory decoded = PbEntity.decConditionalPay(encoded); + + assertEq(decoded.conditions.length, 3); + assertEq(uint256(decoded.conditions[0].conditionType), 0); + assertEq(decoded.conditions[0].hashLock, bytes32(uint256(0xabc))); + assertEq(uint256(decoded.conditions[1].conditionType), 1); + assertEq(decoded.conditions[1].deployedContractAddress, address(0x4444)); + assertEq(decoded.conditions[1].argsQueryOutcome.length, 1); + assertEq(uint8(decoded.conditions[1].argsQueryOutcome[0]), 1); + assertEq(uint256(decoded.conditions[2].conditionType), 1); + assertEq(decoded.conditions[2].deployedContractAddress, address(0x5555)); + assertEq(uint8(decoded.conditions[2].argsQueryOutcome[0]), 42); + } + + function test_encPayIdList_roundTrips() public pure { + bytes32[] memory ids = new bytes32[](2); + ids[0] = bytes32(uint256(1)); + ids[1] = bytes32(uint256(2)); + + bytes memory encoded = Fixtures.encPayIdList(ids, bytes32(uint256(0xdeadbeef))); + PbEntity.PayIdList memory decoded = PbEntity.decPayIdList(encoded); + + assertEq(decoded.payIds.length, 2); + assertEq(decoded.payIds[0], bytes32(uint256(1))); + assertEq(decoded.payIds[1], bytes32(uint256(2))); + assertEq(decoded.nextListHash, bytes32(uint256(0xdeadbeef))); + } + + function test_encSimplexPaymentChannel_roundTrips() public pure { + Fixtures.SimplexState memory s = Fixtures.SimplexState({ + channelId: bytes32(uint256(0x1234)), + peerFrom: address(0xaaaa), + seqNum: 7, + transferAmount: 500, + pendingPayIds: bytes(""), + lastPayResolveDeadline: 100, + totalPendingAmount: 0 + }); + + bytes memory encoded = Fixtures.encSimplexPaymentChannel(s); + PbEntity.SimplexPaymentChannel memory decoded = PbEntity.decSimplexPaymentChannel(encoded); + + assertEq(decoded.channelId, bytes32(uint256(0x1234))); + assertEq(decoded.peerFrom, address(0xaaaa)); + assertEq(decoded.seqNum, 7); + assertEq(decoded.transferToPeer.receiver.amt, 500); + assertEq(decoded.lastPayResolveDeadline, 100); + assertEq(decoded.totalPendingAmount, 0); + } + + function test_encPaymentChannelInitializer_eth_roundTrips() public pure { + Fixtures.PaymentChannelInitializer memory init = Fixtures.PaymentChannelInitializer({ + tokenType: 1, + tokenAddress: address(0), + peers: [address(0x1), address(0x2)], + amounts: [uint256(100), uint256(200)], + openDeadline: 999999, + disputeTimeout: 10, + msgValueReceiver: 0 + }); + + bytes memory encoded = Fixtures.encPaymentChannelInitializer(init); + PbEntity.PaymentChannelInitializer memory decoded = PbEntity.decPaymentChannelInitializer(encoded); + + assertEq(uint256(decoded.initDistribution.token.tokenType), 1); + assertEq(decoded.initDistribution.distribution.length, 2); + assertEq(decoded.initDistribution.distribution[0].account, address(0x1)); + assertEq(decoded.initDistribution.distribution[0].amt, 100); + assertEq(decoded.initDistribution.distribution[1].account, address(0x2)); + assertEq(decoded.initDistribution.distribution[1].amt, 200); + assertEq(decoded.openDeadline, 999999); + assertEq(decoded.disputeTimeout, 10); + assertEq(decoded.msgValueReceiver, 0); + } + + function test_encOpenChannelRequest_roundTrips() public pure { + bytes memory body = hex"deadbeef"; + bytes[] memory sigs = new bytes[](2); + sigs[0] = hex"aaaa"; + sigs[1] = hex"bbbb"; + + bytes memory encoded = Fixtures.encOpenChannelRequest(body, sigs); + PbChain.OpenChannelRequest memory decoded = PbChain.decOpenChannelRequest(encoded); + + assertEq(decoded.channelInitializer, body); + assertEq(decoded.sigs.length, 2); + assertEq(decoded.sigs[0], hex"aaaa"); + assertEq(decoded.sigs[1], hex"bbbb"); + } + + function test_encCooperativeWithdrawInfo_roundTrips() public pure { + Fixtures.CooperativeWithdrawInfo memory w = Fixtures.CooperativeWithdrawInfo({ + channelId: bytes32(uint256(0xc0ffee)), + seqNum: 1, + withdrawAccount: address(0xfeed), + withdrawAmount: 50, + withdrawDeadline: 9999999, + recipientChannelId: bytes32(0) + }); + + bytes memory encoded = Fixtures.encCooperativeWithdrawInfo(w); + PbEntity.CooperativeWithdrawInfo memory decoded = PbEntity.decCooperativeWithdrawInfo(encoded); + + assertEq(decoded.channelId, bytes32(uint256(0xc0ffee))); + assertEq(decoded.seqNum, 1); + assertEq(decoded.withdraw.account, address(0xfeed)); + assertEq(decoded.withdraw.amt, 50); + assertEq(decoded.withdrawDeadline, 9999999); + assertEq(decoded.recipientChannelId, bytes32(0)); + } + + function test_encVouchedCondPayResult_roundTrips() public pure { + bytes memory result = hex"0102030405"; + bytes memory sigSrc = hex"a1a2"; + bytes memory sigDest = hex"b1b2"; + + bytes memory encoded = Fixtures.encVouchedCondPayResult(result, sigSrc, sigDest); + PbEntity.VouchedCondPayResult memory decoded = PbEntity.decVouchedCondPayResult(encoded); + + assertEq(decoded.condPayResult, result); + assertEq(decoded.sigOfSrc, sigSrc); + assertEq(decoded.sigOfDest, sigDest); + } + + function test_encResolvePayByConditionsRequest_roundTrips() public pure { + bytes memory condPay = hex"112233"; + bytes[] memory preimages = new bytes[](2); + preimages[0] = hex"a0"; + preimages[1] = hex"b0"; + + bytes memory encoded = Fixtures.encResolvePayByConditionsRequest(condPay, preimages); + PbChain.ResolvePayByConditionsRequest memory decoded = PbChain.decResolvePayByConditionsRequest(encoded); + + assertEq(decoded.condPay, condPay); + assertEq(decoded.hashPreimages.length, 2); + assertEq(decoded.hashPreimages[0], hex"a0"); + assertEq(decoded.hashPreimages[1], hex"b0"); + } + + function test_uint256ToBytes_handlesZero() public pure { + bytes memory b = Proto.uint256ToBytes(0); + assertEq(b.length, 1); + assertEq(uint8(b[0]), 0); + } + + function test_uint256ToBytes_strippLeadingZeros() public pure { + bytes memory b = Proto.uint256ToBytes(0x123); + assertEq(b.length, 2); + assertEq(uint8(b[0]), 0x01); + assertEq(uint8(b[1]), 0x23); + } + + function test_uint256ToBytes_max() public pure { + bytes memory b = Proto.uint256ToBytes(type(uint256).max); + assertEq(b.length, 32); + for (uint256 i = 0; i < 32; i++) { + assertEq(uint8(b[i]), 0xff); + } + } +} diff --git a/test/utils/SignUtil.sol b/test/utils/SignUtil.sol new file mode 100644 index 0000000..f5c5c84 --- /dev/null +++ b/test/utils/SignUtil.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Vm} from "forge-std/Vm.sol"; + +/** + * @title SignUtil + * @notice Co-sign helpers for AgentPay tests. Takes the keccak256 of the + * serialized message, applies the EIP-191 prefix + * (`MessageHashUtils.toEthSignedMessageHash`) the contracts use to verify, + * and returns a 65-byte (r,s,v) signature with `v ∈ {27, 28}`. + */ +library SignUtil { + Vm internal constant VM = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @dev EIP-191 prefix the contracts apply via `toEthSignedMessageHash`. + function ethSignedHash(bytes memory _msg) internal pure returns (bytes32) { + bytes32 messageHash = keccak256(_msg); + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)); + } + + /// @dev Sign a serialized protobuf message with `_pk`. Returns 65-byte (r,s,v). + function sign(uint256 _pk, bytes memory _msg) internal pure returns (bytes memory sig) { + bytes32 ethHash = ethSignedHash(_msg); + (uint8 v, bytes32 r, bytes32 s) = VM.sign(_pk, ethHash); + sig = abi.encodePacked(r, s, v); + } + + /// @dev Co-sign with two keys, in the same order as their addresses sort ascending. + /// Caller is responsible for ensuring (`_pk0`'s addr < `_pk1`'s addr) — pass keys + /// for already-sorted peer pair (see `BaseTest._makeSortedPeerPair`). + function coSign(uint256 _pk0, uint256 _pk1, bytes memory _msg) internal pure returns (bytes[] memory sigs) { + sigs = new bytes[](2); + sigs[0] = sign(_pk0, _msg); + sigs[1] = sign(_pk1, _msg); + } +} From af838003551166183c40e58b2388da453d63a92e Mon Sep 17 00:00:00 2001 From: Xiaozhou Li Date: Mon, 27 Apr 2026 23:36:23 -0700 Subject: [PATCH 2/3] move gas_logs --- .github/workflows/ci.yml | 3 ++ foundry.toml | 4 +- test/GasReport.t.sol | 40 +++++++++---------- .../gas_logs}/CelerLedger-ERC20.txt | 0 .../gas_logs}/CelerLedger-ETH.txt | 0 .../gas_logs}/CelerLedger-Migrate.txt | 0 {gas_logs => test/gas_logs}/EthPool.txt | 0 {gas_logs => test/gas_logs}/PayResolver.txt | 0 .../gas_logs}/VirtContractResolver.txt | 0 .../gas_logs}/fine_granularity/ClearPays.txt | 0 .../fine_granularity/DepositEthInBatch.txt | 0 .../IntendSettle-OneState.txt | 0 12 files changed, 25 insertions(+), 22 deletions(-) rename {gas_logs => test/gas_logs}/CelerLedger-ERC20.txt (100%) rename {gas_logs => test/gas_logs}/CelerLedger-ETH.txt (100%) rename {gas_logs => test/gas_logs}/CelerLedger-Migrate.txt (100%) rename {gas_logs => test/gas_logs}/EthPool.txt (100%) rename {gas_logs => test/gas_logs}/PayResolver.txt (100%) rename {gas_logs => test/gas_logs}/VirtContractResolver.txt (100%) rename {gas_logs => test/gas_logs}/fine_granularity/ClearPays.txt (100%) rename {gas_logs => test/gas_logs}/fine_granularity/DepositEthInBatch.txt (100%) rename {gas_logs => test/gas_logs}/fine_granularity/IntendSettle-OneState.txt (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05392dc..2cfe4cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: # Solidity sources, tests, deploy scripts. - 'src/**' - 'test/**' + # Gas-report baselines are generated artifacts — exclude from CI triggers + # so a baseline refresh alone does not run the full suite. + - '!test/gas_logs/**' - 'script/**' # Submodule SHA bumps (forge-std, openzeppelin) record under lib/. - 'lib/**' diff --git a/foundry.toml b/foundry.toml index 2ae5ccd..51f9dbb 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,8 +6,8 @@ out = "out" libs = ["lib"] optimizer = true optimizer_runs = 200 -# Allow GasReport tests to write `gas_logs/*.txt` reports. -fs_permissions = [{ access = "write", path = "./gas_logs" }] +# Allow GasReport tests to write `test/gas_logs/*.txt` reports. +fs_permissions = [{ access = "write", path = "./test/gas_logs" }] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/test/GasReport.t.sol b/test/GasReport.t.sol index f4e20d3..ec31a6c 100644 --- a/test/GasReport.t.sol +++ b/test/GasReport.t.sol @@ -15,31 +15,31 @@ import {BooleanCondMock} from "../src/helper/BooleanCondMock.sol"; /** * @title Gas report generator - * @notice Produces human-readable gas reports under [`gas_logs/`](../gas_logs/), + * @notice Produces human-readable gas reports under [`test/gas_logs/`](gas_logs/), * one file per top-level component. Each report lists per-call gas for the * operations a reviewer typically cares about; `fine_granularity/` adds scaling * sweeps for the operations whose cost is parameterized (pay-list size, batch * size). * * @dev Top-level reports (per-call gas): - * - `gas_logs/CelerLedger-ETH.txt` - * - `gas_logs/CelerLedger-ERC20.txt` - * - `gas_logs/CelerLedger-Migrate.txt` - * - `gas_logs/EthPool.txt` - * - `gas_logs/PayResolver.txt` - * - `gas_logs/VirtContractResolver.txt` + * - `test/gas_logs/CelerLedger-ETH.txt` + * - `test/gas_logs/CelerLedger-ERC20.txt` + * - `test/gas_logs/CelerLedger-Migrate.txt` + * - `test/gas_logs/EthPool.txt` + * - `test/gas_logs/PayResolver.txt` + * - `test/gas_logs/VirtContractResolver.txt` * * Fine-granularity scaling sweeps: - * - `gas_logs/fine_granularity/IntendSettle-OneState.txt` - * - `gas_logs/fine_granularity/ClearPays.txt` - * - `gas_logs/fine_granularity/DepositEthInBatch.txt` + * - `test/gas_logs/fine_granularity/IntendSettle-OneState.txt` + * - `test/gas_logs/fine_granularity/ClearPays.txt` + * - `test/gas_logs/fine_granularity/DepositEthInBatch.txt` * * @dev Run via: * forge test --match-contract GasReport -vv * Each test writes one report file. Measurements use `gasleft()` deltas around * the operation under test, which closely tracks the actual gas cost. When * running under `forge coverage`, file writes are skipped so coverage does not - * dirty the committed `gas_logs/` baselines with instrumentation-inflated numbers. + * dirty the committed `test/gas_logs/` baselines with instrumentation-inflated numbers. */ contract GasReport is LedgerTestBase { function setUp() public override { @@ -90,7 +90,7 @@ contract GasReport is LedgerTestBase { s = string.concat(s, _row("confirmSettle()", _measure_confirmSettle())); s = string.concat(s, _row("cooperativeSettle()", _measure_cooperativeSettle())); - _writeReport("gas_logs/CelerLedger-ETH.txt", s); + _writeReport("test/gas_logs/CelerLedger-ETH.txt", s); } // ========================================================================= @@ -105,7 +105,7 @@ contract GasReport is LedgerTestBase { s = string.concat(s, _row("deposit()", _measure_deposit_erc20())); s = string.concat(s, _row("cooperativeWithdraw()", _measure_cooperativeWithdraw_erc20())); s = string.concat(s, _row("cooperativeSettle()", _measure_cooperativeSettle_erc20())); - _writeReport("gas_logs/CelerLedger-ERC20.txt", s); + _writeReport("test/gas_logs/CelerLedger-ERC20.txt", s); } // ========================================================================= @@ -118,7 +118,7 @@ contract GasReport is LedgerTestBase { s = string.concat(s, _row("migrateChannelFrom() an Operable ETH channel", _measure_migrate_operableEth())); s = string.concat(s, _row("migrateChannelFrom() a Settling ETH channel", _measure_migrate_settlingEth())); s = string.concat(s, _row("migrateChannelFrom() an Operable ERC20 channel", _measure_migrate_operableErc20())); - _writeReport("gas_logs/CelerLedger-Migrate.txt", s); + _writeReport("test/gas_logs/CelerLedger-Migrate.txt", s); } // ========================================================================= @@ -130,14 +130,14 @@ contract GasReport is LedgerTestBase { s = string.concat(s, "***** Function Calls Gas Used *****\n"); s = string.concat(s, _row("resolvePaymentByConditions()", _measure_resolveByConditions())); s = string.concat(s, _row("resolvePaymentByVouchedResult()", _measure_resolveByVouchedResult())); - _writeReport("gas_logs/PayResolver.txt", s); + _writeReport("test/gas_logs/PayResolver.txt", s); } function test_writeReport_VirtContractResolver() public { string memory s = "********** Gas Measurement: VirtContractResolver **********\n\n"; s = string.concat(s, "***** Function Calls Gas Used *****\n"); s = string.concat(s, _row("deploy() - BooleanCondMock", _measure_virtDeploy())); - _writeReport("gas_logs/VirtContractResolver.txt", s); + _writeReport("test/gas_logs/VirtContractResolver.txt", s); } function test_writeReport_EthPool() public { @@ -149,7 +149,7 @@ contract GasReport is LedgerTestBase { s = string.concat(s, _row("transferFrom()", _measure_ethPool_transferFrom())); s = string.concat(s, _row("increaseAllowance()", _measure_ethPool_increaseAllowance())); s = string.concat(s, _row("decreaseAllowance()", _measure_ethPool_decreaseAllowance())); - _writeReport("gas_logs/EthPool.txt", s); + _writeReport("test/gas_logs/EthPool.txt", s); } // ========================================================================= @@ -177,7 +177,7 @@ contract GasReport is LedgerTestBase { s = string.concat(s, vm.toString(sizes[i]), "\t", vm.toString(gasUsed), "\n"); } - _writeReport("gas_logs/fine_granularity/IntendSettle-OneState.txt", s); + _writeReport("test/gas_logs/fine_granularity/IntendSettle-OneState.txt", s); } // ========================================================================= @@ -201,7 +201,7 @@ contract GasReport is LedgerTestBase { s = string.concat(s, vm.toString(sizes[i]), "\t", vm.toString(gasUsed), "\n"); } - _writeReport("gas_logs/fine_granularity/DepositEthInBatch.txt", s); + _writeReport("test/gas_logs/fine_granularity/DepositEthInBatch.txt", s); } // ========================================================================= @@ -225,7 +225,7 @@ contract GasReport is LedgerTestBase { s = string.concat(s, vm.toString(sizes[i]), "\t", vm.toString(gasUsed), "\n"); } - _writeReport("gas_logs/fine_granularity/ClearPays.txt", s); + _writeReport("test/gas_logs/fine_granularity/ClearPays.txt", s); } // ========================================================================= diff --git a/gas_logs/CelerLedger-ERC20.txt b/test/gas_logs/CelerLedger-ERC20.txt similarity index 100% rename from gas_logs/CelerLedger-ERC20.txt rename to test/gas_logs/CelerLedger-ERC20.txt diff --git a/gas_logs/CelerLedger-ETH.txt b/test/gas_logs/CelerLedger-ETH.txt similarity index 100% rename from gas_logs/CelerLedger-ETH.txt rename to test/gas_logs/CelerLedger-ETH.txt diff --git a/gas_logs/CelerLedger-Migrate.txt b/test/gas_logs/CelerLedger-Migrate.txt similarity index 100% rename from gas_logs/CelerLedger-Migrate.txt rename to test/gas_logs/CelerLedger-Migrate.txt diff --git a/gas_logs/EthPool.txt b/test/gas_logs/EthPool.txt similarity index 100% rename from gas_logs/EthPool.txt rename to test/gas_logs/EthPool.txt diff --git a/gas_logs/PayResolver.txt b/test/gas_logs/PayResolver.txt similarity index 100% rename from gas_logs/PayResolver.txt rename to test/gas_logs/PayResolver.txt diff --git a/gas_logs/VirtContractResolver.txt b/test/gas_logs/VirtContractResolver.txt similarity index 100% rename from gas_logs/VirtContractResolver.txt rename to test/gas_logs/VirtContractResolver.txt diff --git a/gas_logs/fine_granularity/ClearPays.txt b/test/gas_logs/fine_granularity/ClearPays.txt similarity index 100% rename from gas_logs/fine_granularity/ClearPays.txt rename to test/gas_logs/fine_granularity/ClearPays.txt diff --git a/gas_logs/fine_granularity/DepositEthInBatch.txt b/test/gas_logs/fine_granularity/DepositEthInBatch.txt similarity index 100% rename from gas_logs/fine_granularity/DepositEthInBatch.txt rename to test/gas_logs/fine_granularity/DepositEthInBatch.txt diff --git a/gas_logs/fine_granularity/IntendSettle-OneState.txt b/test/gas_logs/fine_granularity/IntendSettle-OneState.txt similarity index 100% rename from gas_logs/fine_granularity/IntendSettle-OneState.txt rename to test/gas_logs/fine_granularity/IntendSettle-OneState.txt From 2eb27e08ec87bd177a1bc2f3188709bbc21c440f Mon Sep 17 00:00:00 2001 From: Xiaozhou Li Date: Mon, 27 Apr 2026 23:52:44 -0700 Subject: [PATCH 3/3] update fmt, pin foundry version --- .github/workflows/ci.yml | 10 ++++++++-- src/CelerWallet.sol | 5 +---- src/helper/BooleanCondMock.sol | 8 +++++++- src/helper/NumericCondMock.sol | 8 +++++++- src/lib/data/PbEntity.sol | 6 +----- src/lib/ledgerlib/LedgerChannel.sol | 13 +++++++------ src/lib/ledgerlib/LedgerOperation.sol | 22 ++++++++-------------- test/CelerLedger.Migrate.t.sol | 5 +---- test/GasReport.t.sol | 5 +---- test/utils/Fixtures.sol | 6 +----- test/utils/Proto.sol | 6 +----- 11 files changed, 43 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cfe4cf..04052bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,10 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: - version: stable + # Pin to a specific Foundry release so CI is reproducible. Bump this + # alongside any local `foundryup` upgrade, then re-run `forge fmt` to + # absorb any new formatter rules. + version: v1.5.1 - name: Show versions run: forge --version @@ -65,7 +68,10 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: - version: stable + # Pin to a specific Foundry release so CI is reproducible. Bump this + # alongside any local `foundryup` upgrade, then re-run `forge fmt` to + # absorb any new formatter rules. + version: v1.5.1 - name: Run tests run: forge test -vv diff --git a/src/CelerWallet.sol b/src/CelerWallet.sol index 92cd70b..05eda82 100644 --- a/src/CelerWallet.sol +++ b/src/CelerWallet.sol @@ -179,10 +179,7 @@ contract CelerWallet is ICelerWallet, Pausable, Ownable { * @param _walletId id of wallet which owners propose new operator of * @param _newOperator the new operator proposal */ - function proposeNewOperator(bytes32 _walletId, address _newOperator) - public - onlyWalletOwner(_walletId, msg.sender) - { + function proposeNewOperator(bytes32 _walletId, address _newOperator) public onlyWalletOwner(_walletId, msg.sender) { require(_newOperator != address(0), "New operator is address(0)"); Wallet storage w = wallets[_walletId]; diff --git a/src/helper/BooleanCondMock.sol b/src/helper/BooleanCondMock.sol index e89f0e3..bf2b753 100644 --- a/src/helper/BooleanCondMock.sol +++ b/src/helper/BooleanCondMock.sol @@ -10,7 +10,13 @@ import "../lib/interface/IBooleanCond.sol"; * exercise condition-evaluation paths. **Do not deploy to a production network.** */ contract BooleanCondMock is IBooleanCond { - function isFinalized(bytes calldata /* _query */ ) external pure returns (bool) { + function isFinalized( + bytes calldata /* _query */ + ) + external + pure + returns (bool) + { return true; } diff --git a/src/helper/NumericCondMock.sol b/src/helper/NumericCondMock.sol index 866303b..f3e60a4 100644 --- a/src/helper/NumericCondMock.sol +++ b/src/helper/NumericCondMock.sol @@ -10,7 +10,13 @@ import "../lib/interface/INumericCond.sol"; * {BooleanCondMock}. **Do not deploy to a production network.** */ contract NumericCondMock is INumericCond { - function isFinalized(bytes calldata /* _query */ ) external pure returns (bool) { + function isFinalized( + bytes calldata /* _query */ + ) + external + pure + returns (bool) + { return true; } diff --git a/src/lib/data/PbEntity.sol b/src/lib/data/PbEntity.sol index 2db33e3..e08a08f 100644 --- a/src/lib/data/PbEntity.sol +++ b/src/lib/data/PbEntity.sol @@ -416,11 +416,7 @@ library PbEntity { uint256 msgValueReceiver; // tag: 4 } // end struct PaymentChannelInitializer - function decPaymentChannelInitializer(bytes memory raw) - internal - pure - returns (PaymentChannelInitializer memory m) - { + function decPaymentChannelInitializer(bytes memory raw) internal pure returns (PaymentChannelInitializer memory m) { Pb.Buffer memory buf = Pb.fromBytes(raw); uint256 tag; diff --git a/src/lib/ledgerlib/LedgerChannel.sol b/src/lib/ledgerlib/LedgerChannel.sol index fefbba5..f7e6354 100644 --- a/src/lib/ledgerlib/LedgerChannel.sol +++ b/src/lib/ledgerlib/LedgerChannel.sol @@ -267,12 +267,13 @@ library LedgerChannel { returns (address, uint256, uint256, bytes32) { LedgerStruct.WithdrawIntent memory withdrawIntent = _c.withdrawIntent; - return ( - withdrawIntent.receiver, - withdrawIntent.amount, - withdrawIntent.requestTime, - withdrawIntent.recipientChannelId - ); + return + ( + withdrawIntent.receiver, + withdrawIntent.amount, + withdrawIntent.requestTime, + withdrawIntent.recipientChannelId + ); } /** diff --git a/src/lib/ledgerlib/LedgerOperation.sol b/src/lib/ledgerlib/LedgerOperation.sol index 18a40a3..1423b50 100644 --- a/src/lib/ledgerlib/LedgerOperation.sol +++ b/src/lib/ledgerlib/LedgerOperation.sol @@ -128,9 +128,8 @@ library LedgerOperation { _self.celerWallet.depositETH{value: msgValue}(_channelId); } if (_transferFromAmount > 0) { - _self.ethPool.transferToCelerWallet( - msg.sender, address(_self.celerWallet), _channelId, _transferFromAmount - ); + _self.ethPool + .transferToCelerWallet(msg.sender, address(_self.celerWallet), _channelId, _transferFromAmount); } } else if (c.token.tokenType == PbEntity.TokenType.ERC20) { require(msgValue == 0, "msg.value is not 0"); @@ -454,14 +453,10 @@ library LedgerOperation { // TODO: add an additional clearSafeMargin param or change the semantics of // lastPayResolveDeadline to also include clearPays safe margin and rename it. require( - ( - peerProfiles[0].state.nextPayIdListHash == bytes32(0) - || blockNumber > peerProfiles[0].state.lastPayResolveDeadline - ) - && ( - peerProfiles[1].state.nextPayIdListHash == bytes32(0) - || blockNumber > peerProfiles[1].state.lastPayResolveDeadline - ), + (peerProfiles[0].state.nextPayIdListHash == bytes32(0) + || blockNumber > peerProfiles[0].state.lastPayResolveDeadline) + && (peerProfiles[1].state.nextPayIdListHash == bytes32(0) + || blockNumber > peerProfiles[1].state.lastPayResolveDeadline), "Payments are not finalized" ); @@ -669,9 +664,8 @@ library LedgerOperation { _addDeposit(_self, _recipientChannelId, _receiver, _amount); // move funds from one channel's wallet to another channel's wallet - _self.celerWallet.transferToWallet( - _channelId, _recipientChannelId, c.token.tokenAddress, _receiver, _amount - ); + _self.celerWallet + .transferToWallet(_channelId, _recipientChannelId, c.token.tokenAddress, _receiver, _amount); } } diff --git a/test/CelerLedger.Migrate.t.sol b/test/CelerLedger.Migrate.t.sol index 462a75f..aa439c2 100644 --- a/test/CelerLedger.Migrate.t.sol +++ b/test/CelerLedger.Migrate.t.sol @@ -126,10 +126,7 @@ contract CelerLedgerMigrateTest is LedgerTestBase { returns (bytes memory) { Fixtures.ChannelMigrationInfo memory info = Fixtures.ChannelMigrationInfo({ - channelId: _channelId, - fromLedger: _fromLedger, - toLedger: _toLedger, - migrationDeadline: _deadline + channelId: _channelId, fromLedger: _fromLedger, toLedger: _toLedger, migrationDeadline: _deadline }); bytes memory body = Fixtures.encChannelMigrationInfo(info); bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, body); diff --git a/test/GasReport.t.sol b/test/GasReport.t.sol index ec31a6c..73c4982 100644 --- a/test/GasReport.t.sol +++ b/test/GasReport.t.sol @@ -876,10 +876,7 @@ contract GasReport is LedgerTestBase { returns (bytes memory) { Fixtures.ChannelMigrationInfo memory info = Fixtures.ChannelMigrationInfo({ - channelId: _channelId, - fromLedger: _fromLedger, - toLedger: _toLedger, - migrationDeadline: _deadline + channelId: _channelId, fromLedger: _fromLedger, toLedger: _toLedger, migrationDeadline: _deadline }); bytes memory body = Fixtures.encChannelMigrationInfo(info); bytes[] memory sigs = SignUtil.coSign(peer0Pk, peer1Pk, body); diff --git a/test/utils/Fixtures.sol b/test/utils/Fixtures.sol index 76d1559..73d1e0e 100644 --- a/test/utils/Fixtures.sol +++ b/test/utils/Fixtures.sol @@ -317,11 +317,7 @@ library Fixtures { } /// @notice Encode `chain.ChannelMigrationRequest`. - function encChannelMigrationRequest(bytes memory _info, bytes[] memory _sigs) - internal - pure - returns (bytes memory) - { + function encChannelMigrationRequest(bytes memory _info, bytes[] memory _sigs) internal pure returns (bytes memory) { return encBodyAndSigs(_info, _sigs); } diff --git a/test/utils/Proto.sol b/test/utils/Proto.sol index 5269e28..b108973 100644 --- a/test/utils/Proto.sol +++ b/test/utils/Proto.sol @@ -148,11 +148,7 @@ library Proto { } /// @dev Encode a repeated `bytes32` field. Each element gets its own tag. - function repeatedBytes32Field(uint256 _fieldNum, bytes32[] memory _items) - internal - pure - returns (bytes memory out) - { + function repeatedBytes32Field(uint256 _fieldNum, bytes32[] memory _items) internal pure returns (bytes memory out) { for (uint256 i = 0; i < _items.length; i++) { out = cat(out, cat(key(_fieldNum, WIRE_LEN), lenPrefixed(abi.encodePacked(_items[i])))); }