Tip
Reporting a Vulnerability: See the canonical repository Vulnerability Disclosure Policy and Bug Bounty Framework for vulnerability scope, severity tiers, reporting channels, and safe harbor terms. Maintainers handling reports should consult the Coordinated Disclosure Workflow.
This document details the protocol's threat model, token interaction safety arguments, adversarial test coverage, and operational security best practices.
| Version | Supported |
|---|---|
| testnet (current) | Yes |
| mainnet | Not yet deployed — audit pending |
Accord Protocol is unaudited. Do not deploy to mainnet with significant funds until a formal third-party audit is completed. The contract has been reviewed for common Soroban anti-patterns but has not undergone a professional security review. Detailed scope, attack surface analysis, protocol invariants, and verification steps are consolidated in the Audit Preparation Pack.
External audit planning and preparation resources:
- Audit Scope & Specifications: Defines in-scope entrypoints, files, and measured codebase metrics.
- Auditor Selection & RFQ Template: Selection criteria, Request-for-Quote template, and candidate directory.
- Audit Remediation & Tracking: Post-audit triage, verification workflow, and public remediation tracker.
Known areas that require attention before mainnet:
- Reentrancy via external token calls (currently mitigated by Soroban's single-contract-call model, but warrants formal review)
- TTL expiry edge cases under high ledger load
- Use a hardware wallet or an air-gapped signing device for each multisig owner key whenever possible.
- Do not store a private key in a shared password manager, team vault, or any system that more than one person can access.
- Treat each Stellar signing key like a root credential: if it is exposed, assume an attacker can act as that owner until the owner is removed from the multisig.
- Keep recovery material, seed phrases, and backup exports offline and separate from day-to-day operational devices.
- Choose an M-of-N threshold that makes a single compromised key insufficient to move funds or approve sensitive actions on its own.
- At the same time, leave enough slack for normal operations when one owner is traveling, offline, or otherwise unavailable.
- For small teams,
2-of-3is usually a practical baseline because it resists one-key compromise while still allowing continuity if one signer is down. - For larger teams,
3-of-5is a common starting point because it raises the approval bar without making routine coordination unworkable. - Review the threshold whenever the team changes size, the treasury grows, or the approval process becomes operationally brittle.
- Replace owners through the on-chain proposal flow rather than by sharing keys or making out-of-band changes.
- First submit an
add_ownerproposal for the new signer and wait for it to pass so the threshold is still met during the transition. - Verify the new owner can sign a test transaction before removing the departing owner.
- After the replacement signer is confirmed, submit a
remove_ownerproposal for the old owner and wait for that proposal to execute. - If you also need to adjust quorum, use the
change_thresholdproposal flow as part of the same planned rotation.
- Immediately assume the compromised owner can approve pending or future proposals until removed.
- Use a quorum of the remaining uncompromised owners to submit and approve a
remove_ownerproposal for the exposed key as soon as possible. - Pause or cancel any pending proposals that the compromised owner already approved, then re-evaluate them after the owner set is cleaned up.
- If the threshold no longer makes quorum possible after the compromise, raise the issue operationally first and restore a safe threshold before resuming normal use.
execute (for Transfer proposals) and disburse_recurring are the only entrypoints that move funds. Both call an external token contract through token::Client, so a hostile or buggy token is the main interaction risk. This section records the review of those two paths and the argument for why they are safe. It is covered by the adversarial tests listed at the end.
execute, Transfer kind:
- Checks: auth,
Executorrole, not frozen, proposal isReady, time-lock elapsed, not in recovery mode, per-token spending limits. - Treasury balance check: for each token, the summed amount across all transfers is compared to the treasury balance via
try_balancebefore anything moves. - Interaction: each transfer via
try_transfer. - Effects: spent trackers, then
proposal.status = Executed, thenremove_active_proposal(active count), then theexecutedevent.
disburse_recurring:
- Checks: auth, not frozen, not in recovery, schedule status, due time, cap clamp, and every checked counter update (
total_disbursed,periods_disbursed), computed up front. - Treasury balance check via
try_balance. - Interaction: one
try_transfer. - Effects: spent tracker,
last_disbursed_at,total_disbursed,periods_disbursed, then therpayevent.
Both paths put the interaction before the effects, so they are not strict checks-effects-interactions. They are safe for the reasons below, not because of ordering alone.
- No re-entry. The Soroban host rejects a call into a contract that is already on the call stack. A token that calls back into
execute,disburse_recurring,approveor any other Accord entrypoint duringtransfermakes that inner call fail, which traps the token, which makes the outertry_transferreturn an error. The contract therefore never runs while its own state is half-updated, and no token can observe an executed-looking proposal before the transfer completes. The testsexecute_with_reentrant_token_is_rejected_and_preserves_stateanddisburse_recurring_with_reentrant_token_preserves_stateexercise this. - Failure is atomic. Every failure path returns
Err(TransferFailed)(or another error) from the entrypoint. A contract-call error reverts all storage writes made during that invocation, including writes made by the token. Because no Accord state is written before the transfer loop in either path, there is nothing to unwind, and in a multi-transfer proposal an earlier successful transfer is rolled back when a later one fails.execute_with_failing_second_transfer_reverts_the_firstchecks this. - No partial effects. The proposal is marked
Executedand removed from the active set only after every transfer has succeeded. A failedexecutetherefore leaves the proposalReadyand the active count unchanged, so it can be retried. A faileddisburse_recurringleavestotal_disbursed,periods_disbursedandlast_disbursed_atunchanged.
- Fixed: a token returning a non-unit value counted as success. The generated
try_transferreports a mistyped return value asOk(Err(_)), and the earlier code treated any outerOkas success. A token whosetransferreturnedfalsewithout moving funds would have letexecutemark a proposalExecutedanddisburse_recurringadvance a schedule with nothing paid. Both paths now go throughtransfer_succeeded, which accepts onlyOk(Ok(())). - Fixed:
executehad no balance check.disburse_recurringchecked the treasury balance first, butexecutewent straight totry_transfer.executenow checks the per-token total withtry_balancebefore any transfer, so a shortfall fails up front. A token whosebalancecall traps is also reported asTransferFailedinstead of aborting the call. - Confirmed: a failed
executedoes not mark the proposal executed or change the active count. Seeassert_execute_rejected_cleanlyin the tests. - Residual risk: a malicious token can lie. A token can report a large
balanceand return normally fromtransferwithout moving funds. The contract cannot detect this, and it trusts the token for what it moves. Proposers and approvers must only use tokens they trust. Recurring schedules validate that the token answersdecimals,symbolandname, which is a sanity check, not a trust check.
The tests in contracts/accord/src/test.rs use two mock tokens: AdversarialToken (traps, fails for a chosen recipient, re-enters execute or disburse_recurring, reports a configurable balance) and FalseReturnToken (returns false from transfer). They assert, for both execute and disburse_recurring, that each failure mode returns TransferFailed and leaves the proposal or schedule, and the active count, unchanged.
- A stolen owner key can still create and approve proposals up to the threshold gap before it is removed.
- There is no automatic on-chain emergency recovery; owner removal and threshold changes still require coordinated proposal approval.
- Token contracts are checked against the Soroban token interface at creation and execution, but interface checks cannot detect malicious behavior introduced by an upgrade between those events.
- A malicious owner can deliberately fill the active proposal cap and temporarily block new proposals from being created.
- Per-owner, per-token spending limits are available via
SetSpendingLimitproposals and are enforced when a transfer proposal is created, but they are opt-in: an owner with no configured limit for a given token remains unrestricted for it, so the threshold is still the primary access-control mechanism.
- Owner keys are kept secret and not shared. If that assumption fails, the multisig should be treated as compromised until the exposed owner is removed and any impacted proposals are reviewed.
- The Stellar RPC node used by the frontend returns accurate ledger state. If it lies or falls behind, the UI can show stale proposal status, incorrect balances, or misleading approval state.
- Token contracts used in proposals follow the Soroban token interface and are not upgraded to malicious code after proposal creation. If that assumption fails, execution can transfer an asset the owners no longer intended to trust.
RBAC, delegation and recurring payments are analysed in full, with code links and residual risks, in THREAT_MODEL_RBAC_DELEGATION_RECURRING.md.
The protocol's attack surface includes the following external entry points:
- Public Contract Functions:
create_proposal(and variants),approve,revoke,execute,cancel_expired, andupgrade. These are reachable by any actor via the Stellar network. - Guardian / Emergency Pause:
set_guardian,freeze, andunfreeze.set_guardianassigns or replaces the guardian address and requires a set of distinct owners whose combined voting weight reaches the current threshold to co-sign in a single call.freezecan be called only by the currently registered guardian and immediately blocks new proposal creation and all proposal execution.unfreezelifts the freeze and, likeset_guardian, requires distinct owners whose combined weight reaches the threshold. A misconfigured or captured guardian key can therefore halt the contract (a denial-of-service pause), but on its own cannot move funds, change the owner set, or change the threshold. - Concurrent Governance Proposals: Two governance proposals that each pass their own creation-time checks can be approved in parallel and executed back-to-back — for example two
RemoveOwnerproposals, or aRemoveOwneralongside aChangeThreshold. Because each creation-time check evaluates the owner set and total weight as they stand at creation, executing both together can move the owner-count or total-weight past the point where the threshold is still reachable, risking a multisig that can no longer reach quorum. - Role-Based Access Control (RBAC): Operational roles (
Proposer,Approver,Executor,Viewer) govern entrypoints, introducing distinct attack considerations: execution ofReadyproposals by non-owner executors, role concentration on single signers, and approver role-revocation lockout risks (see Role-Based Access Control Threat Model Addendum). - Frontend Wallet Connection: The boundary where the dApp interacts with browser-based wallets (Freighter). Malicious sites could attempt to intercept or spoof these calls.
- RPC Layer Boundary: The communication path between the frontend and a Stellar RPC node. A compromised node can feed dishonest state data to the user.
Accord relies on several explicit assumptions for its security properties to hold:
- Key Confidentiality: Owner private keys are not compromised or shared.
- Honest RPC Node: The RPC node used by the frontend and integrators returns accurate ledger state.
- Asset Integrity: Token contracts passed to the multisig are non-malicious and do not contain backdoors or non-standard callback logic.
- Soroban Platform: The underlying Soroban runtime correctly enforces
require_authand handles cryptographic verification.
Specific mechanisms are in place to enforce security boundaries:
- M-of-N Threshold Guard: The
executeandupgradefunctions enforce a strict approval count, preventing single-key fund transfers or governance takeovers. Threshold alone is not enough for upgrades: owners must still verify the WASM hash and follow the operational checks indocs/UPGRADE_SAFETY.md. - Proposal Deadlines: The
deadlinefield andderive_statuslogic ensure proposals cannot be executed indefinitely, protecting against stale approvals. - Active Proposal Cap: The
MAX_ACTIVE_PROPOSALScheck increate_proposalprevents an attacker from exhausting contract storage. - Owner Authorization: Approval calls (
approveandapprove_sponsored) require the named owner to authorize the exact invocation, and enforce owner membership plus the Approver role. A sponsor may pay the network fee but cannot supply or change the owner's authorization. - Optional Approved-Token Registry: When enabled, the registry is checked at proposal creation and again before transfer execution or recurring disbursement; governance changes emit events. With the default disabled setting, all interface-valid tokens remain allowed.
- Escrow Reservation Accounting: Proposal execution reserves escrow amounts against the contract's current token balance. Ordinary proposal payouts and recurring payments can spend only the unreserved balance; release and refund remove each escrow reservation exactly once.
- Weighted sensitive-action co-signing (audit conclusion):
set_guardian,unfreeze, andupgradereject duplicate addresses before summing approver weights, so a single owner cannot repeat its address to count its weight more than once. They intentionally accept the smallest distinct approver list whose combined weight reaches quorum; this may be one owner when that owner legitimately holds enough configured weight. This is acceptable only as an explicit weighted-governance outcome, and the default 50%ChangeOwnerWeightcap prevents granting a strict majority through a later weight-change proposal. Follow-up concern: initialization can establish a concentrated allocation, so deployers must review initial weights and quorum together; a future release could apply the cap at initialization too if that policy is required. - Guardian Emergency Pause: A guardian registered through
set_guardian(itself co-signed by owners whose combined weight meets the threshold) can callfreezeto immediately block all new proposal creation and execution the moment a compromise or incident is detected. This bounds the damage from a detected compromise: it buys time to remove an exposed owner key or investigate before any funds move, without granting the guardian any power to transfer funds or alter the owner set or threshold. Resuming normal operation requiresunfreeze, which again needs distinct owners whose combined weight reaches the threshold — so no single party, including the guardian, can both pause and silently resume the contract. - Execute-time invariant re-validation for weight changes: Governance proposals are validated when created but executed later, so a
ChangeOwnerWeightproposal re-checks at execution time that the resulting total weight would not leave any still-active (Pending/Ready) proposal un-quorumable — if any active proposal's snapshottedquorum_weightwould exceed the new total weight, execution is rejected withWouldBreakQuorum. Owner-removal is guarded at creation time (a removal that would drop remaining weight below the threshold is rejected up front bycreate_remove_owner_proposalwithWouldBreakQuorum), and threshold changes are validated against total weight at creation. Because those removal and threshold checks evaluate state at creation rather than the combined effect of several proposals executing together, operators should still sequence interacting governance proposals deliberately — approving and executing one before creating the next — so two concurrent removals (or a removal plus a threshold change) cannot combine to push the multisig below a reachable quorum. - Role Separation & Non-Owner Execution Invariants: Operational roles restrict who can propose, approve, or execute. The
executefunction requiresRole::ExecuteProposalbut cannot alter stored proposal parameters, requires un-revoked quorum approval evaluated strictly byderive_status, respects guardian freezes, and enforces timelocks and deadlines — ensuring non-owner executors act strictly as permissionless execution cranks without custody or discretionary authority.
Delegation layers temporary or indefinite voting weight transfers between owners. The contract guarantees the following mathematical and temporal invariants across all operations:
-
Conservation of Total Weight (Sum Invariant):
For any owner set and any sequence of active, expired, or revoked delegations:
$$\sum_{o \in \text{Owners}} \text{effective_weight}(o) = \text{total_weight}$$ Delegation transfers voting power strictly between valid owners without minting or destroying total weight. -
Bounded Effective Weight:
For every owner
$o \in \text{Owners}$ :$$0 \le \text{effective_weight}(o) \le \text{total_weight}$$ An owner's effective weight can never underflow or exceed the total multisig voting weight. -
Temporal Invariant (Expiry):
Delegations configured with an expiration timestamp
$T_{\text{expiry}}$ automatically lapse once the ledger timestamp satisfies$T_{\text{ledger}} \ge T_{\text{expiry}}$ . Expired delegations contribute zero weight to the delegate and deduct zero weight from the delegator. - Revocation Invariant: Undelegating or revoking a delegation immediately restores the delegator's effective weight and removes the delegate's incoming weight contribution.
-
Clean Removal Invariant:
Executing an owner removal via governance (
RemoveOwner) deletes all outgoing delegations from the removed owner and all incoming delegations to the removed owner from remaining owners. This restores the remaining delegators' raw weights and prevents dangling delegations from persisting or reviving upon re-addition.
The threat model above predates weighted voting, where every owner's approval counted as exactly one vote. Weighted governance introduces a new class of risk: voting power concentrating in a single owner, or a colluding group of owners, until the multisig stops functioning as a multisig in practice even though it still looks like one on paper.
A multisig's security rests on requiring several independent parties to agree before funds move or governance changes take effect. Weighted voting weakens that guarantee the moment a single owner's weight reaches the current threshold on its own: approve sums weight from whichever owners call it, and derive_status only compares that sum against quorum_weight (the threshold snapshotted at proposal creation) — nothing in that check cares whether the weight came from one owner or several. An owner whose weight is >= threshold can therefore approve and execute any proposal alone, turning an "M-of-N" multisig into a single point of failure in every way that matters: one compromised key is enough to move the treasury, exactly the scenario a multisig exists to prevent. This can arise either from an unbalanced initial weight distribution passed to initialize, or from weight-change proposals executed over time.
A related but distinct risk is gradual concentration through a sequence of individually-reasonable ChangeOwnerWeight proposals. Each such proposal is checked against the single-owner cap (below) only at the moment it is created and again at the moment it is executed — the check has no memory of earlier proposals and no visibility into whether the beneficiaries are acting independently or in concert. This means:
- A single owner cannot exceed the cap in any one step, because each
ChangeOwnerWeightcall re-derives the resulting total weight and re-checks the cap against it fresh (see Mitigations below) — there is no path for one owner to ratchet past the cap across multiple proposals, since every proposal is capped relative to the state it actually executes against. - A colluding group of owners, however, can each individually raise their own weight — or raise each other's — through separate
ChangeOwnerWeightproposals that every one of them, taken alone, satisfies the cap. Because the cap is evaluated per-owner against the total, not per-coalition, a group of colluding owners can accumulate a combined share ofTOTAL_WEIGHTwell past what any single one of them could reach unilaterally, with no individual proposal ever looking anomalous in isolation.
- Single-owner weight cap, enforced at both creation and execution:
create_change_weight_proposalcomputes the resulting total weight and rejects the proposal withSingleOwnerWeightCapExceededif the target owner's new weight would exceedMAX_SINGLE_OWNER_WEIGHT_PCT(50% by default) of that resulting total. Because weights and owner membership can still shift between a proposal's creation and its execution, the identical check runs again insideexecute'sChangeOwnerWeightbranch against the total weight as it stands at execution time, so a cap-violating outcome cannot slip through even if it would only have become a violation due to changes made after the proposal was created. - Zero-weight rejected in favor of explicit removal:
ChangeOwnerWeightvalidatesnew_weightagainstMIN_OWNER_WEIGHT..=MAX_OWNER_WEIGHTat both creation and execution, so it can never be used to silently zero out an owner's voting power. An owner who should no longer participate must instead go throughcreate_remove_owner_proposal, which is a distinct, explicit action visible in the proposal history as a full removal rather than an easily-overlooked weight edit — and which itself checks at creation time that removal cannot drop the remaining total weight below the threshold.
The single-owner cap bounds what any one ChangeOwnerWeight proposal can do to one owner's share — it does not bound the combined share a coalition of owners can reach across several proposals, and it does not prevent an unbalanced set of weights from being accepted at initialize in the first place (initialization has no equivalent per-owner cap). Deployers and owners reviewing incoming ChangeOwnerWeight proposals should evaluate each one's effect on the combined weight of any owners who might be acting in concert, not just the cap check the contract itself performs, and should review the initial weight distribution passed to initialize with the same scrutiny. See "Opt-in initialization weight review" in Residual Risks below.
Accord introduces an operational role-based access control (RBAC) layer on top of owner-weighted governance. While owner weights govern how much voting influence an account possesses, operational roles define what actions an account is permitted to take across the proposal lifecycle.
The RBAC implementation in contracts/accord/src/lib.rs categorizes permissions into four functional roles defined in the Role enum:
Proposer(Role::CreateProposal): Permits drafting new proposals via anycreate_*_proposalentrypoint (transfers, owner additions/removals, threshold changes, weight adjustments, recurring payments, spending limits, and role grants/revocations).Approver(Role::ApproveProposal): Permits voting on pending proposals viaapproveand revoking votes viarevoke.Executor(Role::ExecuteProposal): Permits triggering execution of proposals that have reachedReadystatus viaexecuteand cleaning up expired proposals viacancel_expired.Viewer: Reserved for read-only interface views and off-chain policy auditing; carries no transaction execution permissions.
The protocol relies on the following core trust assumptions regarding RBAC:
- Separation of Functional Duties: Operational responsibilities — drafting proposals, voting, and triggering execution — can be partitioned across different entities without granting treasury custody to operational drafters or automated execution bots.
- Ownership as the Sole Voting Anchor: Roles alone never grant voting power. The
approveandrevokeentrypoints require bothrequire_roleforRole::ApproveProposalandrequire_owner_and_weight. GrantingApproverto a non-owner address grants zero voting weight and cannot advance proposals toward quorum. - High-Privilege Administrative Actions Bypass Roles: Core security controls —
upgrade,set_guardian,freeze,unfreeze,migrate_to_weighted_governance,migrate_to_rbac, andset_max_single_owner_weight_pct— are not role-gated. They strictly enforcerequire_weighted_approvers, requiring a quorum of distinct registered owners whose combined weight reachesthreshold. An operational role compromise cannot directly hijack contract bytecode, change guardians, or alter thresholds. - Default Backward Compatibility: Initialization and migration (
migrate_to_rbac) grantDEFAULT_OWNER_ROLES(Proposer,Approver,Executor) to all registered owners. This maintains legacy M-of-N multisig behavior until roles are explicitly segregated.
Under Accord's RBAC model, the Executor role (Role::ExecuteProposal) can be assigned to an account that is not an owner — such as an automated keeper bot, a cron relayer, or an operational service.
Attack Scenario: An untrusted, compromised, or rogue non-owner executor attempts to drain funds, redirect payments, execute unauthorized proposals, or front-run multisig signers.
Why Executing an Already-Ready Proposal Is Low-Risk:
-
Immutable Proposal Payloads: When a proposal is created via any
create_*_proposalentrypoint, its parameters (token address, recipients, disbursement amounts, or governance changes) are serialized and committed to immutable contract storage. Theexecutefunction takes only(executor: Address, proposal_id: u64)and contains no mechanism for the caller to alter the proposal's contents, destination, or amounts. -
Strict On-Chain Status Verification (
derive_status): Insideexecute, the contract invokesderive_status. Execution immediately aborts withContractError::ThresholdNotMetorContractError::ProposalNotActiveunless the proposal evaluates strictly toProposalStatus::Ready. Proposals inPending,Executed, orRevokedstatus are rejected, and expired proposals evaluate toProposalStatus::Expiredand returnContractError::ProposalExpired. -
Authentic Owner Quorum Prerequisite: A proposal transitions to
Readystatus only when the cumulative voting weight contributed by registered owners holdingRole::ApproveProposalsatisfies$\text{approvals} \ge \text{quorum_weight}$ (the snapshot threshold recorded at creation). Because non-owners cannot cast approval weight, an executor cannot fabricate approvals. -
Dynamic Approval Revocation Window: Authorized approvers retain the right to call
revokeat any time prior to execution. If an owner detects an error or suspicious activity on aReadyproposal, revoking their approval immediately drops total approvals belowquorum_weight, reverting the status back toPendingand causing any subsequent call toexecuteto fail. -
Timelock Delay Enforcement: If a contract timelock delay is configured (
timelock_key),executeenforcesnow >= proposal.ready_at + time_lock_delay, guaranteeing owners a predictable review window to inspectReadyproposals before they can be executed. -
Guardian Freeze Backstop:
executechecksrequire_not_frozen(&env)?. In the event of an active incident or dispute, the guardian can callfreezeto immediately block all execution contract-wide, overriding any attempts by an executor. -
Deterministic Crank Property: The
Executorrole functions strictly as an unprivileged, non-custodial operational crank. It cannot initiate actions or alter outcomes; it merely finalizes transactions that have already achieved owner consensus.
Attack Scenario: A single address holds all three operational roles (Proposer, Approver, and Executor), which is the default configuration for all owners following initialization or migration.
Risks of Role Concentration:
-
Unilateral Proposal Lifecycle Execution: If an address holding all three roles also controls voting weight
$\ge \text{threshold}$ (a whale owner, or in low-threshold configurations such as 1-of-N), the multisig's checks and balances completely collapse. That single key can draft a malicious proposal, immediately approve it to reach quorum, and execute it within a single ledger block without review or opposition from other owners. -
Workstation Single Point of Failure (SPOF): Even when no single owner possesses sufficient weight to pass quorum alone, concentrating
Proposer,Approver, andExecutoron a single developer or operator machine concentrates risk. If that machine or signing key is compromised, an attacker can draft arbitrary proposals, vote with the owner's weight, and trigger execution scripts, eliminating defense-in-depth across the lifecycle.
Mitigations & Recommendations:
- Enforce Separation of Duties: Organizations should deliberately partition roles across distinct keys:
- Assign
Role::CreateProposal(Proposer) to operational team members or automated ingestion pipelines (governed by spending limits). - Restrict
Role::ApproveProposal(Approver) exclusively to hardware-wallet or air-gapped signing keys held by designated multisig owners. - Assign
Role::ExecuteProposal(Executor) to automated keeper bots or non-custodial relayers that possess no proposing or voting permissions.
- Assign
- Single-Owner Weight Cap (
MAX_SINGLE_OWNER_WEIGHT_PCT): The contract enforces a 50% default cap on any single owner's voting weight at both creation and execution ofChangeOwnerWeightproposals, preventing any single key from legitimately acquiring unilateral quorum via governance proposals. - Post-Migration Role Pruning: Deployers should treat
DEFAULT_OWNER_ROLESas an initial bootstrap state. Once dedicated proposers and execution keepers are operational, owners should submit and approvecreate_revoke_role_proposalcalls to stripProposerandExecutorfrom owner voting keys, reducing each owner key's blast radius to voting only.
Attack Scenario: Roles are granted and revoked via create_grant_role_proposal and create_revoke_role_proposal (executing ProposalKind::GrantRole and ProposalKind::RevokeRole). If Role::ApproveProposal is revoked from one or more owners such that the total voting weight of the remaining owners holding the Approver role falls below the active contract threshold:
the multisig enters a state of Stranded Quorum (Deadlock).
Impact:
- Because
approvestrictly requiresrequire_role(&env, &approver, Role::ApproveProposal), owners who have had their Approver role revoked cannot cast approvals. - If total remaining Approver weight is less than
threshold, no future proposal can ever accumulate enough approvals to reachProposalStatus::Ready. - In-flight
Pendingproposals may become permanently un-quorumable if their snapshottedquorum_weightcannot be met by remaining approvers. - Because role restoration itself requires passing a
create_grant_role_proposalthrough the standard proposal lifecycle, the multisig cannot self-heal via normal proposal execution once Approver weight drops below threshold.
Mitigations & Quorum-Safety Guards:
-
Operational Quorum-Safety Verification:
- Unlike
remove_owner(which programmatically validates that the resulting total weight remains$\ge \text{threshold}$ and rejects proposals that would leave active proposals un-quorumable withContractError::WouldBreakThreshold), role revocation proposals operate at the permission layer. -
Signer Invariant Check: Prior to creating or approving any
create_revoke_role_proposaltargetingRole::ApproveProposal, signers must verify:- The remaining Approver weight satisfies: $$ \left(\sum_{\text{owner} \in \text{Approvers}} \text{weight}(\text{owner})\right) - \text{weight}(\text{target_owner}) \ge \text{current_threshold} $$
- Active proposals remain quorumable: Ensure no
PendingorReadyproposals havequorum_weightexceeding the resulting Approver weight.
- If a planned Approver revocation would drop weight below threshold, operators must first lower the threshold via a
ChangeThresholdproposal before executing the role revocation.
- Unlike
-
Emergency Governance Fail-Safe (Role-Bypassing Recovery):
- Critical administrative recovery entrypoints do not depend on
Role::ApproveProposalor the proposal lifecycle:upgradeset_guardianunfreezemigrate_to_rbac
- These entrypoints bypass
require_roleentirely and validate multi-owner authorization viarequire_weighted_approversdirectly against the raw owner map. - If a multisig's proposal pipeline is deadlocked due to accidental Approver role revocation, registered owners can coordinate out-of-band to execute an emergency
upgradeusing direct co-signatures to deploy updated logic or restore role state, ensuring contract control is never permanently lost.
- Critical administrative recovery entrypoints do not depend on
The following risks are currently unmitigated by the protocol design:
- No SOS Recovery: If enough owner keys are lost, there is no emergency "recovery" mode; the funds remain locked if the threshold cannot be met.
- Opt-in Spending Limits: Per-owner, per-token spending limits exist via
SetSpendingLimitproposals and are enforced at proposal creation (against a fixed 30-day spending window). They are not applied by default, however: an owner with no configured limit for a token can still propose any amount up to the treasury balance once the threshold is reached, so unrestricted tokens remain gated only by the approval threshold. - The Validation Gap: A token contract could be upgraded to malicious code between a proposal's creation (where it is validated) and its execution.
- Opt-in initialization weight review:
initializeenforces per-owner weight bounds and rejects a mismatched owners/weights length, but applies no cap on how concentrated the initial weight distribution may be, and no cap prevents a colluding group of owners from accumulating a combined majority share through a sequence of individually-compliantChangeOwnerWeightproposals after deployment (see "Weighted Governance Threat Model Addendum" above). Both are currently the deployer's and owners' operational responsibility rather than an on-chain guarantee. - Role-Revocation Quorum Stranding: While owner removals strictly enforce
WouldBreakThresholdandWouldBreakQuorumat both creation and execution, revoking theApproverrole from owners relies on operational verification that remaining Approver weight meetsthreshold. If Approver roles are revoked belowthreshold, regular proposal progression stalls until restored by owners via an emergency upgrade or role re-grant.
Do not open a public GitHub issue for a security vulnerability.
To report a security issue:
- Navigate to the GitHub repository's Security tab.
- Click "Report a vulnerability" to open a private advisory.
- Describe the issue clearly: affected function(s), reproduction steps, and potential impact.
We aim to acknowledge reports within 72 hours and publish a fix within 14 days for confirmed Critical/High issues.
contracts/accord/src/lib.rs— the on-chain contract- Contract ↔ frontend integration (authentication bypass, event spoofing)
- Denial-of-service patterns that make funds permanently inaccessible
- Stellar protocol-level issues (report to Stellar Development Foundation)
- Frontend-only UI bugs with no on-chain consequence
- Issues in third-party libraries we depend on (report upstream)
Operational runbooks provide step-by-step procedures for handling critical security events and emergency operations:
- Incident Response Runbook: Severity classification, emergency containment (
freeze,enter_recovery), escalation paths, evidence preservation, and post-incident reviews. - Upgrade Rollback Runbook: Identifying flawed deployments, rolling back in-place WASM, and verifying state consistency.
- Key Rotation Runbook: Step-by-step replacement of owner keys and guardian address using on-chain governance.
- Runbooks Index: Comprehensive listing and operational prerequisites.