// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import { BondStateV3 } from "./BondStateV3.sol"; import { PullPaymentsV3 } from "./PullPaymentsV3.sol"; import { BondMathLibV3 } from "../lib/BondMathLibV3.sol"; import { EIP712BaseV3 } from "../signatures/EIP712BaseV3.sol"; import { SignatureVerifierV3 } from "../signatures/SignatureVerifierV3.sol"; import { IPilotAccessControllerV3 } from "../pilot/IPilotAccessControllerV3.sol"; import { IRewardProgramV3 } from "./IRewardProgramV3.sol"; import { RewardProgramV3 } from "./RewardProgramV3.sol"; /// @title PartnershipBondV3Safe /// @notice Partnership bond with capped deposits, typed mutual /// settlement, and time-safe principal-only exits. /// @dev No deployment path in this contract uses generic consensus or /// discretionary dispute conclusions to move custody. contract PartnershipBondV3Safe is EIP712BaseV3, PullPaymentsV3 { string public constant FLOURISHING_MISSION = "Cooperation, accountable growth, freedom, and human/agent flourishing"; bytes32 public constant FLOURISHING_NON_CONTROL_BOUNDARY = keccak256("FLOURISHING_IS_A_MISSION_OUTCOME_NOT_A_SCORE_OR_PROTECTED_DECISION_INPUT"); using BondStateV3 for BondStateV3.State; uint256 private constant BASE_CHAIN_ID = 8453; uint256 private constant BASE_SEPOLIA_CHAIN_ID = 84532; /// @notice Deprecated legacy ABI value. It is never read by any custody path. bytes32 public constant SETTLEMENT_OUTCOME = keccak256("V3_PARTNERSHIP_SETTLEMENT"); bytes32 public constant PROTOCOL_VERSION_HASH = keccak256("3.0.0"); uint64 private constant MAX_CHALLENGE_WINDOW = 30 days; struct Terms { uint256 bondId; address partyA; address partyB; uint256 partyAStake; uint256 partyBStake; address stakeAsset; bytes32 partnershipType; /// @dev Legacy opaque field. Must be zero: only the closed rule class /// and schema version below can select a settlement predicate. bytes32 breachConditionsHash; uint8 objectiveRuleClass; uint16 objectiveRuleSchemaVersion; /// @dev Reserved compatibility fields. Both must be zero for a /// reward-free bond. A flourishing reward binds the exact metric /// schema hash and the sponsor program that funds it; it can /// never debit protected principal. bytes32 flourishRewardConditionsHash; uint256 rewardProgramId; /// @dev Maximum flourishing reward this bond can ever receive. The /// amount is committed from the program reserve at creation. uint256 flourishRewardCap; uint64 proposalDeadline; uint64 activePeriodStart; uint64 activePeriodEnd; uint64 challengeWindow; uint96 nonce; uint256 chainId; string protocolVersion; } struct BondData { Terms terms; bytes32 termsHash; BondStateV3.State state; uint64 fundedAt; /// @dev Deprecated compatibility slot. Legacy settlement entry points /// always revert and this slot is never written. uint64 settlementRequestedAt; uint256 partyAFunded; uint256 partyBFunded; /// @dev Flourishing rewards already issued against the signed cap. uint256 flourishRewardIssued; /// @dev Stores only mutually authorized split values. uint16 settlementHumanBps; uint16 settlementAiBps; uint16 settlementPartnershipBps; /// @dev Stores only a mutual-settlement EIP-712 digest. bytes32 settlementSnapshot; /// @dev Deprecated compatibility slots. Disputes cannot affect custody /// and none of these slots are written or read. uint256 challengeDisputeId; address challengeChallenger; bytes32 challengeSettlementSnapshot; uint64 challengeSettlementRequestedAt; uint64 challengeFiledAt; } mapping(uint256 => BondData) internal _bonds; mapping(address => mapping(uint96 => bool)) internal _nonceUsed; /// @notice One mutually signed custody instruction may be consumed once per bond. mapping(uint256 => mapping(uint96 => bool)) public mutualSettlementNonceUsed; uint256 public nextBondId = 1; /// @notice Native principal still held by the contract or owed as a pull-payment credit. /// @dev This is the aggregate-cap occupancy. It intentionally does not fall /// when principal is merely converted into a withdrawal credit. uint256 public nativeLiability; address public owner; /// @notice Always address(0) in the initial pilot. /// @dev Kept for ABI compatibility only. Partnership bond principal never /// calls, funds, commits to, or issues from RewardProgramV3. IRewardProgramV3 public immutable rewardProgram; /// @notice Independent verifier that signs exact reward authorizations. /// @dev Governance-controlled. It receives no custody authority. address public flourishRewardIssuer; /// @dev Replay protection for verifier-signed reward authorizations. mapping(uint256 => mapping(uint96 => bool)) flourishRewardAuthorizationNonceUsed; /// @notice Legacy constructor bindings retained for address-level compatibility only; they have no custody authority. address public immutable consensus; address public immutable disputeRegistry; /// @notice Base pilot controller. It is required on Base and is updated /// in lockstep with native liability, including pull withdrawals. IPilotAccessControllerV3 public immutable pilotController; uint256 public immutable maxPartyNativePrincipal; uint256 public immutable maxBondNativePrincipal; uint256 public immutable maxAggregateNativeLiability; bool public paused; error NotOwner(); error ZeroAddress(); error Paused(); error BadTerms(); error BadSignature(); error SelfBondForbidden(); error NonceUsed(); error BadChain(); error BadState(BondStateV3.State current); error DeadlineNotReached(); error DeadlinePassed(); error NotParticipant(); error Overfund(); error InsufficientSurplus(); error PrincipalCapExceeded(); error AggregateCapExceeded(); /// @notice Initial-pilot bonds cannot bind a reward-program contract. error RewardProgramDisabled(); /// @notice Initial-pilot signed terms cannot represent reward behavior. error RewardProgramMismatch(); error UnauthorizedRewardIssuer(); error RewardCapExceeded(); /// @notice Initial-pilot principal settlement has no third-party share. error PartnershipShareDisabled(); /// @notice A controller-bound pilot child must keep its registered design /// partner in the signed participant pair. error DesignPartnerNotParticipant(); /// @notice Base pilot custody may only move under both participants' typed authorization. error PilotMutualConsentOnly(); error AuthorizationExpired(); error AuthorizationUsed(); error InvalidMutualSettlement(); error InvalidEvidence(); event BondCreatedV3(uint256 indexed bondId, address indexed partyA, address indexed partyB, bytes32 termsHash); event BondFundedV3(uint256 indexed bondId, address indexed party, uint256 amount); event BondActivatedV3(uint256 indexed bondId); event BondStateChangedV3(uint256 indexed bondId, BondStateV3.State from, BondStateV3.State to, address indexed by); /// @notice Emitted only by a valid mutual-consent settlement. event BondSettledV3(uint256 indexed bondId, uint256 partyAAmount, uint256 partyBAmount, uint256 partnershipAmount); event FlourishRewardIssuerSetV3(address indexed issuer); event FlourishRewardReleasedV3(uint256 indexed bondId, uint256 amount); event FlourishRewardReleaseFailedV3(uint256 indexed bondId, uint256 amount); event FlourishRewardIssuedV3(uint256 indexed bondId, address indexed beneficiary, uint256 amount); event BondCancelledV3(uint256 indexed bondId, address indexed by, string reason); event BondExpiredV3(uint256 indexed bondId); event ForceExitedV3(uint256 indexed bondId, address indexed by); event PausedV3(address indexed by); event UnpausedV3(address indexed by); event MutualSettlementExecutedV3( uint256 indexed bondId, uint256 partyAAmount, uint256 partyBAmount, uint96 indexed authorizationNonce, bytes32 authorizationHash ); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } modifier whenNotPaused() { if (paused) revert Paused(); _; } constructor( address _owner, address _rewardProgram, address _consensus, address _disputeRegistry, uint256 _maxPartyNativePrincipal, uint256 _maxBondNativePrincipal, uint256 _maxAggregateNativeLiability, address _pilotController ) EIP712BaseV3("3.0.0") { if (_owner == address(0) || _consensus == address(0)) revert ZeroAddress(); if (_rewardProgram != address(0) && _rewardProgram.code.length == 0) revert ZeroAddress(); if ( (block.chainid == BASE_CHAIN_ID || block.chainid == BASE_SEPOLIA_CHAIN_ID) && _pilotController == address(0) ) { revert ZeroAddress(); } if (_pilotController != address(0) && _pilotController.code.length == 0) revert ZeroAddress(); if ( _pilotController != address(0) && IPilotAccessControllerV3(_pilotController).requiredChainId() != block.chainid ) { revert BadTerms(); } if ( _maxPartyNativePrincipal == 0 || _maxBondNativePrincipal == 0 || _maxAggregateNativeLiability == 0 || _maxPartyNativePrincipal > _maxBondNativePrincipal || _maxBondNativePrincipal > _maxAggregateNativeLiability ) revert BadTerms(); owner = _owner; rewardProgram = IRewardProgramV3(_rewardProgram); consensus = _consensus; // Generic disputes are retired from the supported Base closure. _disputeRegistry; disputeRegistry = address(0); pilotController = IPilotAccessControllerV3(_pilotController); maxPartyNativePrincipal = _maxPartyNativePrincipal; maxBondNativePrincipal = _maxBondNativePrincipal; maxAggregateNativeLiability = _maxAggregateNativeLiability; } function pause() external onlyOwner { paused = true; emit PausedV3(msg.sender); } function unpause() external onlyOwner { paused = false; emit UnpausedV3(msg.sender); } bytes32 public constant TERMS_TYPEHASH = keccak256( "Terms(uint256 bondId,address partyA,address partyB,uint256 partyAStake,uint256 partyBStake,address stakeAsset,bytes32 partnershipType,bytes32 breachConditionsHash,uint8 objectiveRuleClass,uint16 objectiveRuleSchemaVersion,bytes32 flourishRewardConditionsHash,uint256 rewardProgramId,uint256 flourishRewardCap,uint64 proposalDeadline,uint64 activePeriodStart,uint64 activePeriodEnd,uint64 challengeWindow,uint96 nonce,uint256 chainId,string protocolVersion)" ); bytes32 public constant FLOURISH_REWARD_AUTHORIZATION_TYPEHASH = keccak256( "FlourishRewardAuthorization(address bond,uint256 chainId,uint256 bondId,address beneficiary,uint256 amount,uint96 nonce,uint64 deadline)" ); bytes32 public constant MUTUAL_SETTLEMENT_TYPEHASH = keccak256( "MutualSettlement(address bond,uint256 chainId,uint256 bondId,bytes32 termsHash,uint256 partyAAmount,uint256 partyBAmount,uint96 nonce,uint64 deadline)" ); /// @notice Exact, domain-separated instruction signed by both participants. struct MutualSettlement { address bond; uint256 chainId; uint256 bondId; bytes32 termsHash; uint256 partyAAmount; uint256 partyBAmount; uint96 nonce; uint64 deadline; } function hashTerms(Terms calldata t) public pure returns (bytes32) { return keccak256( abi.encode( TERMS_TYPEHASH, t.bondId, t.partyA, t.partyB, t.partyAStake, t.partyBStake, t.stakeAsset, t.partnershipType, t.breachConditionsHash, t.objectiveRuleClass, t.objectiveRuleSchemaVersion, t.flourishRewardConditionsHash, t.rewardProgramId, t.flourishRewardCap, t.proposalDeadline, t.activePeriodStart, t.activePeriodEnd, t.challengeWindow, t.nonce, t.chainId, keccak256(bytes(t.protocolVersion)) ) ); } function createBond(Terms calldata t, bytes calldata sigA, bytes calldata sigB) external whenNotPaused returns (uint256 bondId) { if (t.chainId != block.chainid) revert BadChain(); if (_nonceUsed[t.partyA][t.nonce] || _nonceUsed[t.partyB][t.nonce]) revert NonceUsed(); if (t.bondId != nextBondId) revert BadTerms(); if (t.partyA == address(0) || t.partyB == address(0)) revert ZeroAddress(); if (t.partyA == t.partyB) revert SelfBondForbidden(); _requireRegisteredDesignPartner(t.partyA, t.partyB); if ( t.stakeAsset != address(0) || (t.partyAStake == 0 && t.partyBStake == 0) || t.partnershipType == bytes32(0) || t.breachConditionsHash != bytes32(0) || t.objectiveRuleClass != 0 || t.objectiveRuleSchemaVersion != 0 || t.challengeWindow == 0 || t.challengeWindow > MAX_CHALLENGE_WINDOW || keccak256(bytes(t.protocolVersion)) != PROTOCOL_VERSION_HASH ) { revert BadTerms(); } _validateRewardTerms(t); if ( t.partyAStake > maxPartyNativePrincipal || t.partyBStake > maxPartyNativePrincipal || t.partyAStake > maxBondNativePrincipal - t.partyBStake ) { revert PrincipalCapExceeded(); } if ( t.proposalDeadline <= block.timestamp || t.activePeriodStart < t.proposalDeadline || t.activePeriodEnd <= t.activePeriodStart || uint256(t.activePeriodEnd) + (uint256(t.challengeWindow) * 2) > type(uint64).max ) { revert BadTerms(); } bytes32 digest = _hashTypedData(hashTerms(t)); if (!SignatureVerifierV3.verify(t.partyA, digest, sigA) || !SignatureVerifierV3.verify(t.partyB, digest, sigB)) { revert BadSignature(); } _nonceUsed[t.partyA][t.nonce] = true; _nonceUsed[t.partyB][t.nonce] = true; bondId = nextBondId++; BondData storage b = _bonds[bondId]; b.terms = t; b.termsHash = hashTerms(t); _transitionTo(bondId, BondStateV3.State.Proposed); _transitionTo(bondId, BondStateV3.State.Accepted); if (t.rewardProgramId != 0) { // Commit the signed reward cap from the sponsor reserve so this // bond can never overbook the program. Failure reverts creation. rewardProgram.commit(t.rewardProgramId, bondId, t.flourishRewardCap, b.termsHash); } emit BondCreatedV3(bondId, t.partyA, t.partyB, b.termsHash); } /// @notice Validates the optional flourishing reward binding. /// @dev Reward terms are all-or-nothing: a reward bond must bind a live /// program, the exact metric schema, and a positive cap. The reward /// can never touch protected principal. function _validateRewardTerms(Terms calldata t) private view { bool bound = t.flourishRewardConditionsHash != bytes32(0) || t.rewardProgramId != 0; if (!bound) { if (t.flourishRewardCap != 0) revert BadTerms(); return; } if ( t.flourishRewardConditionsHash == bytes32(0) || t.rewardProgramId == 0 || t.flourishRewardCap == 0 || address(rewardProgram) == address(0) ) revert BadTerms(); if ( !rewardProgram.matchesBondProgram( t.rewardProgramId, t.stakeAsset, t.flourishRewardConditionsHash, t.activePeriodEnd, t.bondId, t.activePeriodStart, t.flourishRewardCap ) ) revert RewardProgramMismatch(); } /// @notice Releases unused sponsor funding after completing custody effects. /// @dev Call only after state, credits and terminal events are complete. /// The untrusted callback must not observe a repeatable principal exit. function _releaseUnusedFlourishReward(uint256 bondId, BondData storage b) private { if (b.terms.rewardProgramId == 0 || address(rewardProgram) == address(0)) return; uint256 remaining = b.terms.flourishRewardCap - b.flourishRewardIssued; if (remaining != 0) { // A rotated or paused program must never block recovery of the // participants' funded value. A failed release only strands the // sponsor's reserve decision, never the bond's value. try rewardProgram.release{ gas: 100_000 }(b.terms.rewardProgramId, bondId, remaining) { emit FlourishRewardReleasedV3(bondId, remaining); } catch { emit FlourishRewardReleaseFailedV3(bondId, remaining); } } } /// @notice Executes a verifier-signed flourishing reward inside the signed /// cap and the signed active window. /// @dev The independent verifier signs the exact authorization; anyone may /// relay it. Issuance is replay-protected, deadline-bounded, capped /// and can never touch protected principal. function issueFlourishReward( uint256 bondId, address beneficiary, uint256 amount, FlourishRewardAuthorization calldata authorization, bytes calldata signature ) external { BondData storage b = _bonds[bondId]; if (b.terms.rewardProgramId == 0) revert BadTerms(); if (flourishRewardIssuer == address(0)) revert UnauthorizedRewardIssuer(); if (b.state != BondStateV3.State.Active) revert BadState(b.state); if (block.timestamp >= b.terms.activePeriodEnd) revert DeadlinePassed(); if (flourishRewardAuthorizationNonceUsed[bondId][authorization.nonce]) revert NonceUsed(); if ( authorization.bond != address(this) || authorization.chainId != block.chainid || authorization.bondId != bondId || authorization.beneficiary != beneficiary || authorization.amount != amount || authorization.deadline < block.timestamp ) revert InvalidEvidence(); bytes32 digest = _hashTypedData(hashFlourishRewardAuthorization(authorization)); if (!SignatureVerifierV3.verify(flourishRewardIssuer, digest, signature)) revert UnauthorizedRewardIssuer(); flourishRewardAuthorizationNonceUsed[bondId][authorization.nonce] = true; uint256 remaining = b.terms.flourishRewardCap - b.flourishRewardIssued; if (amount > remaining) revert RewardCapExceeded(); b.flourishRewardIssued += amount; rewardProgram.issue(b.terms.rewardProgramId, bondId, beneficiary, amount); emit FlourishRewardIssuedV3(bondId, beneficiary, amount); } /// @notice Binds the independent flourishing-reward verifier. /// @dev The verifier signs exact reward authorizations inside the signed /// cap and window. It never gains custody or principal authority, /// and rotating it never blocks value recovery. function setFlourishRewardIssuer(address issuer) external onlyOwner { if (issuer == address(0)) revert ZeroAddress(); flourishRewardIssuer = issuer; emit FlourishRewardIssuerSetV3(issuer); } /// @notice Exact, domain-separated flourishing-reward authorization signed /// by the independent verifier bound to the program policy. struct FlourishRewardAuthorization { address bond; uint256 chainId; uint256 bondId; address beneficiary; uint256 amount; uint96 nonce; uint64 deadline; } function hashFlourishRewardAuthorization(FlourishRewardAuthorization calldata a) public pure returns (bytes32) { return keccak256( abi.encode( FLOURISH_REWARD_AUTHORIZATION_TYPEHASH, a.bond, a.chainId, a.bondId, a.beneficiary, a.amount, a.nonce, a.deadline ) ); } function fundNative(uint256 bondId) external payable whenNotPaused { BondData storage b = _bonds[bondId]; if (b.terms.stakeAsset != address(0)) revert BadTerms(); if (b.state != BondStateV3.State.Accepted && b.state != BondStateV3.State.Funded) revert BadState(b.state); // Funding is permitted only during [creation, proposalDeadline). At // proposalDeadline, expireUnfunded is already available to return any // partial principal, so accepting funds would revive expired terms. if (block.timestamp >= b.terms.proposalDeadline) revert DeadlinePassed(); uint256 remaining; if (msg.sender == b.terms.partyA) { remaining = b.terms.partyAStake - b.partyAFunded; if (msg.value == 0 || msg.value > remaining) revert Overfund(); b.partyAFunded += msg.value; } else if (msg.sender == b.terms.partyB) { remaining = b.terms.partyBStake - b.partyBFunded; if (msg.value == 0 || msg.value > remaining) revert Overfund(); b.partyBFunded += msg.value; } else { revert NotParticipant(); } if (msg.value > maxAggregateNativeLiability - nativeLiability) revert AggregateCapExceeded(); nativeLiability += msg.value; if ( b.state == BondStateV3.State.Accepted && b.partyAFunded == b.terms.partyAStake && b.partyBFunded == b.terms.partyBStake ) { _transitionTo(bondId, BondStateV3.State.Funded); b.fundedAt = uint64(block.timestamp); } emit BondFundedV3(bondId, msg.sender, msg.value); if (address(pilotController) != address(0)) { pilotController.increasePositionAllocation(address(this), msg.value); } } /// @dev A factory-registered Base pilot child has a permanent controller /// association. The current allowlist is also checked so a rotated or /// revoked partner cannot leave an old child accepting new value. /// Controller-free V3 deployments retain their existing behavior. function _requireRegisteredDesignPartner(address partyA, address partyB) private view { if (address(pilotController) == address(0)) return; address designPartner = pilotController.positionDesignPartner(address(this)); if ( designPartner == address(0) || !pilotController.isDesignPartner(designPartner) || (partyA != designPartner && partyB != designPartner) ) revert DesignPartnerNotParticipant(); } function activate(uint256 bondId) external whenNotPaused { BondData storage b = _bonds[bondId]; if (b.state != BondStateV3.State.Funded) revert BadState(b.state); if (block.timestamp < b.terms.activePeriodStart) revert DeadlineNotReached(); // The active period is half-open: [activePeriodStart, activePeriodEnd). // At activePeriodEnd an active bond may markExpired, while a funded // bond that never activated becomes permissionlessly recoverable. // Neither path may be bypassed by reviving an expired agreement. if (block.timestamp >= b.terms.activePeriodEnd) revert DeadlinePassed(); _transitionTo(bondId, BondStateV3.State.Active); emit BondActivatedV3(bondId); } function cancelBeforeActivation(uint256 bondId) external { BondData storage b = _bonds[bondId]; if (msg.sender != b.terms.partyA && msg.sender != b.terms.partyB) revert NotParticipant(); if (b.state != BondStateV3.State.Accepted && b.state != BondStateV3.State.Funded) revert BadState(b.state); _refundPrincipal(b); _transitionTo(bondId, BondStateV3.State.Cancelled); emit BondCancelledV3(bondId, msg.sender, "pre-active"); _releaseUnusedFlourishReward(bondId, b); } /// @notice Permissionless expiry for an accepted, partly-funded proposal. function expireUnfunded(uint256 bondId) external { BondData storage b = _bonds[bondId]; if (b.state != BondStateV3.State.Accepted) revert BadState(b.state); if (block.timestamp < b.terms.proposalDeadline) revert DeadlineNotReached(); _refundPrincipal(b); _transitionTo(bondId, BondStateV3.State.Expired); _transitionTo(bondId, BondStateV3.State.ForceExited); emit BondExpiredV3(bondId); emit ForceExitedV3(bondId, msg.sender); _releaseUnusedFlourishReward(bondId, b); } /// @notice Permissionless recovery for a fully funded bond that was never /// activated before the signed active-period window closed. /// @dev Callable while paused. Principal is credited to the original /// funders and remains subject to pull-payment withdrawal. function expireUnactivated(uint256 bondId) external { BondData storage b = _bonds[bondId]; if (b.state != BondStateV3.State.Funded) revert BadState(b.state); if (block.timestamp < b.terms.activePeriodEnd) revert DeadlineNotReached(); _refundPrincipal(b); _transitionTo(bondId, BondStateV3.State.Expired); _transitionTo(bondId, BondStateV3.State.ForceExited); emit BondExpiredV3(bondId); emit ForceExitedV3(bondId, msg.sender); _releaseUnusedFlourishReward(bondId, b); } function markExpired(uint256 bondId) external { BondData storage b = _bonds[bondId]; if (b.state != BondStateV3.State.Active) revert BadState(b.state); if (block.timestamp < b.terms.activePeriodEnd) revert DeadlineNotReached(); _transitionTo(bondId, BondStateV3.State.Expired); emit BondExpiredV3(bondId); _releaseUnusedFlourishReward(bondId, b); } function forceExit(uint256 bondId) external { BondData storage b = _bonds[bondId]; if (b.state != BondStateV3.State.Expired) revert BadState(b.state); _refundPrincipal(b); _transitionTo(bondId, BondStateV3.State.ForceExited); emit ForceExitedV3(bondId, msg.sender); _releaseUnusedFlourishReward(bondId, b); } /// @notice Disabled legacy generic custody entry points retained only for ABI compatibility. function requestSettlement(uint256) external pure { revert PilotMutualConsentOnly(); } function challengeSettlement(uint256, uint256) external pure { revert PilotMutualConsentOnly(); } function advanceToFinalizable(uint256) external pure { revert PilotMutualConsentOnly(); } function cancelStalledSettlement(uint256) external pure { revert PilotMutualConsentOnly(); } function resolveChallenge(uint256) external pure { revert PilotMutualConsentOnly(); } function settlementSnapshot(uint256, BondMathLibV3.Split calldata) external pure returns (bytes32) { revert PilotMutualConsentOnly(); } function freezeSettlement(uint256, uint96, BondMathLibV3.Split calldata) external pure { revert PilotMutualConsentOnly(); } function settle(uint256) external pure { revert PilotMutualConsentOnly(); } function hashMutualSettlement(MutualSettlement calldata authorization) public pure returns (bytes32) { return keccak256( abi.encode( MUTUAL_SETTLEMENT_TYPEHASH, authorization.bond, authorization.chainId, authorization.bondId, authorization.termsHash, authorization.partyAAmount, authorization.partyBAmount, authorization.nonce, authorization.deadline ) ); } /// @notice Settles an active bond only on matching typed authorization from /// both participants. No score, evidence commitment, threshold vote, /// or adjudicator result can alter this Base-pilot custody outcome. function settleByMutualConsent(MutualSettlement calldata authorization, bytes calldata sigA, bytes calldata sigB) external whenNotPaused { if (authorization.bond != address(this) || authorization.chainId != block.chainid) revert BadChain(); if (block.timestamp > authorization.deadline) revert AuthorizationExpired(); BondData storage b = _bonds[authorization.bondId]; if (mutualSettlementNonceUsed[authorization.bondId][authorization.nonce]) revert AuthorizationUsed(); if (b.state != BondStateV3.State.Active) revert BadState(b.state); if (block.timestamp >= b.terms.activePeriodEnd) revert DeadlinePassed(); uint256 pool = b.partyAFunded + b.partyBFunded; if (authorization.termsHash != b.termsHash || authorization.partyAAmount + authorization.partyBAmount != pool) { revert InvalidMutualSettlement(); } bytes32 digest = _hashTypedData(hashMutualSettlement(authorization)); if ( !SignatureVerifierV3.verify(b.terms.partyA, digest, sigA) || !SignatureVerifierV3.verify(b.terms.partyB, digest, sigB) ) revert BadSignature(); mutualSettlementNonceUsed[authorization.bondId][authorization.nonce] = true; b.settlementSnapshot = digest; _transitionTo(authorization.bondId, BondStateV3.State.SettlementRequested); _transitionTo(authorization.bondId, BondStateV3.State.Finalizable); _transitionTo(authorization.bondId, BondStateV3.State.SettlementFrozen); if (authorization.partyAAmount > 0) _credit(b.terms.stakeAsset, b.terms.partyA, authorization.partyAAmount); if (authorization.partyBAmount > 0) _credit(b.terms.stakeAsset, b.terms.partyB, authorization.partyBAmount); _transitionTo(authorization.bondId, BondStateV3.State.Settled); emit MutualSettlementExecutedV3( authorization.bondId, authorization.partyAAmount, authorization.partyBAmount, authorization.nonce, digest ); emit BondSettledV3(authorization.bondId, authorization.partyAAmount, authorization.partyBAmount, 0); _releaseUnusedFlourishReward(authorization.bondId, b); } /// @notice Disabled dispute hooks retained only as fail-closed ABI compatibility stubs. function disputeSubject(uint256) external pure returns (bytes32) { revert PilotMutualConsentOnly(); } function settlementChallengeContext(uint256) external pure returns (bytes32, uint64, uint64) { revert PilotMutualConsentOnly(); } function isAuthorizedChallenger(uint256, address) external pure returns (bool) { revert PilotMutualConsentOnly(); } function authorizedRespondent(uint256, address) external pure returns (address) { revert PilotMutualConsentOnly(); } function disputePayoutSource(uint256) external pure returns (address) { revert PilotMutualConsentOnly(); } function disputePayoutCap(uint256) external pure returns (uint256) { revert PilotMutualConsentOnly(); } function releaseDisputePayout(uint256, address, uint256) external pure { revert PilotMutualConsentOnly(); } function bondOf(uint256 bondId) external view returns (BondData memory) { return _bonds[bondId]; } function nonceUsed(address party, uint96 nonce) external view returns (bool) { return _nonceUsed[party][nonce]; } function _refundPrincipal(BondData storage b) internal { if (b.partyAFunded > 0) _credit(b.terms.stakeAsset, b.terms.partyA, b.partyAFunded); if (b.partyBFunded > 0) _credit(b.terms.stakeAsset, b.terms.partyB, b.partyBFunded); } function _baseSettlementSnapshot(uint256 bondId, BondData storage b) internal view returns (bytes32) { return keccak256( abi.encode(address(this), bondId, b.termsHash, b.partyAFunded, b.partyBFunded, b.settlementRequestedAt) ); } function _transitionTo(uint256 bondId, BondStateV3.State to) internal { BondStateV3.State from = _bonds[bondId].state; BondStateV3.requireTransition(from, to); _bonds[bondId].state = to; emit BondStateChangedV3(bondId, from, to, msg.sender); } function skimSurplus(address asset, address to, uint256 amount) external onlyOwner { if (to == address(0)) revert ZeroAddress(); if (asset == address(0)) { uint256 balance = address(this).balance; if (balance < nativeLiability || amount > balance - nativeLiability) revert InsufficientSurplus(); (bool ok,) = payable(to).call{ value: amount }(""); if (!ok) revert("skim failed"); } else { (bool ok, bytes memory ret) = asset.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount)); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert("skim failed"); } } function _afterCreditWithdrawal(address asset, uint256 amount) internal override { if (asset == address(0)) { nativeLiability -= amount; if (address(pilotController) != address(0)) { pilotController.releasePositionAllocation(address(this), amount); } } } }