Skip to main content

Delegation Security — Program Escrow

Overview

ProgramEscrowContract supports a single-level delegate per program. A delegate is an address that can act on behalf of the program owner (authorized_payout_key) for a specific subset of operations, controlled by a bitmask.

Permission Bitmask

ConstantBitGrants
DELEGATE_PERMISSION_RELEASE0Execute single/batch payouts and schedules
DELEGATE_PERMISSION_REFUND1Trigger refunds
DELEGATE_PERMISSION_UPDATE_META2Update program metadata

DELEGATE_PERMISSION_MASK is the OR of all three bits. Any bitmask with bits outside this mask is rejected with "Unsupported delegate permissions".

Security Invariants

1. Only owner or admin can set/revoke a delegate

set_program_delegate and revoke_program_delegate call require_program_owner_or_admin, which accepts only:

  • the program's authorized_payout_key, or
  • the contract-level admin.

A delegate is not in this set. This prevents delegation chains where a delegate grants its permissions (or a superset) to a third party.

2. No permission escalation

Because delegates cannot call set_program_delegate, they cannot:

  • upgrade their own bitmask,
  • grant a superset of their permissions to another address, or
  • replace themselves with a different address.

3. Immediate revocation

When the owner calls set_program_delegate with a new address, the previous delegate is atomically replaced. The old delegate loses all access on the same ledger entry write — there is no grace period.

4. Delegate ≠ owner

The payout key cannot be registered as its own delegate ("Delegate must differ from owner"). This prevents a degenerate state where the owner appears to hold delegate-level permissions through a separate code path.

5. Non-empty, valid bitmask required

A zero bitmask is rejected ("Delegate permissions cannot be empty"). Bits outside DELEGATE_PERMISSION_MASK are rejected ("Unsupported delegate permissions"). This ensures the stored bitmask always represents a meaningful, forward-compatible permission set.

Attack Vectors Mitigated

AttackMitigation
Delegate re-delegates to third partyset_program_delegate requires owner/admin auth
Delegate escalates own bitmaskSame — delegate is not an authorized caller
Replaced delegate retains accessAtomic overwrite; old address no longer matches
Delegate self-revokes to clear audit trailrevoke_program_delegate requires owner/admin auth
Bitmask with reserved bits setvalidate_delegate_permissions rejects unknown bits

Test Coverage (src/test_rbac.rs)

TestProperty verified
test_delegate_cannot_redelegate_to_third_partyRe-delegation rejected
test_delegate_with_partial_permissions_cannot_redelegatePartial-permission re-delegation rejected
test_delegate_cannot_escalate_own_permissionsSelf-escalation rejected
test_delegate_cannot_revoke_itselfSelf-revocation rejected
test_arbitrary_address_cannot_set_delegateUnauthenticated caller rejected
test_admin_can_set_delegateAdmin is a valid setter (positive control)
test_owner_can_replace_delegateOwner can atomically replace delegate
test_replaced_delegate_loses_accessReplaced delegate has no residual access
test_set_delegate_rejects_zero_permissionsZero bitmask rejected
test_set_delegate_rejects_unsupported_permission_bitsOut-of-mask bits rejected
test_owner_cannot_be_set_as_own_delegateOwner ≠ delegate invariant enforced