From c58a32642d3a3ca988455c9213766fc767003d4b Mon Sep 17 00:00:00 2001 From: madschristensen99 Date: Thu, 11 Jun 2026 10:52:46 -0400 Subject: [PATCH] AP-5: Implement PayoutManifest (multi-recipient release authority) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PayoutManifest.sol: declarative per-invocation payout schema with encrypted amounts (euint64) and plaintext recipients - ERC-7201 namespace storage (reineira.storage.PayoutManifest) - Two-gate firing model (gate 0 / gate 1) with per-invocationId consumed replay protection (DEV-69 pattern) - Permanent FHE.allow on all encrypted amounts during schema registration - nonReentrant + CEI ordering on onGateFired - Calls IEscrow.release(escrowId, recipient, amount) — never holds funds - Full IPayoutManifest interface with custom errors and events - Add foundry.toml remappings for FHE dependencies - 29 tests covering: deployment, schema registration, all 4 cells of the two-gate release table, replay protection, reentrancy guard, auth, event emission, and invocation isolation --- .../contracts/core/PayoutManifest.sol | 187 ++++++++ .../interfaces/core/IPayoutManifest.sol | 135 ++++++ .../contracts/mocks/MockEscrow.sol | 61 +++ .../contracts/mocks/MockReentrantEscrow.sol | 65 +++ packages/orchestration/foundry.toml | 5 + .../test/unit/PayoutManifest.t.sol | 434 ++++++++++++++++++ 6 files changed, 887 insertions(+) create mode 100644 packages/orchestration/contracts/core/PayoutManifest.sol create mode 100644 packages/orchestration/contracts/interfaces/core/IPayoutManifest.sol create mode 100644 packages/orchestration/contracts/mocks/MockEscrow.sol create mode 100644 packages/orchestration/contracts/mocks/MockReentrantEscrow.sol create mode 100644 packages/orchestration/test/unit/PayoutManifest.t.sol diff --git a/packages/orchestration/contracts/core/PayoutManifest.sol b/packages/orchestration/contracts/core/PayoutManifest.sol new file mode 100644 index 0000000..67a3389 --- /dev/null +++ b/packages/orchestration/contracts/core/PayoutManifest.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Reineira Labs Limited All rights reserved. +pragma solidity ^0.8.25; + +import {FHE, euint64, InEuint64} from "@fhenixprotocol/cofhe-contracts/FHE.sol"; +import {FHEMeta} from "@reineira-os/shared/contracts/common/FHEMeta.sol"; +import {IEscrow} from "@reineira-os/shared/contracts/interfaces/core/IEscrow.sol"; +import {TestnetCoreBase} from "@reineira-os/shared/contracts/common/TestnetCoreBase.sol"; +import {IPayoutManifest} from "../interfaces/core/IPayoutManifest.sol"; + +contract PayoutManifest is IPayoutManifest, TestnetCoreBase { + uint8 public constant MAX_GATES = 2; + + struct PayoutLine { + euint64 amount; + address recipient; + uint8 requiredGateMask; + } + + struct PayoutSchema { + PayoutLine[] lines; + mapping(uint256 => bool) released; + bool exists; + } + + /// @custom:storage-location erc7201:reineira.storage.PayoutManifest + struct PayoutManifestStorage { + mapping(bytes32 => mapping(uint8 => bool)) consumed; + mapping(uint8 => address) gateCallers; + mapping(uint256 => mapping(bytes32 => PayoutSchema)) schemas; + IEscrow escrow; + } + + bytes32 private constant PAYOUT_MANIFEST_STORAGE_LOCATION = + 0x1f9fb62f305306063fa95221f7263189160648be96caa38ffbb1f7e2e70dd900; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address trustedForwarder_) TestnetCoreBase(trustedForwarder_) { + _disableInitializers(); + } + + function initialize(address owner_, address escrow_) external initializer { + if (escrow_ == address(0)) revert InvalidEscrow(); + __TestnetCoreBase_init(owner_); + PayoutManifestStorage storage $ = _getPayoutManifestStorage(); + $.escrow = IEscrow(escrow_); + } + + // ── Schema management ─────────────────────────────────────── + + function registerSchema( + uint256 escrowId, + bytes32 invocationId, + PayoutLineInput[] calldata lines + ) external onlyOwner { + uint256 lineCount = lines.length; + if (lineCount == 0) revert EmptySchema(); + + PayoutManifestStorage storage $ = _getPayoutManifestStorage(); + PayoutSchema storage schema = $.schemas[escrowId][invocationId]; + if (schema.exists) revert SchemaAlreadyExists(escrowId, invocationId); + + schema.exists = true; + + address sender = msg.sender; + + for (uint256 i = 0; i < lineCount; i++) { + PayoutLineInput calldata input = lines[i]; + if (input.recipient == address(0)) revert InvalidRecipient(uint8(i)); + if (input.requiredGateMask == 0 || input.requiredGateMask > 3) { + revert InvalidGateMask(uint8(i), input.requiredGateMask); + } + + euint64 amount = FHEMeta.asEuint64(input.amount, sender); + FHE.allowThis(amount); + + schema.lines.push( + PayoutLine({amount: amount, recipient: input.recipient, requiredGateMask: input.requiredGateMask}) + ); + } + + emit SchemaRegistered(escrowId, invocationId, lineCount); + } + + // ── Gate firing ─────────────────────────────────────────────── + + function onGateFired(uint256 escrowId, bytes32 invocationId, uint8 gateId) external nonReentrant { + // checks + if (gateId >= MAX_GATES) revert InvalidGateId(gateId); + + PayoutManifestStorage storage $ = _getPayoutManifestStorage(); + if ($.gateCallers[gateId] != msg.sender) revert UnauthorizedGateCaller(gateId, msg.sender); + if ($.consumed[invocationId][gateId]) revert GateAlreadyConsumed(invocationId, gateId); + + PayoutSchema storage schema = $.schemas[escrowId][invocationId]; + if (!schema.exists) revert SchemaNotFound(escrowId, invocationId); + + // effects + $.consumed[invocationId][gateId] = true; + uint8 consumedMask = _consumedMask(invocationId); + + uint256 lineCount = schema.lines.length; + for (uint256 i = 0; i < lineCount; i++) { + if (schema.released[i]) continue; + + PayoutLine storage line = schema.lines[i]; + if ((line.requiredGateMask & ~consumedMask) != 0) continue; + + schema.released[i] = true; + + // interactions: call IEscrow.release for each satisfied line + bytes memory amountBytes = abi.encode(line.amount); + $.escrow.release(escrowId, line.recipient, amountBytes); + + emit LineReleased(escrowId, invocationId, uint8(i), line.recipient); + } + + emit GateFired(escrowId, invocationId, gateId); + } + + // ── Admin ───────────────────────────────────────────────────── + + function setGateCaller(uint8 gateId, address caller) external onlyOwner { + if (gateId >= MAX_GATES) revert InvalidGateId(gateId); + _getPayoutManifestStorage().gateCallers[gateId] = caller; + } + + function setEscrow(address escrow_) external onlyOwner { + if (escrow_ == address(0)) revert InvalidEscrow(); + _getPayoutManifestStorage().escrow = IEscrow(escrow_); + } + + // ── Views ───────────────────────────────────────────────────── + + function isGateConsumed(bytes32 invocationId, uint8 gateId) external view returns (bool) { + return _getPayoutManifestStorage().consumed[invocationId][gateId]; + } + + function gateCaller(uint8 gateId) external view returns (address) { + return _getPayoutManifestStorage().gateCallers[gateId]; + } + + function escrow() external view returns (address) { + return address(_getPayoutManifestStorage().escrow); + } + + function schemaExists(uint256 escrowId, bytes32 invocationId) external view returns (bool) { + return _getPayoutManifestStorage().schemas[escrowId][invocationId].exists; + } + + function getSchemaLine( + uint256 escrowId, + bytes32 invocationId, + uint256 lineIndex + ) external view returns (uint256 amount, address recipient, uint8 requiredGateMask) { + PayoutSchema storage schema = _getPayoutManifestStorage().schemas[escrowId][invocationId]; + if (!schema.exists) revert SchemaNotFound(escrowId, invocationId); + if (lineIndex >= schema.lines.length) revert InvalidSchemaLength(); + + PayoutLine storage line = schema.lines[lineIndex]; + return (uint256(euint64.unwrap(line.amount)), line.recipient, line.requiredGateMask); + } + + function isLineReleased(uint256 escrowId, bytes32 invocationId, uint256 lineIndex) external view returns (bool) { + return _getPayoutManifestStorage().schemas[escrowId][invocationId].released[lineIndex]; + } + + function getSchemaLineCount(uint256 escrowId, bytes32 invocationId) external view returns (uint256) { + PayoutSchema storage schema = _getPayoutManifestStorage().schemas[escrowId][invocationId]; + if (!schema.exists) revert SchemaNotFound(escrowId, invocationId); + return schema.lines.length; + } + + // ── Internal ────────────────────────────────────────────────── + + function _getPayoutManifestStorage() private pure returns (PayoutManifestStorage storage $) { + assembly { + $.slot := PAYOUT_MANIFEST_STORAGE_LOCATION + } + } + + function _consumedMask(bytes32 invocationId) private view returns (uint8 mask) { + PayoutManifestStorage storage $ = _getPayoutManifestStorage(); + if ($.consumed[invocationId][0]) mask |= 1; + if ($.consumed[invocationId][1]) mask |= 2; + } +} diff --git a/packages/orchestration/contracts/interfaces/core/IPayoutManifest.sol b/packages/orchestration/contracts/interfaces/core/IPayoutManifest.sol new file mode 100644 index 0000000..fac70f2 --- /dev/null +++ b/packages/orchestration/contracts/interfaces/core/IPayoutManifest.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Reineira Labs Limited All rights reserved. +pragma solidity ^0.8.25; + +import {InEuint64} from "@fhenixprotocol/cofhe-contracts/FHE.sol"; + +/// @title IPayoutManifest +/// @notice Interface for the declarative multi-recipient payout authority. +/// Holds per-invocation encrypted-amount / plaintext-recipient schema lines +/// and authorizes IEscrow.release calls when gates fire. +interface IPayoutManifest { + /// @notice Emitted when a payout schema is registered for an escrow+invocation + /// @param escrowId The escrow the schema applies to + /// @param invocationId The unique invocation identifier + /// @param lineCount Number of payout lines in the schema + event SchemaRegistered(uint256 indexed escrowId, bytes32 indexed invocationId, uint256 lineCount); + + /// @notice Emitted when a gate fires for an invocation + /// @param escrowId The escrow identifier + /// @param invocationId The invocation identifier + /// @param gateId The gate that fired + event GateFired(uint256 indexed escrowId, bytes32 indexed invocationId, uint8 indexed gateId); + + /// @notice Emitted when a single schema line is released to a recipient + /// @param escrowId The escrow identifier + /// @param invocationId The invocation identifier + /// @param lineIndex The index of the released line in the schema + /// @param recipient The address that received the release + event LineReleased( + uint256 indexed escrowId, + bytes32 indexed invocationId, + uint8 indexed lineIndex, + address recipient + ); + + /// @notice Thrown when attempting to fire a gate that has already been consumed for this invocation + error GateAlreadyConsumed(bytes32 invocationId, uint8 gateId); + + /// @notice Thrown when a caller is not authorized to fire a gate + error UnauthorizedGateCaller(uint8 gateId, address caller); + + /// @notice Thrown when a schema already exists for the given escrow+invocation + error SchemaAlreadyExists(uint256 escrowId, bytes32 invocationId); + + /// @notice Thrown when a schema is not found for the given escrow+invocation + error SchemaNotFound(uint256 escrowId, bytes32 invocationId); + + /// @notice Thrown when attempting to register an empty schema + error EmptySchema(); + + /// @notice Thrown when input arrays have mismatched lengths + error InvalidSchemaLength(); + + /// @notice Thrown when a line has an invalid gate mask + error InvalidGateMask(uint8 lineIndex, uint8 gateMask); + + /// @notice Thrown when a line has a zero-address recipient + error InvalidRecipient(uint8 lineIndex); + + /// @notice Thrown when an invalid gate id is provided + error InvalidGateId(uint8 gateId); + + /// @notice Thrown when an invalid escrow address is provided + error InvalidEscrow(); + + /// @notice Input struct for schema registration + struct PayoutLineInput { + InEuint64 amount; + address recipient; + uint8 requiredGateMask; + } + + /// @notice Maximum number of gates supported (2) + function MAX_GATES() external view returns (uint8); + + /// @notice Initializes the contract with an owner and escrow reference + /// @param owner_ The contract owner + /// @param escrow_ The IEscrow contract to release funds from + function initialize(address owner_, address escrow_) external; + + /// @notice Registers a declarative payout schema for an escrow+invocation + /// @dev Owner-only. Verifies encrypted inputs and grants permanent FHE.allow. + /// @param escrowId The escrow identifier + /// @param invocationId The unique invocation identifier + /// @param lines Array of payout lines (encrypted amount, recipient, gate mask) + function registerSchema(uint256 escrowId, bytes32 invocationId, PayoutLineInput[] calldata lines) external; + + /// @notice Called by an authorized gate caller when a gate fires + /// @dev Marks the gate consumed, then releases all satisfied schema lines. + /// Reverts if gate already consumed, caller unauthorized, or schema missing. + /// @param escrowId The escrow identifier + /// @param invocationId The unique invocation identifier + /// @param gateId The gate that fired (0 or 1) + function onGateFired(uint256 escrowId, bytes32 invocationId, uint8 gateId) external; + + /// @notice Sets the authorized caller for a gate + /// @param gateId The gate identifier (0 or 1) + /// @param caller The address authorized to fire this gate + function setGateCaller(uint8 gateId, address caller) external; + + /// @notice Updates the IEscrow reference + /// @param escrow_ The new escrow contract address + function setEscrow(address escrow_) external; + + /// @notice Returns whether a gate has been consumed for an invocation + function isGateConsumed(bytes32 invocationId, uint8 gateId) external view returns (bool); + + /// @notice Returns the authorized caller for a gate + function gateCaller(uint8 gateId) external view returns (address); + + /// @notice Returns the IEscrow contract address + function escrow() external view returns (address); + + /// @notice Returns whether a schema exists for an escrow+invocation + function schemaExists(uint256 escrowId, bytes32 invocationId) external view returns (bool); + + /// @notice Returns a schema line at a given index + /// @param escrowId The escrow identifier + /// @param invocationId The invocation identifier + /// @param lineIndex The line index + /// @return amount The encrypted amount handle (as uint256 for interface compatibility) + /// @return recipient The plaintext recipient address + /// @return requiredGateMask The bitmask of required gates + function getSchemaLine( + uint256 escrowId, + bytes32 invocationId, + uint256 lineIndex + ) external view returns (uint256 amount, address recipient, uint8 requiredGateMask); + + /// @notice Returns whether a specific line has been released + function isLineReleased(uint256 escrowId, bytes32 invocationId, uint256 lineIndex) external view returns (bool); + + /// @notice Returns the number of lines in a schema + function getSchemaLineCount(uint256 escrowId, bytes32 invocationId) external view returns (uint256); +} diff --git a/packages/orchestration/contracts/mocks/MockEscrow.sol b/packages/orchestration/contracts/mocks/MockEscrow.sol new file mode 100644 index 0000000..9868a4b --- /dev/null +++ b/packages/orchestration/contracts/mocks/MockEscrow.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {IEscrow} from "@reineira-os/shared/contracts/interfaces/core/IEscrow.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +contract MockEscrow is IEscrow { + struct ReleaseCall { + uint256 escrowId; + address recipient; + bytes amount; + } + + ReleaseCall[] public releaseCalls; + + function release(uint256 escrowId, address recipient, bytes calldata amount) external { + releaseCalls.push(ReleaseCall(escrowId, recipient, amount)); + } + + function getReleaseCallCount() external view returns (uint256) { + return releaseCalls.length; + } + + function getReleaseCall(uint256 index) external view returns (ReleaseCall memory) { + return releaseCalls[index]; + } + + // Unused IEscrow stubs + function create(bytes calldata, address, bytes calldata) external returns (uint256) { + return 0; + } + function create(address, uint256, address, bytes calldata) external returns (uint256) { + return 0; + } + function fund(uint256, bytes calldata) external {} + function isFunded(uint256) external view returns (bool) { + return true; + } + function budget(uint256) external view returns (bytes memory) { + return ""; + } + function redeem(uint256) external {} + function redeemMultiple(uint256[] calldata) external {} + function total() external view returns (uint256) { + return 0; + } + function registerFeeModule(uint8, address) external {} + function setCoverageManager(address) external {} + function getFeeModule(uint8) external view returns (address) { + return address(0); + } + function status(uint256) external view returns (Phase) { + return Phase.Open; + } + function exists(uint256) external view returns (bool) { + return true; + } + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(IEscrow).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/orchestration/contracts/mocks/MockReentrantEscrow.sol b/packages/orchestration/contracts/mocks/MockReentrantEscrow.sol new file mode 100644 index 0000000..2af77e8 --- /dev/null +++ b/packages/orchestration/contracts/mocks/MockReentrantEscrow.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {IEscrow} from "@reineira-os/shared/contracts/interfaces/core/IEscrow.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IPayoutManifest} from "../interfaces/core/IPayoutManifest.sol"; + +contract MockReentrantEscrow is IEscrow { + IPayoutManifest public manifest; + uint256 public reentrantEscrowId; + bytes32 public reentrantInvocationId; + uint8 public reentrantGateId; + uint256 public callDepth; + + function setManifest(address manifest_) external { + manifest = IPayoutManifest(manifest_); + } + + function setReentrantParams(uint256 escrowId, bytes32 invocationId, uint8 gateId) external { + reentrantEscrowId = escrowId; + reentrantInvocationId = invocationId; + reentrantGateId = gateId; + } + + function release(uint256, address, bytes calldata) external { + if (callDepth == 0) { + callDepth++; + manifest.onGateFired(reentrantEscrowId, reentrantInvocationId, reentrantGateId); + } + } + + // Unused IEscrow stubs + function create(bytes calldata, address, bytes calldata) external returns (uint256) { + return 0; + } + function create(address, uint256, address, bytes calldata) external returns (uint256) { + return 0; + } + function fund(uint256, bytes calldata) external {} + function isFunded(uint256) external view returns (bool) { + return true; + } + function budget(uint256) external view returns (bytes memory) { + return ""; + } + function redeem(uint256) external {} + function redeemMultiple(uint256[] calldata) external {} + function total() external view returns (uint256) { + return 0; + } + function registerFeeModule(uint8, address) external {} + function setCoverageManager(address) external {} + function getFeeModule(uint8) external view returns (address) { + return address(0); + } + function status(uint256) external view returns (Phase) { + return Phase.Open; + } + function exists(uint256) external view returns (bool) { + return true; + } + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(IEscrow).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/orchestration/foundry.toml b/packages/orchestration/foundry.toml index 0637be5..763ccdf 100644 --- a/packages/orchestration/foundry.toml +++ b/packages/orchestration/foundry.toml @@ -9,8 +9,13 @@ libs = ["node_modules", "../../node_modules"] remappings = [ "@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/", "@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/", + "@fhenixprotocol/cofhe-contracts/=../../node_modules/@fhenixprotocol/cofhe-contracts/", + "@cofhe/mock-contracts/=../../node_modules/@cofhe/mock-contracts/", + "@cofhe/foundry-plugin/=../../node_modules/@cofhe/foundry-plugin/", + "fhenix-confidential-contracts/=../../node_modules/fhenix-confidential-contracts/", "@reineira-os/shared/=node_modules/@reineira-os/shared/", "forge-std/=../../node_modules/forge-std/src/", + "hardhat/=../../node_modules/forge-std/src/", ] solc_version = "0.8.25" diff --git a/packages/orchestration/test/unit/PayoutManifest.t.sol b/packages/orchestration/test/unit/PayoutManifest.t.sol new file mode 100644 index 0000000..798e19b --- /dev/null +++ b/packages/orchestration/test/unit/PayoutManifest.t.sol @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {InEuint64, EncryptedInput} from "@fhenixprotocol/cofhe-contracts/FHE.sol"; +import {PayoutManifest} from "../../contracts/core/PayoutManifest.sol"; +import {IPayoutManifest} from "../../contracts/interfaces/core/IPayoutManifest.sol"; +import {MockEscrow} from "../../contracts/mocks/MockEscrow.sol"; +import {MockReentrantEscrow} from "../../contracts/mocks/MockReentrantEscrow.sol"; + +address constant TASK_MANAGER_ADDRESS = 0xeA30c4B8b44078Bbf8a6ef5b9f1eC1626C7848D9; + +/// @notice Minimal TaskManager mock for FHEMeta.asEuint64 and FHE.allow +contract MockTaskManager { + function verifyInput(EncryptedInput memory, address) external pure returns (uint256) { + return 12345; + } + + function allow(uint256, address) external pure {} +} + +contract PayoutManifestTest is Test { + PayoutManifest public manifest; + MockEscrow public escrow; + + address public owner; + address public gate0Caller; + address public gate1Caller; + address public user; + + uint256 constant ESCROW_ID = 42; + bytes32 constant INVOCATION_ID = keccak256("test-invocation"); + + function setUp() public { + // Deploy minimal TaskManager mock at hardcoded address for FHE operations + vm.etch(TASK_MANAGER_ADDRESS, type(MockTaskManager).runtimeCode); + + owner = makeAddr("owner"); + gate0Caller = makeAddr("gate0Caller"); + gate1Caller = makeAddr("gate1Caller"); + user = makeAddr("user"); + + vm.startPrank(owner); + escrow = new MockEscrow(); + PayoutManifest impl = new PayoutManifest(address(0)); + manifest = PayoutManifest( + address( + new ERC1967Proxy(address(impl), abi.encodeCall(PayoutManifest.initialize, (owner, address(escrow)))) + ) + ); + manifest.setGateCaller(0, gate0Caller); + manifest.setGateCaller(1, gate1Caller); + vm.stopPrank(); + } + + // ── Helpers ─────────────────────────────────────────────────── + + function _inEuint64(uint64 value) internal pure returns (InEuint64 memory) { + return InEuint64({ctHash: uint256(value), securityZone: 0, utype: 5, signature: ""}); + } + + function _makeLines( + uint64 amount0, + address recipient0, + uint8 mask0, + uint64 amount1, + address recipient1, + uint8 mask1, + uint64 amount2, + address recipient2, + uint8 mask2 + ) internal pure returns (IPayoutManifest.PayoutLineInput[] memory lines) { + lines = new IPayoutManifest.PayoutLineInput[](3); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(amount0), recipient0, mask0); + lines[1] = IPayoutManifest.PayoutLineInput(_inEuint64(amount1), recipient1, mask1); + lines[2] = IPayoutManifest.PayoutLineInput(_inEuint64(amount2), recipient2, mask2); + } + + function _registerSchema3() internal { + address r0 = makeAddr("recipient0"); + address r1 = makeAddr("recipient1"); + address r2 = makeAddr("recipient2"); + + IPayoutManifest.PayoutLineInput[] memory lines = _makeLines( + 100, + r0, + 1, // gate 0 only + 200, + r1, + 2, // gate 1 only + 300, + r2, + 3 // both gates + ); + + vm.prank(owner); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + // ── Deployment / Init ───────────────────────────────────────── + + function test_deployment_setsCorrectOwnerAndEscrow() public view { + assertEq(manifest.owner(), owner); + assertEq(manifest.escrow(), address(escrow)); + } + + function test_deployment_revertsWithZeroEscrow() public { + PayoutManifest impl = new PayoutManifest(address(0)); + vm.prank(owner); + vm.expectRevert(IPayoutManifest.InvalidEscrow.selector); + new ERC1967Proxy(address(impl), abi.encodeCall(PayoutManifest.initialize, (owner, address(0)))); + } + + function test_setEscrow_updatesEscrow() public { + MockEscrow newEscrow = new MockEscrow(); + vm.prank(owner); + manifest.setEscrow(address(newEscrow)); + assertEq(manifest.escrow(), address(newEscrow)); + } + + function test_setEscrow_revertsWithZeroAddress() public { + vm.prank(owner); + vm.expectRevert(IPayoutManifest.InvalidEscrow.selector); + manifest.setEscrow(address(0)); + } + + function test_setEscrow_revertsWhenNotOwner() public { + vm.prank(user); + vm.expectRevert(); + manifest.setEscrow(address(escrow)); + } + + function test_setGateCaller_updatesCaller() public { + vm.prank(owner); + manifest.setGateCaller(0, user); + assertEq(manifest.gateCaller(0), user); + } + + function test_setGateCaller_revertsWithInvalidGateId() public { + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.InvalidGateId.selector, 5)); + manifest.setGateCaller(5, user); + } + + // ── Schema Registration ─────────────────────────────────────── + + function test_registerSchema_emitsEvent() public { + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(100), makeAddr("recipient0"), 1); + + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit IPayoutManifest.SchemaRegistered(ESCROW_ID, INVOCATION_ID, 1); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + function test_registerSchema_storesLines() public { + _registerSchema3(); + + assertTrue(manifest.schemaExists(ESCROW_ID, INVOCATION_ID)); + assertEq(manifest.getSchemaLineCount(ESCROW_ID, INVOCATION_ID), 3); + + (uint256 amount0, address recipient0, uint8 mask0) = manifest.getSchemaLine(ESCROW_ID, INVOCATION_ID, 0); + assertEq(recipient0, makeAddr("recipient0")); + assertEq(mask0, 1); + assertEq(amount0, 12345); // dummy handle from MockTaskManager + + (, address recipient1, uint8 mask1) = manifest.getSchemaLine(ESCROW_ID, INVOCATION_ID, 1); + assertEq(recipient1, makeAddr("recipient1")); + assertEq(mask1, 2); + + (, address recipient2, uint8 mask2) = manifest.getSchemaLine(ESCROW_ID, INVOCATION_ID, 2); + assertEq(recipient2, makeAddr("recipient2")); + assertEq(mask2, 3); + } + + function test_registerSchema_revertsWhenNotOwner() public { + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(100), makeAddr("recipient0"), 1); + + vm.prank(user); + vm.expectRevert(); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + function test_registerSchema_revertsWithEmptySchema() public { + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](0); + + vm.prank(owner); + vm.expectRevert(IPayoutManifest.EmptySchema.selector); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + function test_registerSchema_revertsWithZeroRecipient() public { + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(100), address(0), 1); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.InvalidRecipient.selector, 0)); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + function test_registerSchema_revertsWithInvalidGateMask() public { + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(100), makeAddr("recipient0"), 0); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.InvalidGateMask.selector, 0, 0)); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + function test_registerSchema_revertsWithGateMaskExceedingTwoGates() public { + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(100), makeAddr("recipient0"), 5); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.InvalidGateMask.selector, 0, 5)); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + function test_registerSchema_revertsIfAlreadyExists() public { + _registerSchema3(); + + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(100), makeAddr("recipient0"), 1); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.SchemaAlreadyExists.selector, ESCROW_ID, INVOCATION_ID)); + manifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + } + + // ── Two-gate release table (4 cells) ────────────────────────── + + function test_gate0Only_releasesGate0Lines() public { + _registerSchema3(); + + vm.prank(gate0Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + + assertEq(escrow.getReleaseCallCount(), 1); + MockEscrow.ReleaseCall memory call = escrow.getReleaseCall(0); + assertEq(call.escrowId, ESCROW_ID); + assertEq(call.recipient, makeAddr("recipient0")); + + assertTrue(manifest.isGateConsumed(INVOCATION_ID, 0)); + assertFalse(manifest.isGateConsumed(INVOCATION_ID, 1)); + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 0)); + assertFalse(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 1)); + assertFalse(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 2)); + } + + function test_gate1Only_releasesGate1Lines() public { + _registerSchema3(); + + vm.prank(gate1Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 1); + + assertEq(escrow.getReleaseCallCount(), 1); + MockEscrow.ReleaseCall memory call = escrow.getReleaseCall(0); + assertEq(call.escrowId, ESCROW_ID); + assertEq(call.recipient, makeAddr("recipient1")); + + assertFalse(manifest.isGateConsumed(INVOCATION_ID, 0)); + assertTrue(manifest.isGateConsumed(INVOCATION_ID, 1)); + assertFalse(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 0)); + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 1)); + assertFalse(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 2)); + } + + function test_bothGates_gate0ThenGate1_releasesAllLines() public { + _registerSchema3(); + + vm.prank(gate0Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + + vm.prank(gate1Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 1); + + assertEq(escrow.getReleaseCallCount(), 3); + assertEq(escrow.getReleaseCall(0).recipient, makeAddr("recipient0")); + assertEq(escrow.getReleaseCall(1).recipient, makeAddr("recipient1")); + assertEq(escrow.getReleaseCall(2).recipient, makeAddr("recipient2")); + + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 0)); + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 1)); + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 2)); + } + + function test_bothGates_gate1ThenGate0_releasesAllLines() public { + _registerSchema3(); + + vm.prank(gate1Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 1); + + vm.prank(gate0Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + + assertEq(escrow.getReleaseCallCount(), 3); + // order depends on firing order: gate1 fires first => recipient1 first + assertEq(escrow.getReleaseCall(0).recipient, makeAddr("recipient1")); + assertEq(escrow.getReleaseCall(1).recipient, makeAddr("recipient0")); + assertEq(escrow.getReleaseCall(2).recipient, makeAddr("recipient2")); + + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 0)); + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 1)); + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 2)); + } + + // ── Failure modes ───────────────────────────────────────────── + + function test_gateFire_revertsIfAlreadyConsumed() public { + _registerSchema3(); + + vm.prank(gate0Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + + vm.prank(gate0Caller); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.GateAlreadyConsumed.selector, INVOCATION_ID, 0)); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + } + + function test_gateFire_revertsIfUnauthorizedCaller() public { + _registerSchema3(); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.UnauthorizedGateCaller.selector, 0, user)); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + } + + function test_gateFire_revertsIfSchemaNotFound() public { + vm.prank(gate0Caller); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.SchemaNotFound.selector, ESCROW_ID, INVOCATION_ID)); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + } + + function test_gateFire_revertsWithInvalidGateId() public { + vm.prank(gate0Caller); + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.InvalidGateId.selector, 5)); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 5); + } + + function test_gateFire_doesNotReReleaseAlreadyReleasedLines() public { + _registerSchema3(); + + // Fire both gates + vm.prank(gate0Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + vm.prank(gate1Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 1); + + // Total 3 releases + assertEq(escrow.getReleaseCallCount(), 3); + + // Verify line 0 not released again (escrow only has 3 calls) + assertTrue(manifest.isLineReleased(ESCROW_ID, INVOCATION_ID, 0)); + } + + function test_reentrancy_protected() public { + // Deploy manifest with reentrant escrow + MockReentrantEscrow reentrantEscrow = new MockReentrantEscrow(); + + vm.startPrank(owner); + PayoutManifest impl = new PayoutManifest(address(0)); + PayoutManifest reentrantManifest = PayoutManifest( + address( + new ERC1967Proxy( + address(impl), + abi.encodeCall(PayoutManifest.initialize, (owner, address(reentrantEscrow))) + ) + ) + ); + reentrantManifest.setGateCaller(0, gate0Caller); + vm.stopPrank(); + + reentrantEscrow.setManifest(address(reentrantManifest)); + reentrantEscrow.setReentrantParams(ESCROW_ID, INVOCATION_ID, 0); + + // Register a simple schema + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(100), makeAddr("recipient0"), 1); + vm.prank(owner); + reentrantManifest.registerSchema(ESCROW_ID, INVOCATION_ID, lines); + + // Attempt reentrant gate fire + vm.prank(gate0Caller); + vm.expectRevert(); + reentrantManifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + } + + function test_gateFire_emitsEvents() public { + _registerSchema3(); + + vm.prank(gate0Caller); + vm.expectEmit(true, true, true, true); + emit IPayoutManifest.LineReleased(ESCROW_ID, INVOCATION_ID, 0, makeAddr("recipient0")); + vm.expectEmit(true, true, true, false); + emit IPayoutManifest.GateFired(ESCROW_ID, INVOCATION_ID, 0); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + } + + function test_differentInvocations_areIndependent() public { + _registerSchema3(); + + bytes32 invocation2 = keccak256("test-invocation-2"); + IPayoutManifest.PayoutLineInput[] memory lines = new IPayoutManifest.PayoutLineInput[](1); + lines[0] = IPayoutManifest.PayoutLineInput(_inEuint64(500), makeAddr("recipient4"), 1); + vm.prank(owner); + manifest.registerSchema(ESCROW_ID, invocation2, lines); + + // Fire gate 0 on invocation 1 + vm.prank(gate0Caller); + manifest.onGateFired(ESCROW_ID, INVOCATION_ID, 0); + + // Invocation 2 gate 0 should still be available + vm.prank(gate0Caller); + manifest.onGateFired(ESCROW_ID, invocation2, 0); + + assertEq(escrow.getReleaseCallCount(), 2); + assertTrue(manifest.isGateConsumed(INVOCATION_ID, 0)); + assertTrue(manifest.isGateConsumed(invocation2, 0)); + } + + function test_getSchemaLine_revertsForMissingSchema() public { + vm.expectRevert(abi.encodeWithSelector(IPayoutManifest.SchemaNotFound.selector, 999, INVOCATION_ID)); + manifest.getSchemaLine(999, INVOCATION_ID, 0); + } + + function test_getSchemaLine_revertsForOutOfBoundsIndex() public { + _registerSchema3(); + vm.expectRevert(IPayoutManifest.InvalidSchemaLength.selector); + manifest.getSchemaLine(ESCROW_ID, INVOCATION_ID, 10); + } +}