Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions packages/orchestration/contracts/core/PayoutManifest.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
135 changes: 135 additions & 0 deletions packages/orchestration/contracts/interfaces/core/IPayoutManifest.sol
Original file line number Diff line number Diff line change
@@ -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);
}
61 changes: 61 additions & 0 deletions packages/orchestration/contracts/mocks/MockEscrow.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading