The LiquiFact escrow contract supports a governed on-chain rotation of the SME beneficiary (the address that receives the escrow’s funded principal on withdraw).
This document is the authoritative, code-accurate reference for the rotate_beneficiary flow, including its dual-authorization requirement, its exact guard ordering, and its operator-facing rejection codes.
Downstream impact:
rotate_beneficiarychanges thesme_addressstored in contract state. Later SME-gated disbursement (withdraw) uses the currentsme_address, so rotation determines where the funded principal is routed.
pub fn rotate_beneficiary(env: Env, new_sme_address: Address) -> InvoiceEscrowInvoiceEscrow::sme_addressis atomically updated from the current SME tonew_sme_address.
This entrypoint enforces dual authorization:
- Outgoing SME (
escrow.sme_address.require_auth()) - Current admin (
escrow.admin.require_auth())
Both must sign in the same transaction. This prevents unilateral redirection of the withdrawal destination by:
- a compromised admin key alone (admin cannot rotate without the SME signing), and
- a compromised SME key alone (SME cannot rotate without the admin signing).
rotate_beneficiary evaluates guards in this order:
-
Legal-hold gate (read-only)
- Condition:
!legal_hold_active - If
LegalHoldis active, the call aborts immediately.
- Condition:
-
State gate (allowed states only)
- Condition:
escrow.status == 0 || escrow.status == 1 - Meaning:
0= open (pre-settlement)1= funded (still pre-settlement)
- Condition:
-
No-op guard
- Condition:
new_sme_address != escrow.sme_address - Rotating to the current address is rejected.
- Condition:
-
Dual authorization
escrow.sme_address.require_auth()escrow.admin.require_auth()
-
Storage write + event emission
- Persists the updated
sme_addressintoDataKey::Escrow. - Emits
BeneficiaryRotated.
- Persists the updated
Rotation is only permitted in pre-settlement states:
status = 0(open)status = 1(funded)
Rotation is rejected in terminal/post-settlement states:
status = 2(settled)status = 3(withdrawn)status = 4(cancelled)
These are the typed EscrowError variants emitted by rotate_beneficiary:
-
LegalHoldBlocksBeneficiaryRotation(160)- Trigger: legal hold is active.
- Meaning: compliance/legal hold blocks beneficiary rotation.
-
RotationNotOpen(161)- Trigger: escrow is not in a pre-settlement state.
- Meaning:
statusmust be0(open) or1(funded).
-
NewSmeSameAsCurrent(162)- Trigger:
new_sme_address == escrow.sme_address. - Meaning: no-op rotations are rejected.
- Trigger:
withdraw is SME-gated and sends the funded principal to the current stored sme_address.
So after a successful rotation:
withdrawwill route disbursement to the new SME beneficiary.- the new SME becomes the authority for subsequent SME-gated flows.
rotate_beneficiaryemitsBeneficiaryRotated(withprior_smeandnew_sme) andBenChange(withprior_sme,new_sme, andamount).- After rotation, later SME disbursement emits
SmeWithdrew.
Indexers should:
- update their internal “active SME” mapping on
BeneficiaryRotatedorBenChange, then - attribute a later
SmeWithdrewto the SME that was current after the rotation.
Emitted after successful rotate_beneficiary.
Fields:
name:ben_rotinvoice_id: the escrow invoice idprior_sme: previous SME addressnew_sme: updated SME address
Emitted after successful rotate_beneficiary as a dedicated <= 9 chars topic event.
Fields:
name:ben_chginvoice_id: the escrow invoice idprior_sme: previous SME addressnew_sme: updated SME addressamount: the escrow target amount
- Rotation is intentionally not a proposal/accept flow. It is a single call requiring both the outgoing SME and admin signatures.
- Legal hold blocks beneficiary rotation before any authorization checks run.
- Rotation only affects the withdrawal destination (
sme_address). It does not move tokens directly; token routing happens inwithdraw. - If you operate with multisig governance, ensure the admin key used for
rotate_beneficiarysigning cannot be invoked unilaterally without SME consent (and vice-versa), matching the intended dual-control policy.