diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03583b9..9c75888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,11 @@ jobs: fi - name: Coverage Report + env: + FOUNDRY_PROFILE: coverage run: | - forge coverage --report summary - forge coverage --report lcov + forge coverage --report summary --ir-minimum + forge coverage --report lcov --ir-minimum - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c53439c..c012ce5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,6 +25,11 @@ on: - Registry - SafeBaseEscrow - Executor + force_new: + description: 'Force new proxy (ignore existing addresses)' + required: false + type: boolean + default: false jobs: deploy: @@ -51,6 +56,7 @@ jobs: OWNER_ADDRESS: ${{ secrets.OWNER_ADDRESS }} BASESCAN_API_KEY: ${{ secrets.BASESCAN_API_KEY }} DEPLOY_CONTRACT: ${{ inputs.contract }} + FORCE_NEW: ${{ inputs.force_new }} - name: Save deployment artifacts uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 57cda07..acc4cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .env broadcast/ lib/ +.DS_Store diff --git a/README.md b/README.md index ff6ec4e..14eb1b4 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,9 @@ forge script script/DeployAndInteract.s.sol \ | Verifier | `0x1B079e9519CF110b491a231d7AA67c9a597F13B2` | ✅ | | PaymentTracker | `0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94` | ✅ | | BasePay | `0x062d3a45862a32BF5D1e35404aaA55e7027c4F4B` | ✅ | -| RulesEngine | `0xDb1855c6C8ADd51eE4B7e132173cA9833B1DAf07` | ✅ | +| RulesEngine | `0xFA194dd94Fb7E8253fb6717eF4C6C23D4b2Cc7A2` | ✅ | | Registry | `0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8` | ✅ | -| SafeBaseEscrow | `0xA1e13a0E7E54bC71ee4173D74773b455A86816aB` | ✅ | +| SafeBaseEscrow | `0x2a441dD6a9B81013D49C1d553e8c42Cb32679652` | ✅ | | Executor | `0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428` | ✅ | ### Base Mainnet @@ -86,10 +86,10 @@ forge script script/DeployAndInteract.s.sol \ | Verifier | `0xb06d4414B479eb425f6E7d38226d0194C595c7CF` | ✅ | | PaymentTracker | `0xAA1be2099208db011dFbEa7174114D69982cFcef` | ✅ | | BasePay | `0xD47991043dA73bdfcF6c399e5Ed26e5C8D6c3D27` | ✅ | -| RulesEngine | `0x7bFA481f050AC09d676A7Ba61397b3f4dac6E558` | ✅ | +| RulesEngine | `0x02267434995220548CCc3171263229a2aa54e1a4` | ✅ | | Registry | `0x273930106653461A2F4f33Ea2821652283dcAE11` | ✅ | -| SafeBaseEscrow | `0x1B079e9519CF110b491a231d7AA67c9a597F13B2` | ✅ | -| Executor | `0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94` | ✅ | +| SafeBaseEscrow | `0xec0c6F43b9064cE1C33E9343671c0e67cB19594c` | ✅ | +| Executor | `0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94` | ✅ | Impl: `0xBb744584644c5956353bC8E382EC8E1dAA4286BF` | **Network Details:** - **Base Sepolia RPC**: `https://sepolia.base.org` diff --git a/deployments/8453.json b/deployments/8453.json index 4ab1b2e..82ad962 100644 --- a/deployments/8453.json +++ b/deployments/8453.json @@ -24,20 +24,20 @@ "implementation": "0x546bD44cE5576A6e90cC7150aD93aAD1B06291BE" }, "RulesEngine": { - "proxy": "0x7bFA481f050AC09d676A7Ba61397b3f4dac6E558", - "implementation": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428" + "proxy": "0x02267434995220548CCc3171263229a2aa54e1a4", + "implementation": "0xF2a7fbffD5760721C99104A7C1f500797F3D1314" }, "Registry": { "proxy": "0x273930106653461A2F4f33Ea2821652283dcAE11", "implementation": "0xC1E06BfBBe9b812BFc5feFBe4efDFb7B9A4E64cB" }, "SafeBaseEscrow": { - "proxy": "0x1B079e9519CF110b491a231d7AA67c9a597F13B2", - "implementation": "0xA1e13a0E7E54bC71ee4173D74773b455A86816aB" + "proxy": "0xec0c6F43b9064cE1C33E9343671c0e67cB19594c", + "implementation": "0x9e2B522F357711CF4Cd24D781ED2a553E08cCC59" }, "Executor": { "proxy": "0xdBa335d18751944b46f205F32F03Fa4F1BEf1a94", - "implementation": "0xD7fd8D4026123B1c4135cbF6ba91b7A0b3a5C748" + "implementation": "0xBb744584644c5956353bC8E382EC8E1dAA4286BF" } } } diff --git a/deployments/84532.json b/deployments/84532.json index 4494b25..a2cf9b7 100644 --- a/deployments/84532.json +++ b/deployments/84532.json @@ -24,20 +24,20 @@ "implementation": "0xCB66CBF0A09c3Bf336f1290DFB6e6CfB213132Ff" }, "RulesEngine": { - "proxy": "0xDb1855c6C8ADd51eE4B7e132173cA9833B1DAf07", - "implementation": "0xFA439194fe9B624AD51f7ecccf9FbcdB4350Bc70" + "proxy": "0xFA194dd94Fb7E8253fb6717eF4C6C23D4b2Cc7A2", + "implementation": "0x1d4036bbc2960a6b572D07eab33a3744FbF354D6" }, "Registry": { "proxy": "0x57741EE5bAc991D43Cf71207781fCB0eE4b9e9a8", "implementation": "0x509014Ac3d57ee08B2A47639aCDeEaE1B700960f" }, "SafeBaseEscrow": { - "proxy": "0xA1e13a0E7E54bC71ee4173D74773b455A86816aB", - "implementation": "0x682026827839A367252Ec80a0bbaaA47AFA3d870" + "proxy": "0x2a441dD6a9B81013D49C1d553e8c42Cb32679652", + "implementation": "0x675c3F20aC7E6C7BF64D07F079EC84785610c12e" }, "Executor": { "proxy": "0xB49e7b4cCB76B3aE9439798eb980434CBCF8c428", - "implementation": "0x6112146dea7A81D2F3F189084608a99BCAA2F388" + "implementation": "0x9Bc334daCcB224cac61BDbeB97669BB1C67dBe1E" } } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1f77a65..8f7a48e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,3 +1,36 @@ -# docs: add architecture docs +# SafeBase Architecture -Documentation placeholder. +## Modules +- **SafeBaseEscrowV1** - 6-state FSM, stores deal params, routes calls to Treasury, RulesEngine, Registry. +- **RulesEngineV1** - evaluates release/refund (approvals, deadlines, external verifier, auto-release/auto-refund). +- **RegistryV1** - escrow index (id -> metadata), emits events for frontend/indexer. +- **ExecutorV1** - automated execution (auto-refund/auto-release) based on rules/deadlines. +- **Treasury / TreasuryV2** - custody, multi-step approvals, Base Pay transaction idempotency. +- **BasePay / PaymentTracker / Verifier** - off-chain payment bridge, paymentId -> escrow mapping, verification. +- **Wallet layer (SmartWallet, SubAccountManager, BatchCaller, WalletFactory, NameService)** - B2B roles/limits, batching. +- **AccessController** - roles/admins, used by Treasury/Registry/Executor. +- **Webhook / onchain Utils** - helper calls and notifications. + +## Flows (high-level) +1) **Create**: buyer calls `createEscrow` (seller, mediator?, token, amount, deadline, ruleSetId) -> write to Registry. +2) **Funding**: + - `fundEscrow` (ETH) -> Treasury, state Funded. + - `fundEscrowWithBasePay(paymentId)` for off-chain payment -> PaymentTracker/Verifier confirm -> Funded. +3) **Approvals**: buyer/seller may approve; mediator can override when enabled. +4) **Release/Refund**: + - `releaseToSeller` or `refundToBuyer` check RulesEngine (approvals/deadline/verifier/mediator override) -> Treasury transfers. + - In Disputed state mediator decides. +5) **Automation**: Executor polls rules/deadlines and triggers release/refund without manual clicks. +6) **Upgrades**: UUPS, proxy owner = admin; verify storage layout before upgrade. + +## Roles +- Buyer: create/fund, approve, dispute/cancel, may trigger release when fully approved. +- Seller: receives funds, may dispute. +- Mediator: override rules, release/refund from Funded/Disputed. +- Admin/Owner: manages addresses for RulesEngine/Registry/Treasury/Executor, pause, upgrades. +- Executor bot: service that calls Executor for automated actions. + +## Integrations +- **Base Pay**: off-chain payment -> Verifier -> `fundEscrowWithBasePay`, `paymentIdToEscrow` in Registry/PaymentTracker. +- **Indexer**: consume Escrow/Treasury/Rules/Executor/BasePay events; build timelines and SLA metrics. +- **Frontend (OnchainKit + Base SDK)**: wallet connect, tx launch, status rendering from indexer. diff --git a/docs/BASEPAY.md b/docs/BASEPAY.md index 5ad7350..8e28c42 100644 --- a/docs/BASEPAY.md +++ b/docs/BASEPAY.md @@ -1,3 +1,38 @@ -# docs: add Base Pay guide +# Base Pay - flow and integration -Documentation placeholder. +## Goals +- Map off-chain payment to on-chain escrow. +- Idempotency: each `paymentId` processed once (`paymentIdToEscrow`, `TreasuryV2.basePayTransactions`). +- Transparency: events for indexer and dashboards. + +## Flow +1) User pays via Base Pay off-chain. +2) Backend listens to Base Pay notification -> validates -> triggers Verifier/PaymentTracker. +3) On-chain call `fundEscrowWithBasePay(escrowId, paymentId)`: + - Sets state to Funded. + - Stores `paymentIdToEscrow[paymentId] = escrowId`. + - Emits `BasePayFundingReceived`. +4) (Optional) `TreasuryV2.processBasePayTransaction(txId, token, to, amount)`: + - Marks `basePayTransactions[txId] = true` (idempotent). + - Emits `BasePayTransactionProcessed`. + +## Backend requirements +- Verify `paymentId` authenticity (Base Pay signature/webhook). +- Guarantee once-only processing per `paymentId` (retry with same id must be no-op). +- Log escrowId/paymentId/tx hash; retry on network errors. +- Respect proxy settings from environment. + +## Errors / edge cases +- Duplicate `paymentId`: should safely no-op/fail. +- Escrow not in Created: `InvalidState`. +- Invalid `escrowId`: `EscrowNotFound`. +- Verifier unavailable: backend must not call contract until verified. + +## Metrics/events for indexer +- `BasePayFundingReceived(escrowId, paymentId)` +- `EscrowFunded(escrowId, amount)` +- `BasePayTransactionProcessed(txId)` + +## Next steps +- Add full ERC20 handling in `fundEscrowWithBasePay` (currently only marks funding). +- Add worker service for reliable retries and dead-letter handling. diff --git a/docs/SECURITY_NOTES.md b/docs/SECURITY_NOTES.md index fbb9853..d0273ff 100644 --- a/docs/SECURITY_NOTES.md +++ b/docs/SECURITY_NOTES.md @@ -1,3 +1,30 @@ -# docs: add security notes +# Security Notes -Documentation placeholder. +## Invariants +- Escrow: terminal states are final; exactly one payout (release or refund) per escrow. +- Deadline: refund after deadline is open to anyone if no mediator; dispute requires mediator. +- Treasury: funds balance >= sum of active Funded/Disputed escrows; approvals before withdrawal. +- Rules: external verifier required when enabled; missing address blocks release. + +## Risks and mitigations +- **Mediator collusion**: mediator can release/refund from Funded/Disputed. Mitigate with trusted selection, log review, rule templates/limits. +- **Reentrancy / external calls**: withdrawals via call; keep reentrancy guard, ensure safe tokens when ERC20 path is added. +- **Deadline DoS**: many escrows with short deadlines stress Executor. Mitigate with batching, rate-limits in off-chain worker. +- **Verifier downtime**: blocks release; backend should retry and avoid calling contract until verified. +- **Upgrade risk**: UUPS - check storage layout, proxy owner, test on testnet before prod. +- **Key/role compromise**: AccessController/Treasury admins in multisig, enforce limits/approvals. + +## Upgrades +- Ensure proxy owner is nonzero; never renounce on upgradeable infra. +- Review storage layout diff before release; adjust gap carefully. +- Upgrade scripts must use `upgradeTo`/`upgradeToAndCall` and log tx. + +## Tests / QA (minimum) +- Invariant tests: single payout, terminal states irreversible, deadline rules, no release without buyerApproved when required by RuleSet. +- Fuzz: dispute/approve racing deadline; duplicate `paymentId`; mass fund/refund. +- Negative: missing verifier, zero mediator dispute, release from Created, double refund. + +## Operations +- Monitor events: `EscrowFunded/Released/Refunded/Disputed`, `BasePayFundingReceived`, `BasePayTransactionProcessed`, Treasury approvals/executions. +- Runbooks: manual withdrawal if Executor fails; actions when Verifier/Base Pay is down. +- RPC: prefer reliable RPC (mainnet.base.org/Alchemy/Infura/Ankr); avoid unstable endpoints. diff --git a/docs/SMARTWALLET.md b/docs/SMARTWALLET.md index 7c9b9cb..8f84a16 100644 --- a/docs/SMARTWALLET.md +++ b/docs/SMARTWALLET.md @@ -1,3 +1,30 @@ -# docs: add smart wallet guide +# Smart Wallet layer (B2B) -Documentation placeholder. +## Components +- **SmartWallet** - base smart account with roles. +- **SubAccountManager** - hierarchy of org subaccounts/budgets. +- **BatchCaller** - batch operations (approve, release, refund, registry calls). +- **WalletFactory** - issues wallets for orgs. +- **NameService** - readable names/aliases. + +## Roles / limits (example) +- Org Admin - creates subaccounts, sets limits, assigns operators. +- Operator - performs payments/escrow actions within limits. +- Viewer/Auditor - read-only/off-chain sign-offs. +- Limits: per-tx, daily, by op type (fund, release/refund, treasury withdraw). + +## Flows +1) Org deploys SmartWallet via Factory, registers in NameService. +2) Creates subaccounts with limits (e.g., procurement with daily cap). +3) Operators interact with Escrow/Treasury via BatchCaller (gas and UX reduction). +4) Events include org/subaccount/actor/opType/amount for audit and indexing. + +## UX / integrations +- OnchainKit + Base SDK: select subaccount, view limits, submit batches. +- Multisig/role approvals on wallet level (extra layer beyond escrow approvals). +- AA/4337 compatibility priority: avoid breaking standard smart wallet interfaces. + +## Next steps +- Implement limits/roles fully if placeholders remain. +- Emit detailed audit events (org, subaccount, actor, opType, amount). +- E2E tests: batches with limits, limit violations, operator without admin rights. diff --git a/docs/escrow-lifecycle.md b/docs/escrow-lifecycle.md index 828df23..6858192 100644 --- a/docs/escrow-lifecycle.md +++ b/docs/escrow-lifecycle.md @@ -12,9 +12,9 @@ SafeBase escrow system is a production-grade, modular escrow and conditional pay ### States (6 total) 1. **Created** - Initial state after escrow creation -2. **Funded** - Funds deposited (via direct funding or Base Pay) -3. **Released** - Funds released to seller (terminal state) -4. **Refunded** - Funds refunded to buyer (terminal state) +2. **Funded** - Funds deposited (via direct funding or Base Pay; ETH or ERC20) +3. **Released** - Funds released to seller (terminal state; may reach via partial releases) +4. **Refunded** - Funds refunded to buyer (terminal state; remaining amount after partial releases) 5. **Disputed** - Dispute raised, requires mediator intervention 6. **Cancelled** - Escrow cancelled before funding (terminal state) @@ -39,9 +39,9 @@ Created ──fundEscrow()──────────> Funded ──releaseTo | Created | Funded | fundEscrow() | Buyer only | | Created | Funded | fundEscrowWithBasePay() | Anyone (off-chain verified) | | Created | Cancelled | cancelEscrow() | Buyer only | -| Funded | Released | releaseToSeller() | Buyer (with approval) OR Mediator | -| Funded | Refunded | refundToBuyer() | Mediator OR Anyone after deadline | -| Funded | Disputed | disputeEscrow() | Buyer OR Seller (requires mediator set) | +| Funded | Released | releaseToSeller() | Buyer (with approval) OR Mediator | +| Funded | Refunded | refundToBuyer() | Mediator OR Anyone after deadline | +| Funded | Disputed | disputeEscrow() | Buyer OR Seller (requires mediator set) | | Disputed | Released | releaseToSeller() | Mediator only | | Disputed | Refunded | refundToBuyer() | Mediator only | @@ -145,7 +145,7 @@ struct RuleSet { ## Integration Patterns -### Standard Escrow Flow +### Standard Escrow Flow (ETH) ```solidity // 1. Create escrow uint256 escrowId = escrow.createEscrow( @@ -175,6 +175,28 @@ escrow.fundEscrowWithBasePay(escrowId, paymentId); ``` ### Dispute Resolution + +### ERC20 Funding +```solidity +escrow.createEscrow( + seller, + mediator, + address(token), // ERC20 + 100 ether, + block.timestamp + 7 days, + ruleSetId +); + +token.approve(address(escrow), 100 ether); +escrow.fundEscrow(escrowId); // msg.value must be 0 +``` + +### Partial Releases +```solidity +// After approvals / rules allow +escrow.releasePartialToSeller(escrowId, 40 ether); // updates state, Registry, Treasury withdrawal +// Remaining amount stays in escrow until released or refunded +``` ```solidity // Buyer raises dispute escrow.disputeEscrow(escrowId); @@ -231,12 +253,10 @@ SafeBaseEscrowV1 uses UUPS proxy pattern: ## Future Enhancements (Beyond Block 1) -1. **Partial releases**: Split payments for milestone-based escrows -2. **Multi-token support**: ERC20 token escrows (currently ETH only) -3. **Time-locked releases**: Automatic release after deadline + approval -4. **Appeal mechanism**: Secondary mediator for disputed cases -5. **Escrow templates**: Pre-configured rule sets for common use cases -6. **Event-driven automation**: Executor integration for auto-release/refund +1. **Time-locked releases**: Automatic release after deadline + approval +2. **Appeal mechanism**: Secondary mediator for disputed cases +3. **Escrow templates**: Pre-configured rule sets for common use cases +4. **Event-driven automation**: Executor integration for auto-release/refund --- diff --git a/foundry.toml b/foundry.toml index 5993eac..5dda090 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,6 +8,11 @@ optimizer_runs = 200 fs_permissions = [{ access = "read", path = "./deployments" }] gas_reports = ["SafeBaseEscrowV1", "RulesEngineV1", "RegistryV1", "ExecutorV1"] +[profile.coverage] +via_ir = true +optimizer = true +optimizer_runs = 200 + [rpc_endpoints] base = "https://mainnet.base.org" base-sepolia = "https://sepolia.base.org" diff --git a/script/DeployModular.s.sol b/script/DeployModular.s.sol index 4520cd6..f1ea6cb 100644 --- a/script/DeployModular.s.sol +++ b/script/DeployModular.s.sol @@ -80,11 +80,11 @@ contract DeployModularScript is Script { return addrs; } - function deployContract(string memory contractName, address owner, ExistingAddresses memory existing) internal returns (address) { + function deployContract(string memory contractName, address owner, ExistingAddresses memory existing, bool forceNew) internal returns (address) { bytes32 nameHash = keccak256(bytes(contractName)); if (nameHash == keccak256("Treasury")) { - if (existing.treasury != address(0)) { + if (!forceNew && existing.treasury != address(0)) { console.log("Treasury already deployed:", existing.treasury); return existing.treasury; } @@ -96,7 +96,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("AccessController")) { - if (existing.accessController != address(0)) { + if (!forceNew && existing.accessController != address(0)) { console.log("AccessController already deployed:", existing.accessController); return existing.accessController; } @@ -108,7 +108,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("Verifier")) { - if (existing.verifier != address(0)) { + if (!forceNew && existing.verifier != address(0)) { console.log("Verifier already deployed:", existing.verifier); return existing.verifier; } @@ -120,7 +120,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("PaymentTracker")) { - if (existing.paymentTracker != address(0)) { + if (!forceNew && existing.paymentTracker != address(0)) { console.log("PaymentTracker already deployed:", existing.paymentTracker); return existing.paymentTracker; } @@ -132,7 +132,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("BasePay")) { - if (existing.basePay != address(0)) { + if (!forceNew && existing.basePay != address(0)) { console.log("BasePay already deployed:", existing.basePay); return existing.basePay; } @@ -144,7 +144,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("RulesEngine")) { - if (existing.rulesEngine != address(0)) { + if (!forceNew && existing.rulesEngine != address(0)) { console.log("RulesEngine already deployed:", existing.rulesEngine); return existing.rulesEngine; } @@ -156,7 +156,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("Registry")) { - if (existing.registry != address(0)) { + if (!forceNew && existing.registry != address(0)) { console.log("Registry already deployed:", existing.registry); return existing.registry; } @@ -168,7 +168,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("SafeBaseEscrow")) { - if (existing.escrow != address(0)) { + if (!forceNew && existing.escrow != address(0)) { console.log("SafeBaseEscrow already deployed:", existing.escrow); return existing.escrow; } @@ -181,7 +181,7 @@ contract DeployModularScript is Script { } if (nameHash == keccak256("Executor")) { - if (existing.executor != address(0)) { + if (!forceNew && existing.executor != address(0)) { console.log("Executor already deployed:", existing.executor); return existing.executor; } @@ -201,11 +201,13 @@ contract DeployModularScript is Script { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); address owner = vm.envAddress("OWNER_ADDRESS"); string memory targetContract = vm.envOr("DEPLOY_CONTRACT", string("all")); + bool forceNew = vm.envOr("FORCE_NEW", false); console.log("=== SafeBase Modular Deployment ==="); console.log("Chain ID:", block.chainid); console.log("Owner:", owner); console.log("Target:", targetContract); + console.log("Force new:", forceNew ? "true" : "false"); ExistingAddresses memory existing = loadExistingAddresses(); @@ -214,15 +216,15 @@ contract DeployModularScript is Script { if (keccak256(bytes(targetContract)) == keccak256("all")) { console.log("\n--- Deploying all contracts ---"); - existing.treasury = deployContract("Treasury", owner, existing); - existing.accessController = deployContract("AccessController", owner, existing); - existing.verifier = deployContract("Verifier", owner, existing); - existing.paymentTracker = deployContract("PaymentTracker", owner, existing); - existing.basePay = deployContract("BasePay", owner, existing); - existing.rulesEngine = deployContract("RulesEngine", owner, existing); - existing.registry = deployContract("Registry", owner, existing); - existing.escrow = deployContract("SafeBaseEscrow", owner, existing); - existing.executor = deployContract("Executor", owner, existing); + existing.treasury = deployContract("Treasury", owner, existing, forceNew); + existing.accessController = deployContract("AccessController", owner, existing, forceNew); + existing.verifier = deployContract("Verifier", owner, existing, forceNew); + existing.paymentTracker = deployContract("PaymentTracker", owner, existing, forceNew); + existing.basePay = deployContract("BasePay", owner, existing, forceNew); + existing.rulesEngine = deployContract("RulesEngine", owner, existing, forceNew); + existing.registry = deployContract("Registry", owner, existing, forceNew); + existing.escrow = deployContract("SafeBaseEscrow", owner, existing, forceNew); + existing.executor = deployContract("Executor", owner, existing, forceNew); if (existing.escrow != address(0) && existing.rulesEngine != address(0) && existing.registry != address(0)) { SafeBaseEscrowV1(existing.escrow).setRulesEngine(existing.rulesEngine); @@ -242,7 +244,7 @@ contract DeployModularScript is Script { } } else { console.log("\n--- Deploying single contract ---"); - deployContract(targetContract, owner, existing); + deployContract(targetContract, owner, existing, forceNew); } vm.stopBroadcast(); diff --git a/src/escrow/ExecutorV1.sol b/src/escrow/ExecutorV1.sol index a961b9f..206c0d7 100644 --- a/src/escrow/ExecutorV1.sol +++ b/src/escrow/ExecutorV1.sol @@ -6,19 +6,23 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; interface ISafeBaseEscrow { - function getEscrow(uint256 escrowId) external view returns ( - address buyer, - address seller, - address mediator, - address token, - uint256 amount, - uint256 deadline, - uint8 state, - bool buyerApproved, - bool sellerApproved, - bytes32 paymentId, - uint256 createdAt - ); + struct EscrowData { + address buyer; + address seller; + address mediator; + address token; + uint256 amount; + uint256 releasedAmount; + uint256 deadline; + uint8 state; + bool buyerApproved; + bool sellerApproved; + bytes32 paymentId; + uint256 createdAt; + uint256 ruleSetId; + } + + function getEscrow(uint256 escrowId) external view returns (EscrowData memory); function refundToBuyer(uint256 escrowId) external; function releaseToSeller(uint256 escrowId) external; } @@ -88,23 +92,11 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { emit DeadlineCheckScheduled(_escrowId, _deadline); } - function executeAutoRefund(uint256 _escrowId, uint256 _ruleSetId) external onlyAutomator { - ( - , - , - , - , - , - uint256 deadline, - uint8 state, - , - , - , - ) = escrowContract.getEscrow(_escrowId); - - if (state != 1) revert InvalidState(); - - bool canRefund = rulesEngine.canRefund(_ruleSetId, deadline, false); + function executeAutoRefund(uint256 _escrowId) external onlyAutomator { + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(_escrowId); + if (e.state != 1) revert InvalidState(); + + bool canRefund = rulesEngine.canRefund(e.ruleSetId, e.deadline, false); if (!canRefund) revert DeadlineNotReached(); escrowContract.refundToBuyer(_escrowId); @@ -113,26 +105,14 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { emit AutoRefundExecuted(_escrowId); } - function executeAutoRelease(uint256 _escrowId, uint256 _ruleSetId) external onlyAutomator { - ( - , - , - , - , - , - , - uint8 state, - bool buyerApproved, - bool sellerApproved, - , - ) = escrowContract.getEscrow(_escrowId); - - if (state != 1) revert InvalidState(); + function executeAutoRelease(uint256 _escrowId) external onlyAutomator { + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(_escrowId); + if (e.state != 1) revert InvalidState(); bool canRelease = rulesEngine.canRelease( - _ruleSetId, - buyerApproved, - sellerApproved, + e.ruleSetId, + e.buyerApproved, + e.sellerApproved, false, _escrowId, "" @@ -146,25 +126,14 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { emit AutoReleaseExecuted(_escrowId); } - function checkAndExecuteDeadlines(uint256[] calldata _escrowIds, uint256 _ruleSetId) external onlyAutomator { + function checkAndExecuteDeadlines(uint256[] calldata _escrowIds) external onlyAutomator { for (uint256 i = 0; i < _escrowIds.length; i++) { uint256 escrowId = _escrowIds[i]; - ( - , - , - , - , - , - uint256 deadline, - uint8 state, - , - , - , - ) = escrowContract.getEscrow(escrowId); - - if (state == 1 && block.timestamp > deadline) { - bool canRefund = rulesEngine.canRefund(_ruleSetId, deadline, false); + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(escrowId); + + if (e.state == 1 && block.timestamp > e.deadline) { + bool canRefund = rulesEngine.canRefund(e.ruleSetId, e.deadline, false); if (canRefund) { try escrowContract.refundToBuyer(escrowId) { emit AutoRefundExecuted(escrowId); @@ -174,6 +143,29 @@ contract ExecutorV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { } } + function checkAndExecuteReleases(uint256[] calldata _escrowIds) external onlyAutomator { + for (uint256 i = 0; i < _escrowIds.length; i++) { + uint256 escrowId = _escrowIds[i]; + ISafeBaseEscrow.EscrowData memory e = escrowContract.getEscrow(escrowId); + + if (e.state == 1) { + bool canRelease = rulesEngine.canRelease( + e.ruleSetId, + e.buyerApproved, + e.sellerApproved, + false, + escrowId, + "" + ); + if (canRelease) { + try escrowContract.releaseToSeller(escrowId) { + emit AutoReleaseExecuted(escrowId); + } catch {} + } + } + } + } + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} uint256[50] private __gap; diff --git a/src/escrow/RulesEngineV1.sol b/src/escrow/RulesEngineV1.sol index 7677d09..e996e5e 100644 --- a/src/escrow/RulesEngineV1.sol +++ b/src/escrow/RulesEngineV1.sol @@ -20,7 +20,18 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { address externalVerifier; } + struct RuleSetInput { + bool requireBuyerApproval; + bool requireSellerApproval; + bool autoRefundAfterDeadline; + bool autoReleaseOnFullApproval; + bool mediatorOverrideEnabled; + bool externalVerifierEnabled; + address externalVerifier; + } + mapping(uint256 => RuleSet) public ruleSets; + mapping(uint256 => bool) public ruleSetExists; uint256 public defaultRuleSetId; event RuleSetCreated(uint256 indexed ruleSetId); @@ -56,7 +67,7 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { ) ); - ruleSets[ruleSetId] = RuleSet({ + _writeRuleSet(ruleSetId, RuleSetInput({ requireBuyerApproval: _requireBuyerApproval, requireSellerApproval: _requireSellerApproval, autoRefundAfterDeadline: _autoRefundAfterDeadline, @@ -64,15 +75,43 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { mediatorOverrideEnabled: _mediatorOverrideEnabled, externalVerifierEnabled: _externalVerifierEnabled, externalVerifier: _externalVerifier - }); + })); emit RuleSetCreated(ruleSetId); return ruleSetId; } + function updateRuleSet( + uint256 _ruleSetId, + bool _requireBuyerApproval, + bool _requireSellerApproval, + bool _autoRefundAfterDeadline, + bool _autoReleaseOnFullApproval, + bool _mediatorOverrideEnabled, + bool _externalVerifierEnabled, + address _externalVerifier + ) external onlyOwner { + if (!ruleSetExists[_ruleSetId]) revert InvalidRuleSet(); + + _writeRuleSet(_ruleSetId, RuleSetInput({ + requireBuyerApproval: _requireBuyerApproval, + requireSellerApproval: _requireSellerApproval, + autoRefundAfterDeadline: _autoRefundAfterDeadline, + autoReleaseOnFullApproval: _autoReleaseOnFullApproval, + mediatorOverrideEnabled: _mediatorOverrideEnabled, + externalVerifierEnabled: _externalVerifierEnabled, + externalVerifier: _externalVerifier + })); + + emit RuleSetUpdated(_ruleSetId); + } + function setDefaultRuleSet(uint256 _ruleSetId) external onlyOwner { - if (ruleSets[_ruleSetId].requireBuyerApproval == false && - ruleSets[_ruleSetId].requireSellerApproval == false) { + RuleSet memory rules = ruleSets[_ruleSetId]; + if (!ruleSetExists[_ruleSetId]) { + revert InvalidRuleSet(); + } + if (!rules.requireBuyerApproval && !rules.requireSellerApproval) { revert InvalidRuleSet(); } defaultRuleSetId = _ruleSetId; @@ -138,4 +177,20 @@ contract RulesEngineV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable { function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} uint256[50] private __gap; + + function _writeRuleSet(uint256 _ruleSetId, RuleSetInput memory input) internal { + if (input.externalVerifierEnabled && input.externalVerifier == address(0)) { + revert InvalidVerifier(); + } + ruleSets[_ruleSetId] = RuleSet({ + requireBuyerApproval: input.requireBuyerApproval, + requireSellerApproval: input.requireSellerApproval, + autoRefundAfterDeadline: input.autoRefundAfterDeadline, + autoReleaseOnFullApproval: input.autoReleaseOnFullApproval, + mediatorOverrideEnabled: input.mediatorOverrideEnabled, + externalVerifierEnabled: input.externalVerifierEnabled, + externalVerifier: input.externalVerifier + }); + ruleSetExists[_ruleSetId] = true; + } } diff --git a/src/escrow/SafeBaseEscrowV1.sol b/src/escrow/SafeBaseEscrowV1.sol index 05da163..29be600 100644 --- a/src/escrow/SafeBaseEscrowV1.sol +++ b/src/escrow/SafeBaseEscrowV1.sol @@ -6,6 +6,19 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {Treasury} from "../Treasury.sol"; +import {IERC20} from "../IERC20.sol"; + +interface IRegistry { + function indexEscrow( + uint256 escrowId, + address buyer, + address seller, + uint256 amount, + uint256 createdAt + ) external; + + function updateEscrowState(uint256 escrowId, uint8 state) external; +} interface IRulesEngine { function canRelease( @@ -39,6 +52,7 @@ contract SafeBaseEscrowV1 is address mediator; address token; uint256 amount; + uint256 releasedAmount; uint256 deadline; EscrowState state; bool buyerApproved; @@ -67,6 +81,7 @@ contract SafeBaseEscrowV1 is event EscrowFunded(uint256 indexed escrowId, uint256 amount); event EscrowReleased(uint256 indexed escrowId, address indexed recipient); + event EscrowPartialReleased(uint256 indexed escrowId, address indexed recipient, uint256 amount); event EscrowRefunded(uint256 indexed escrowId, address indexed recipient); event EscrowDisputed(uint256 indexed escrowId, address indexed initiator); event EscrowCancelled(uint256 indexed escrowId); @@ -80,6 +95,7 @@ contract SafeBaseEscrowV1 is error InvalidAddress(); error EscrowNotFound(); error AlreadyApproved(); + error AmountExceeded(); modifier nonReentrant() { require(_reentrancyStatus != 2, "ReentrancyGuard: reentrant call"); @@ -127,6 +143,7 @@ contract SafeBaseEscrowV1 is mediator: _mediator, token: _token, amount: _amount, + releasedAmount: 0, deadline: _deadline, state: EscrowState.Created, buyerApproved: false, @@ -138,6 +155,10 @@ contract SafeBaseEscrowV1 is emit EscrowCreated(escrowId, msg.sender, _seller, _token, _amount, _deadline); + if (registry != address(0)) { + IRegistry(registry).indexEscrow(escrowId, msg.sender, _seller, _amount, block.timestamp); + } + return escrowId; } @@ -150,9 +171,14 @@ contract SafeBaseEscrowV1 is if (msg.value != escrow.amount) revert InvalidAmount(); (bool success, ) = payable(address(treasury)).call{value: msg.value}(""); require(success, "ETH transfer failed"); + } else { + if (msg.value != 0) revert InvalidAmount(); + bool ok = IERC20(escrow.token).transferFrom(msg.sender, address(treasury), escrow.amount); + require(ok, "ERC20 transfer failed"); } escrow.state = EscrowState.Funded; + _updateRegistryState(_escrowId); emit EscrowFunded(_escrowId, escrow.amount); } @@ -164,6 +190,7 @@ contract SafeBaseEscrowV1 is escrow.paymentId = _paymentId; paymentIdToEscrow[_paymentId] = _escrowId; + _updateRegistryState(_escrowId); emit EscrowFunded(_escrowId, escrow.amount); emit BasePayFundingReceived(_escrowId, _paymentId); } @@ -217,15 +244,61 @@ contract SafeBaseEscrowV1 is if (!isMediator && !escrow.buyerApproved) revert Unauthorized(); } + uint256 remaining = escrow.amount - escrow.releasedAmount; + if (remaining == 0) revert AmountExceeded(); + escrow.releasedAmount = escrow.amount; escrow.state = EscrowState.Released; - uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.seller, escrow.amount); + uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.seller, remaining); treasury.approveWithdrawal(requestId); treasury.executeWithdrawal(requestId); + _updateRegistryState(_escrowId); emit EscrowReleased(_escrowId, escrow.seller); } + function releasePartialToSeller(uint256 _escrowId, uint256 _amount) external nonReentrant whenNotPaused { + EscrowData storage escrow = escrows[_escrowId]; + if (escrow.state != EscrowState.Funded && escrow.state != EscrowState.Disputed) { + revert InvalidState(); + } + if (_amount == 0) revert InvalidAmount(); + + bool isMediator = msg.sender == escrow.mediator && escrow.mediator != address(0); + bool isBuyer = msg.sender == escrow.buyer; + if (!isMediator && !isBuyer) revert Unauthorized(); + if (escrow.state == EscrowState.Disputed && !isMediator) revert Unauthorized(); + + if (rulesEngine != address(0) && escrow.ruleSetId != 0) { + bool canRelease = IRulesEngine(rulesEngine).canRelease( + escrow.ruleSetId, + escrow.buyerApproved, + escrow.sellerApproved, + isMediator, + _escrowId, + "" + ); + if (!canRelease) revert Unauthorized(); + } else { + if (!isMediator && !escrow.buyerApproved) revert Unauthorized(); + } + + uint256 remaining = escrow.amount - escrow.releasedAmount; + if (_amount > remaining) revert AmountExceeded(); + + escrow.releasedAmount += _amount; + if (escrow.releasedAmount == escrow.amount) { + escrow.state = EscrowState.Released; + } + + uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.seller, _amount); + treasury.approveWithdrawal(requestId); + treasury.executeWithdrawal(requestId); + + _updateRegistryState(_escrowId); + emit EscrowPartialReleased(_escrowId, escrow.seller, _amount); + } + function refundToBuyer(uint256 _escrowId) external nonReentrant whenNotPaused { EscrowData storage escrow = escrows[_escrowId]; if (escrow.state != EscrowState.Funded && escrow.state != EscrowState.Disputed) revert InvalidState(); @@ -248,12 +321,15 @@ contract SafeBaseEscrowV1 is if (!canRefund) revert Unauthorized(); } + uint256 remaining = escrow.amount - escrow.releasedAmount; + if (remaining == 0) revert AmountExceeded(); escrow.state = EscrowState.Refunded; - uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.buyer, escrow.amount); + uint256 requestId = treasury.requestWithdrawal(escrow.token, escrow.buyer, remaining); treasury.approveWithdrawal(requestId); treasury.executeWithdrawal(requestId); + _updateRegistryState(_escrowId); emit EscrowRefunded(_escrowId, escrow.buyer); } @@ -264,6 +340,7 @@ contract SafeBaseEscrowV1 is if (escrow.mediator == address(0)) revert Unauthorized(); escrow.state = EscrowState.Disputed; + _updateRegistryState(_escrowId); emit EscrowDisputed(_escrowId, msg.sender); } @@ -273,6 +350,7 @@ contract SafeBaseEscrowV1 is if (msg.sender != escrow.buyer) revert Unauthorized(); escrow.state = EscrowState.Cancelled; + _updateRegistryState(_escrowId); emit EscrowCancelled(_escrowId); } @@ -290,5 +368,11 @@ contract SafeBaseEscrowV1 is function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + function _updateRegistryState(uint256 _escrowId) internal { + if (registry != address(0)) { + IRegistry(registry).updateEscrowState(_escrowId, uint8(escrows[_escrowId].state)); + } + } + uint256[50] private __gap; } diff --git a/test/ExecutorV1.t.sol b/test/ExecutorV1.t.sol index f33407b..bd0ac69 100644 --- a/test/ExecutorV1.t.sol +++ b/test/ExecutorV1.t.sol @@ -4,58 +4,40 @@ pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; import {ExecutorV1} from "../src/escrow/ExecutorV1.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ISafeBaseEscrow} from "../src/escrow/ExecutorV1.sol"; contract MockEscrow { - struct EscrowData { - address buyer; - address seller; - address mediator; - address token; - uint256 amount; - uint256 deadline; - uint8 state; - bool buyerApproved; - bool sellerApproved; - bytes32 paymentId; - uint256 createdAt; - } - - EscrowData public escrowData; + ISafeBaseEscrow.EscrowData public escrowData; + bool public releasedCalled; + bool public refundedCalled; function setEscrow( address buyer, uint256 deadline, uint8 state, bool buyerApproved, - bool sellerApproved + bool sellerApproved, + uint256 ruleSetId ) external { escrowData.buyer = buyer; escrowData.deadline = deadline; escrowData.state = state; escrowData.buyerApproved = buyerApproved; escrowData.sellerApproved = sellerApproved; + escrowData.ruleSetId = ruleSetId; } - function getEscrow(uint256) external view returns ( - address, address, address, address, uint256, uint256, uint8, bool, bool, bytes32, uint256 - ) { - return ( - escrowData.buyer, - escrowData.seller, - escrowData.mediator, - escrowData.token, - escrowData.amount, - escrowData.deadline, - escrowData.state, - escrowData.buyerApproved, - escrowData.sellerApproved, - escrowData.paymentId, - escrowData.createdAt - ); + function getEscrow(uint256) external view returns (ISafeBaseEscrow.EscrowData memory) { + return escrowData; + } + + function refundToBuyer(uint256) external { + refundedCalled = true; } - function refundToBuyer(uint256) external {} - function releaseToSeller(uint256) external {} + function releaseToSeller(uint256) external { + releasedCalled = true; + } } contract MockRulesEngine { @@ -172,14 +154,14 @@ contract ExecutorV1Test is Test { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false); + escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false, 1); rulesEngine.setCanRelease(true); vm.expectEmit(true, false, false, false); emit AutoRefundExecuted(1); vm.prank(automator); - executor.executeAutoRefund(1, 1); + executor.executeAutoRefund(1); assertFalse(executor.scheduledForRefund(1)); } @@ -188,26 +170,26 @@ contract ExecutorV1Test is Test { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, false, false); + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, false, false, 1); rulesEngine.setCanRelease(false); vm.prank(automator); vm.expectRevert(ExecutorV1.DeadlineNotReached.selector); - executor.executeAutoRefund(1, 1); + executor.executeAutoRefund(1); } function testExecuteAutoRelease() public { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, true, true); + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, true, true, 1); rulesEngine.setCanRelease(true); vm.expectEmit(true, false, false, false); emit AutoReleaseExecuted(1); vm.prank(automator); - executor.executeAutoRelease(1, 1); + executor.executeAutoRelease(1); assertFalse(executor.scheduledForRelease(1)); } @@ -216,14 +198,32 @@ contract ExecutorV1Test is Test { vm.prank(owner); executor.addAutomator(automator); - escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false); + escrowContract.setEscrow(address(3), block.timestamp - 1, 1, false, false, 1); rulesEngine.setCanRelease(true); uint256[] memory escrowIds = new uint256[](1); escrowIds[0] = 1; vm.prank(automator); - executor.checkAndExecuteDeadlines(escrowIds, 1); + executor.checkAndExecuteDeadlines(escrowIds); + + assertTrue(escrowContract.refundedCalled()); + } + + function testCheckAndExecuteReleases() public { + vm.prank(owner); + executor.addAutomator(automator); + + escrowContract.setEscrow(address(3), block.timestamp + 1 days, 1, true, true, 1); + rulesEngine.setCanRelease(true); + + uint256[] memory escrowIds = new uint256[](1); + escrowIds[0] = 1; + + vm.prank(automator); + executor.checkAndExecuteReleases(escrowIds); + + assertTrue(escrowContract.releasedCalled()); } function testOnlyAutomatorModifier() public { diff --git a/test/RulesEngineV1.t.sol b/test/RulesEngineV1.t.sol index 19d537c..44e8e29 100644 --- a/test/RulesEngineV1.t.sol +++ b/test/RulesEngineV1.t.sol @@ -109,6 +109,84 @@ contract RulesEngineV1Test is Test { rulesEngine.setDefaultRuleSet(ruleSetId); } + function testUpdateRuleSet() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + false, + false, + false, + false, + false, + address(0) + ); + + vm.expectEmit(true, false, false, false); + emit RuleSetUpdated(ruleSetId); + + vm.prank(owner); + rulesEngine.updateRuleSet( + ruleSetId, + false, + true, + true, + true, + true, + true, + address(verifier) + ); + + RulesEngineV1.RuleSet memory ruleSet = rulesEngine.getRuleSet(ruleSetId); + assertFalse(ruleSet.requireBuyerApproval); + assertTrue(ruleSet.requireSellerApproval); + assertTrue(ruleSet.autoRefundAfterDeadline); + assertTrue(ruleSet.autoReleaseOnFullApproval); + assertTrue(ruleSet.mediatorOverrideEnabled); + assertTrue(ruleSet.externalVerifierEnabled); + assertEq(ruleSet.externalVerifier, address(verifier)); + } + + function testUpdateRuleSetRequiresExisting() public { + vm.prank(owner); + vm.expectRevert(RulesEngineV1.InvalidRuleSet.selector); + rulesEngine.updateRuleSet( + 123, + true, + true, + false, + false, + false, + false, + address(0) + ); + } + + function testUpdateRuleSetOnlyOwner() public { + vm.prank(owner); + uint256 ruleSetId = rulesEngine.createRuleSet( + true, + false, + false, + false, + false, + false, + address(0) + ); + + vm.prank(address(99)); + vm.expectRevert(); + rulesEngine.updateRuleSet( + ruleSetId, + false, + true, + false, + false, + false, + false, + address(0) + ); + } + function testCanReleaseWithBuyerApproval() public { vm.prank(owner); uint256 ruleSetId = rulesEngine.createRuleSet( diff --git a/test/SafeBaseEscrowV1.t.sol b/test/SafeBaseEscrowV1.t.sol index ee5fb52..0baef68 100644 --- a/test/SafeBaseEscrowV1.t.sol +++ b/test/SafeBaseEscrowV1.t.sol @@ -6,11 +6,13 @@ import {SafeBaseEscrowV1} from "../src/escrow/SafeBaseEscrowV1.sol"; import {Treasury} from "../src/Treasury.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockRegistry} from "./mocks/MockRegistry.sol"; contract SafeBaseEscrowV1Test is Test { SafeBaseEscrowV1 public escrow; Treasury public treasury; MockERC20 public token; + MockRegistry public registry; address public owner = address(1); address public buyer = address(2); @@ -36,6 +38,7 @@ contract SafeBaseEscrowV1Test is Test { function setUp() public { token = new MockERC20("Test Token", "TEST", 18); + registry = new MockRegistry(); Treasury treasuryImpl = new Treasury(); bytes memory treasuryData = abi.encodeWithSelector( @@ -58,6 +61,7 @@ contract SafeBaseEscrowV1Test is Test { vm.startPrank(owner); treasury.addAdmin(address(escrow)); treasury.addExecutor(address(escrow)); + escrow.setRegistry(address(registry)); vm.stopPrank(); vm.deal(buyer, 100 ether); @@ -209,6 +213,75 @@ contract SafeBaseEscrowV1Test is Test { escrow.fundEscrow{value: 0.5 ether}(escrowId); } + function testRegistryIndexAndUpdateOnFund() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(0), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + (address b, address s, uint256 amt, uint256 ts) = registry.indexedEscrows(escrowId); + assertEq(b, buyer); + assertEq(s, seller); + assertEq(amt, 1 ether); + assertEq(ts, block.timestamp); + + vm.prank(buyer); + escrow.fundEscrow{value: 1 ether}(escrowId); + + assertEq(registry.lastEscrowId(), escrowId); + assertEq(registry.lastState(), uint8(SafeBaseEscrowV1.EscrowState.Funded)); + } + + function testFundEscrowERC20() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 100 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 100 ether); + + vm.expectEmit(true, false, false, true); + emit EscrowFunded(escrowId, 100 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Funded); + assertEq(token.balanceOf(address(treasury)), 100 ether); + assertEq(token.balanceOf(buyer), 900 ether); + } + + function testFundEscrowERC20RejectsValue() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 1 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 1 ether); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.InvalidAmount.selector); + escrow.fundEscrow{value: 1 wei}(escrowId); + } + function testFundEscrowWithBasePay() public { vm.prank(buyer); uint256 escrowId = escrow.createEscrow( @@ -352,6 +425,107 @@ contract SafeBaseEscrowV1Test is Test { assertEq(seller.balance, sellerBalanceBefore + 1 ether); } + function testPartialReleaseThenRefundRemainingERC20() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 100 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 100 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + uint256 sellerBalanceBefore = token.balanceOf(seller); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 40 ether); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Funded); + assertEq(escrowData.releasedAmount, 40 ether); + assertEq(token.balanceOf(seller), sellerBalanceBefore + 40 ether); + + vm.warp(block.timestamp + 2 days); + + uint256 buyerBalanceBefore = token.balanceOf(buyer); + + vm.prank(buyer); + escrow.refundToBuyer(escrowId); + + escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); + assertEq(token.balanceOf(buyer), buyerBalanceBefore + 60 ether); + } + + function testPartialReleaseFullAmountSetsReleased() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 50 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 50 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 20 ether); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 30 ether); + + SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); + assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Released); + assertEq(escrowData.releasedAmount, 50 ether); + } + + function testPartialReleaseExceedsAmount() public { + vm.prank(buyer); + uint256 escrowId = escrow.createEscrow( + seller, + mediator, + address(token), + 10 ether, + block.timestamp + 1 days, + 0 + ); + + vm.prank(buyer); + token.approve(address(escrow), 10 ether); + + vm.prank(buyer); + escrow.fundEscrow{value: 0}(escrowId); + + vm.prank(buyer); + escrow.approveBuyer(escrowId); + + vm.prank(buyer); + escrow.releasePartialToSeller(escrowId, 9 ether); + + vm.prank(buyer); + vm.expectRevert(SafeBaseEscrowV1.AmountExceeded.selector); + escrow.releasePartialToSeller(escrowId, 2 ether); + } + function testReleaseToSellerMediator() public { vm.prank(buyer); uint256 escrowId = escrow.createEscrow( @@ -422,6 +596,8 @@ contract SafeBaseEscrowV1Test is Test { SafeBaseEscrowV1.EscrowData memory escrowData = escrow.getEscrow(escrowId); assertTrue(escrowData.state == SafeBaseEscrowV1.EscrowState.Refunded); assertEq(buyer.balance, buyerBalanceBefore + 1 ether); + assertEq(registry.lastEscrowId(), escrowId); + assertEq(registry.lastState(), uint8(SafeBaseEscrowV1.EscrowState.Refunded)); } function testRefundToBuyerMediator() public { diff --git a/test/mocks/MockRegistry.sol b/test/mocks/MockRegistry.sol new file mode 100644 index 0000000..1a33275 --- /dev/null +++ b/test/mocks/MockRegistry.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract MockRegistry { + uint256 public lastEscrowId; + uint8 public lastState; + struct Indexed { + address buyer; + address seller; + uint256 amount; + uint256 createdAt; + } + mapping(uint256 => Indexed) public indexedEscrows; + + function indexEscrow( + uint256 escrowId, + address buyer, + address seller, + uint256 amount, + uint256 createdAt + ) external { + indexedEscrows[escrowId] = Indexed(buyer, seller, amount, createdAt); + lastEscrowId = escrowId; + } + + function updateEscrowState(uint256 escrowId, uint8 state) external { + lastEscrowId = escrowId; + lastState = state; + } +} +