From 1fa8698fd6163a88884e5b85c9f51b5cf1861852 Mon Sep 17 00:00:00 2001 From: Truphile Date: Sat, 27 Jun 2026 15:37:54 -0400 Subject: [PATCH] feat: Add governed beneficiary rotation --- docs/ESCROW_BENEFICIARY_ROTATION.md | 203 +++------------------ escrow/src/lib.rs | 263 ++++++++-------------------- escrow/src/tests/admin.rs | 78 +++++++++ 3 files changed, 176 insertions(+), 368 deletions(-) diff --git a/docs/ESCROW_BENEFICIARY_ROTATION.md b/docs/ESCROW_BENEFICIARY_ROTATION.md index 89172377..e4f9a9dc 100644 --- a/docs/ESCROW_BENEFICIARY_ROTATION.md +++ b/docs/ESCROW_BENEFICIARY_ROTATION.md @@ -1,214 +1,55 @@ # Beneficiary Rotation System -This document describes the timelocked beneficiary rotation system implemented in the LiquiFact Escrow contract. +This document describes the beneficiary rotation system implemented in the LiquiFact Escrow contract. ## Overview -The beneficiary rotation system allows the admin to propose a new SME (Small Medium Enterprise) beneficiary with a timelock, ensuring secure and transparent transitions of fund control rights. +The beneficiary rotation system allows the current SME (Small Medium Enterprise) and the admin to jointly update the SME beneficiary address (`sme_address`). This is useful for assigning the rights to a different entity (e.g., factoring) without needing to redeploy the escrow contract. ## Key Features -- **Timelocked Proposals**: Admin can propose new beneficiaries with a minimum delay before acceptance -- **Authorization Control**: Only the proposed address can accept the role after timelock expires -- **Event Transparency**: All proposal, acceptance, and cancellation actions emit events -- **State Management**: Current active SME address is tracked separately from original SME -- **Admin Controls**: Admin can cancel proposals and manage the rotation process - -## Data Structures - -### BeneficiaryProposal - -```rust -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BeneficiaryProposal { - /// Address that will become the new SME after acceptance and timelock expiry - pub proposed_address: Address, - /// Ledger timestamp when the proposal was created - pub proposed_at: u64, - /// Minimum delay in seconds before the proposal can be accepted - pub timelock_duration_secs: u64, -} -``` - -### CurrentSmeAddress - -```rust -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct CurrentSmeAddress { - /// The currently active SME address that can withdraw funds - pub address: Address, -} -``` - -## Storage Keys - -- `DataKey::BeneficiaryProposal` - Stores the current proposal (if any) -- `DataKey::CurrentSmeAddress` - Stores the currently active SME address +- **Dual Consent Authorization**: Requires authorization from both the current SME address and the admin address. +- **State Guard**: Only allowed when the escrow is in a non-terminal state (open or funded). +- **Collateral Ownership**: Metadata ownership intrinsically transfers with the `sme_address`. +- **Event Transparency**: A `BeneficiaryRotated` event is emitted upon successful rotation. ## Core Functions -### `propose_beneficiary` +### `rotate_beneficiary` -Proposes a new SME beneficiary with a timelock duration. +Updates the SME beneficiary address. **Parameters:** -- `proposed_address`: The address that will become the new SME -- `timelock_duration_secs`: Minimum delay before the proposal can be accepted +- `new_sme`: The address that will become the new SME. **Requirements:** -- Admin authorization required -- Proposed address cannot be the same as current SME -- Timelock duration must be greater than zero -- No existing proposal can be active +- Escrow status must be `0` (open) or `1` (funded). +- Authorization required from **current SME address**. +- Authorization required from **admin address**. +- The `new_sme` address cannot be the same as the current SME. **Events:** -- `BeneficiaryProposed` - Emitted when proposal is created - -### `accept_beneficiary` - -Accepts a proposed beneficiary role after timelock expires. - -**Parameters:** - -- `caller`: The proposed address (must match the proposal) - -**Requirements:** - -- Caller must be the proposed address -- Proposal must exist -- Timelock must have expired - -**Effects:** - -- Updates the current SME address -- Removes the proposal -- Emits `BeneficiaryAccepted` event - -### `cancel_beneficiary_proposal` - -Cancels an active beneficiary proposal. - -**Requirements:** - -- Admin authorization required -- Proposal must exist - -**Effects:** - -- Removes the proposal -- Emits `BeneficiaryCancelled` event - -### `get_current_sme_address` - -Returns the currently active SME address. - -**Logic:** - -- If `CurrentSmeAddress` exists, returns that address -- Otherwise, returns the original SME address from the escrow - -### `can_accept_beneficiary` - -Checks if a beneficiary proposal is active and timelock has expired. - -**Returns:** - -- `true` if proposal exists and timelock has expired -- `false` otherwise - -## Event Flow - -### Successful Rotation - -1. Admin calls `propose_beneficiary` -2. `BeneficiaryProposed` event emitted -3. Wait for timelock duration -4. New SME calls `accept_beneficiary` -5. `BeneficiaryAccepted` event emitted -6. New SME can now call `withdraw` and `settle` - -### Cancellation Flow - -1. Admin calls `cancel_beneficiary_proposal` -2. `BeneficiaryCancelled` event emitted -3. Original SME retains control - -## Security Considerations - -### Timelock Security - -- Minimum timelock duration of 1 second prevents immediate transfers -- Timelock expiration is based on ledger timestamp, not wall-clock time -- Prevents rushed or coerced transfers - -### Authorization Security - -- Only admin can propose new beneficiaries -- Only proposed address can accept the role -- Admin cannot force acceptance or bypass timelock - -### State Consistency - -- Current SME address is updated atomically with proposal acceptance -- Original SME address remains unchanged in escrow storage -- Clear separation between proposal state and active beneficiary state - -## Usage Examples - -### Proposing a New Beneficiary - -```rust -// Admin proposes new SME with 24-hour timelock -let proposal = client.propose_beneficiary(&new_sme_address, &86400u64); -assert_eq!(proposal.timelock_duration_secs, 86400); -``` - -### Accepting Beneficiary Role - -```rust -// After timelock expires, new SME accepts -let updated_escrow = client.accept_beneficiary(&new_sme_address); -assert_eq!(updated_escrow.sme_address, new_sme_address); -``` - -### Checking Rotation Status - -```rust -// Check if rotation is possible -let can_accept = client.can_accept_beneficiary(); -if can_accept { - // New SME can accept the role -} -``` +- `BeneficiaryRotated` - Emitted when the rotation is successful, detailing the `old_sme` and `new_sme`. ## Integration with Existing Functions ### `withdraw` and `settle` -These functions now use `get_current_sme_address()` instead of the original SME address, ensuring that the current active beneficiary can control funds. +These functions rely on `sme_address.require_auth()`. After `rotate_beneficiary` is called, the new address becomes the sole authority for these functions. -### Legal Hold Compatibility +### `record_sme_collateral_commitment` -Beneficiary rotation is not blocked by legal holds, as it's an administrative function that doesn't move funds. +Collateral metadata commitments are tied to the active SME. When the beneficiary is rotated, the new SME automatically gains the right to update these commitments. -## Testing +## Security Considerations -The implementation includes comprehensive tests covering: +### Dual Consent -- Successful proposal and acceptance -- Timelock enforcement -- Authorization requirements -- State transitions -- Event emission -- Edge cases and error conditions +Both the admin and the current SME must sign the transaction. The admin alone cannot forcibly reassign the beneficiary, protecting the SME from malicious admin actions. The SME alone cannot transfer the rights, maintaining governance oversight. -## Migration Considerations +### Terminal States -- Existing escrow contracts will continue to work unchanged -- New contracts will have the rotation functionality available -- No migration is required for existing deployments +Rotation is blocked if the escrow is settled, withdrawn, or cancelled. This prevents unexpected state changes during or after the distribution of funds. diff --git a/escrow/src/lib.rs b/escrow/src/lib.rs index 653593fb..d54ded7f 100644 --- a/escrow/src/lib.rs +++ b/escrow/src/lib.rs @@ -124,7 +124,8 @@ pub mod external_calls; // Typed contract errors used throughout the contract. Keep in sync with // `docs/escrow-error-messages.md`. #[contracterror] -#[derive(Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] pub enum EscrowError { AmountMustBePositive = 1, YieldBpsOutOfRange = 2, @@ -139,9 +140,11 @@ pub enum EscrowError { TierYieldBelowBase = 11, TierLockNotIncreasing = 12, TierYieldNotNonDecreasing = 13, + EscrowNotInitialized = 20, FundingTokenNotSet = 21, TreasuryNotSet = 22, + LegalHoldBlocksTreasuryDustSweep = 30, SweepAmountNotPositive = 31, SweepAmountExceedsMax = 32, @@ -154,11 +157,17 @@ pub enum EscrowError { RecipientBalanceUnderflow = 39, SenderBalanceDeltaMismatch = 40, RecipientBalanceDeltaMismatch = 41, + /// Sweep would reduce the contract balance below outstanding investor liabilities. + /// `balance - sweep_amt` must be `>= funded_amount - distributed_principal`. + SweepExceedsLiabilityFloor = 42, + PrimaryAttestationAlreadyBound = 50, AttestationAppendLogCapacityReached = 51, + CollateralAmountNotPositive = 60, CollateralAssetEmpty = 61, CollateralTimestampBackwards = 62, + InvestorBatchEmpty = 70, InvestorBatchTooLarge = 71, TargetNotPositive = 72, @@ -170,9 +179,13 @@ pub enum EscrowError { NewCapBelowCurrentFunderCount = 78, MaturityUpdateNotOpen = 79, NewAdminSameAsCurrent = 80, + NewSmeSameAsCurrent = 81, + RotateBeneficiaryNotOpen = 82, + MigrationVersionMismatch = 90, AlreadyCurrentSchemaVersion = 91, NoMigrationPath = 92, + FundingAmountNotPositive = 100, FundingBelowMinContribution = 101, LegalHoldBlocksFunding = 102, @@ -184,6 +197,7 @@ pub enum EscrowError { TieredSecondDeposit = 108, InvestorClaimTimeOverflow = 109, FundedAmountOverflow = 110, + LegalHoldBlocksSettlement = 120, SettlementNotFunded = 121, MaturityNotReached = 122, @@ -194,24 +208,30 @@ pub enum EscrowError { InvestorClaimNotSettled = 127, InvestorCommitmentLockNotExpired = 128, ComputePayoutArithmeticOverflow = 129, + LegalHoldClearDelayOverflow = 130, + LegalHoldClearNotReady = 131, + LegalHoldClearRequestMissing = 132, + LegalHoldBlocksCancelFunding = 140, CancelFundingNotOpen = 141, RefundNotCancelled = 142, NoContributionToRefund = 143, } +/// Generic fail helper that panics with a typed `EscrowError` and coerces to any return type. +#[inline(always)] +pub fn fail(env: &Env, err: EscrowError) -> T { + panic_with_error!(env, err) +} + /// Panic with a typed `EscrowError` if `cond` is false. +#[inline(always)] pub fn ensure(env: &Env, cond: bool, err: EscrowError) { if !cond { - panic_with_error!(env, err); + fail::<()>(env, err); } } -/// Generic fail helper that panics with a typed `EscrowError` and coerces to any return type. -pub fn fail(env: &Env, err: EscrowError) -> T { - panic_with_error!(env, err) -} - /// Current storage schema version written to [`DataKey::Version`] by [`LiquifactEscrow::init`]. /// /// # Schema version changelog @@ -232,80 +252,6 @@ pub const SCHEMA_VERSION: u32 = 6; /// Revocation via [`LiquifactEscrow::revoke_attestation_digest`] does not consume a slot. pub const MAX_ATTESTATION_APPEND_ENTRIES: u32 = 32; -/// Typed contract error codes for escrow validation and guard failures. -#[contracterror] -pub enum EscrowError { - AlreadyCurrentSchemaVersion, - AmountMustBePositive, - AttestationAppendLogCapacityReached, - CancelFundingNotOpen, - CollateralAmountNotPositive, - CollateralAssetEmpty, - CollateralTimestampBackwards, - ComputePayoutArithmeticOverflow, - DustSweepNotTerminal, - EffectiveSweepAmountZero, - EscrowAlreadyInitialized, - EscrowNotInitialized, - EscrowNotOpenForFunding, - FundedAmountOverflow, - FundingAmountNotPositive, - FundingBelowMinContribution, - FundingTokenNotSet, - InsufficientTokenBalanceBeforeTransfer, - InvestorClaimNotSettled, - InvestorClaimTimeOverflow, - InvestorCommitmentLockNotExpired, - InvestorContributionExceedsCap, - InvestorContributionOverflow, - InvestorNotAllowlisted, - InvoiceIdInvalidCharset, - InvoiceIdInvalidLength, - LegalHoldBlocksCancelFunding, - LegalHoldBlocksFunding, - LegalHoldBlocksInvestorClaims, - LegalHoldBlocksSettlement, - LegalHoldBlocksTreasuryDustSweep, - LegalHoldBlocksWithdrawal, - LegalHoldClearDelayOverflow, - LegalHoldClearNotReady, - LegalHoldClearRequestMissing, - MaturityNotReached, - MaturityUpdateNotOpen, - MaxPerInvestorNotPositive, - MaxUniqueInvestorsNotPositive, - MigrationVersionMismatch, - MinContributionExceedsAmount, - MinContributionNotPositive, - NewAdminSameAsCurrent, - NoContributionToClaim, - NoContributionToRefund, - NoFundingTokenBalanceToSweep, - NoMigrationPath, - PrimaryAttestationAlreadyBound, - RecipientBalanceDeltaMismatch, - RecipientBalanceUnderflow, - RefundNotCancelled, - SenderBalanceDeltaMismatch, - SenderBalanceUnderflow, - SettlementNotFunded, - SweepAmountExceedsMax, - SweepAmountNotPositive, - TargetBelowFundedAmount, - TargetNotPositive, - TargetUpdateNotOpen, - TierLockNotIncreasing, - TierYieldBelowBase, - TierYieldNotNonDecreasing, - TierYieldOutOfRange, - TieredSecondDeposit, - TransferAmountNotPositive, - TreasuryNotSet, - UniqueInvestorCapReached, - WithdrawalNotFunded, - YieldBpsOutOfRange, -} - /// Upper bound on batch allowlist mutation entries to keep storage/CPU bounded. /// Mirrors the spirit of `MAX_ATTESTATION_APPEND_ENTRIES` to limit per-call work. pub const MAX_INVESTOR_ALLOWLIST_BATCH: u32 = 32; @@ -332,112 +278,6 @@ pub const INSTANCE_TTL_MIN_EXTENSION_LEDGERS: u32 = 60 * 60; // Approx. 1h at 1 /// Extending persistent allowlist TTL reduces the risk of silent allowlist disablement. pub const PERSISTENT_TTL_MIN_EXTENSION_LEDGERS: u32 = 60 * 60; // Approx. 1h at 1 ledger/sec. -/// Stable typed errors emitted by LiquiFact escrow entrypoints. -/// -/// Codes are append-only: never reuse or renumber a variant. Client SDKs should branch on the -/// numeric code rather than legacy panic strings. See `docs/escrow-error-messages.md`. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum EscrowError { - AmountMustBePositive = 1, - YieldBpsOutOfRange = 2, - EscrowAlreadyInitialized = 3, - InvoiceIdInvalidLength = 4, - InvoiceIdInvalidCharset = 5, - MinContributionNotPositive = 6, - MinContributionExceedsAmount = 7, - MaxUniqueInvestorsNotPositive = 8, - MaxPerInvestorNotPositive = 9, - TierYieldOutOfRange = 10, - TierYieldBelowBase = 11, - TierLockNotIncreasing = 12, - TierYieldNotNonDecreasing = 13, - - EscrowNotInitialized = 20, - FundingTokenNotSet = 21, - TreasuryNotSet = 22, - - LegalHoldBlocksTreasuryDustSweep = 30, - SweepAmountNotPositive = 31, - SweepAmountExceedsMax = 32, - DustSweepNotTerminal = 33, - NoFundingTokenBalanceToSweep = 34, - EffectiveSweepAmountZero = 35, - TransferAmountNotPositive = 36, - InsufficientTokenBalanceBeforeTransfer = 37, - SenderBalanceUnderflow = 38, - RecipientBalanceUnderflow = 39, - SenderBalanceDeltaMismatch = 40, - RecipientBalanceDeltaMismatch = 41, - /// Sweep would reduce the contract balance below outstanding investor liabilities. - /// `balance - sweep_amt` must be `>= funded_amount - distributed_principal`. - SweepExceedsLiabilityFloor = 42, - - PrimaryAttestationAlreadyBound = 50, - AttestationAppendLogCapacityReached = 51, - - CollateralAmountNotPositive = 60, - CollateralAssetEmpty = 61, - CollateralTimestampBackwards = 62, - - InvestorBatchEmpty = 70, - InvestorBatchTooLarge = 71, - TargetNotPositive = 72, - TargetUpdateNotOpen = 73, - TargetBelowFundedAmount = 74, - CapLowerNotOpen = 75, - NoInvestorCapConfigured = 76, - NewCapNotLower = 77, - NewCapBelowCurrentFunderCount = 78, - MaturityUpdateNotOpen = 79, - NewAdminSameAsCurrent = 80, - - MigrationVersionMismatch = 90, - AlreadyCurrentSchemaVersion = 91, - NoMigrationPath = 92, - - FundingAmountNotPositive = 100, - FundingBelowMinContribution = 101, - LegalHoldBlocksFunding = 102, - EscrowNotOpenForFunding = 103, - InvestorNotAllowlisted = 104, - InvestorContributionOverflow = 105, - InvestorContributionExceedsCap = 106, - UniqueInvestorCapReached = 107, - TieredSecondDeposit = 108, - InvestorClaimTimeOverflow = 109, - FundedAmountOverflow = 110, - - LegalHoldBlocksSettlement = 120, - SettlementNotFunded = 121, - MaturityNotReached = 122, - LegalHoldBlocksWithdrawal = 123, - WithdrawalNotFunded = 124, - LegalHoldBlocksInvestorClaims = 125, - NoContributionToClaim = 126, - InvestorClaimNotSettled = 127, - InvestorCommitmentLockNotExpired = 128, - ComputePayoutArithmeticOverflow = 129, - - LegalHoldBlocksCancelFunding = 140, - CancelFundingNotOpen = 141, - RefundNotCancelled = 142, - NoContributionToRefund = 143, -} - -#[inline(always)] -pub(crate) fn fail(env: &Env, error: EscrowError) -> ! { - panic_with_error!(env, error) -} - -#[inline(always)] -pub(crate) fn ensure(env: &Env, condition: bool, error: EscrowError) { - if !condition { - fail(env, error); - } -} - // --- Storage keys --- #[contracttype] @@ -730,6 +570,16 @@ pub struct MaturityUpdatedEvent { pub new_maturity: u64, } +#[contractevent] +pub struct BeneficiaryRotated { + #[topic] + pub name: Symbol, + #[topic] + pub invoice_id: Symbol, + pub old_sme: Address, + pub new_sme: Address, +} + #[contractevent] pub struct AdminTransferredEvent { #[topic] @@ -2080,11 +1930,13 @@ impl LiquifactEscrow { escrow.yield_bps, ); Self::set_persistent_investor_claim_not_before(&env, investor.clone(), 0u64); + tier_lock_secs = 0; } else { // Returning investor: yield was set on first deposit; read it for the event. investor_effective_yield_bps = Self::get_persistent_investor_effective_yield(&env, investor.clone()) .unwrap_or(escrow.yield_bps); + tier_lock_secs = 0; } // If prev > 0, preserve existing effective yield and claim lock } else { @@ -2095,6 +1947,7 @@ impl LiquifactEscrow { let (eff, lock) = Self::effective_yield_for_commitment(&env, escrow.yield_bps, committed_lock_secs); investor_effective_yield_bps = eff; + tier_lock_secs = lock; Self::set_persistent_investor_effective_yield(&env, investor.clone(), eff); let now = env.ledger().timestamp(); let claim_nb = if committed_lock_secs == 0 { @@ -2513,6 +2366,42 @@ impl LiquifactEscrow { } } + /// Update the SME beneficiary address via dual consent (current SME and admin). + /// + /// Allowed only in non-terminal states (0 = open, 1 = funded). + /// Invariant: after rotation, only the new SME may withdraw/settle. + pub fn rotate_beneficiary(env: Env, new_sme: Address) { + let mut escrow = Self::get_escrow(env.clone()); + + ensure( + &env, + escrow.status == 0 || escrow.status == 1, + EscrowError::RotateBeneficiaryNotOpen, + ); + + escrow.sme_address.require_auth(); + escrow.admin.require_auth(); + + ensure( + &env, + escrow.sme_address != new_sme, + EscrowError::NewSmeSameAsCurrent, + ); + + let old_sme = escrow.sme_address.clone(); + escrow.sme_address = new_sme.clone(); + + env.storage().instance().set(&DataKey::Escrow, &escrow); + + BeneficiaryRotated { + name: Symbol::new(&env, "BeneficiaryRotated"), + invoice_id: escrow.invoice_id.clone(), + old_sme, + new_sme, + } + .publish(&env); + } + /// Propose a new admin (`PendingAdmin`) — step 1 of a two-step handover. /// /// Requires current admin authorization. The destination must differ from the current admin. diff --git a/escrow/src/tests/admin.rs b/escrow/src/tests/admin.rs index 426a2d9c..1dbc2254 100644 --- a/escrow/src/tests/admin.rs +++ b/escrow/src/tests/admin.rs @@ -192,6 +192,84 @@ fn test_transfer_admin_same_address_panics() { client.propose_admin(&admin); } +#[test] +fn test_rotate_beneficiary_success() { + let env = Env::default(); + let (client, admin, sme) = setup(&env); + let new_sme = Address::generate(&env); + let invoice_id = soroban_sdk::String::from_str(&env, "ROT001"); + client.init( + &admin, + &invoice_id, + &sme, + &TARGET, + &800i64, + &1000u64, + &Address::generate(&env), + &None, + &Address::generate(&env), + &None, + &None, + &None, + &None, + &None, + ); + + client.rotate_beneficiary(&new_sme); + let updated = client.get_escrow(); + assert_eq!(updated.sme_address, new_sme); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #81)")] +fn test_rotate_beneficiary_same_address_panics() { + let env = Env::default(); + let (client, admin, sme) = setup(&env); + client.init( + &admin, + &soroban_sdk::String::from_str(&env, "ROT002"), + &sme, + &TARGET, + &800i64, + &1000u64, + &Address::generate(&env), + &None, + &Address::generate(&env), + &None, + &None, + &None, + &None, + &None, + ); + client.rotate_beneficiary(&sme); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #82)")] +fn test_rotate_beneficiary_wrong_state() { + let env = Env::default(); + let (client, admin, sme) = setup(&env); + client.init( + &admin, + &soroban_sdk::String::from_str(&env, "ROT003"), + &sme, + &TARGET, + &800i64, + &1000u64, + &Address::generate(&env), + &None, + &Address::generate(&env), + &None, + &None, + &None, + &None, + &None, + ); + // Cancel the escrow so it's in a terminal state + client.cancel_funding(); + client.rotate_beneficiary(&Address::generate(&env)); +} + #[test] #[should_panic] fn test_transfer_admin_uninitialized_panics() {