A draft program — in-scope assets, severity tiers, and (unfunded) reward
bands — is published in
docs/bug-bounty-program.md. It is scoped
to the Assets at Risk table, Trust Assumptions, and the admin-key
blast-radius table in this file.
This document describes the trust assumptions, assets at risk, and known
limitations of the intent_settlement contract ahead of a mainnet deployment.
It is intended for auditors and integrators.
| Asset | Where held | Value |
|---|---|---|
| Solver bonds | intent_settlement contract account (USDC) |
≥ 50 USDC per solver |
| User swap output | Solver's token balance (transferred in fill_intent) |
Unbounded |
| Protocol fees | FeeRecipient address |
0.05 % of filled volume |
| Admin privileges | Admin key in instance storage |
Full contract control |
By default the contract has no on-chain proof that a solver actually sent
tokens on the source chain. fill_intent trusts the solver to transfer
dst_token to the user on Stellar; the economic incentive not to default is the
bond slash (slash_solver takes 10 % of the bond permissionlessly once the fill
window expires).
Implication: A solver can still accept an intent, fail to fill, absorb the slash, and profit if the source-chain spread outweighs it. The proportional formula removes the old asymmetry where a large bond made a small-intent default disproportionately expensive (or a small bond made a large-intent default cheap); the bond size remains the primary economic deterrent.
Optional cryptographic gate (issue #190). An admin can call
set_proof_registry to point intent_settlement at a deployed ProofRegistry
(Wormhole-backed). A solver may then call
fill_intent(solver, intent_id, fill_amount, require_proof = true), which
cross-checks a verified source-chain deposit record before any token moves:
- no proof for the intent →
ProofNotFound; - proof's Wormhole
src_chain_id≠ mappedintent.src_chain→ProofChainMismatch; proof.src_amount < intent.src_amount→ProofAmountInsufficient;- registry not configured →
ProofRegistryNotSet.
Every failure path reverts before touching state, so the intent stays
Accepted and slash_solver remains the backstop (see
docs/129-proof-mismatch-fallback.md). This is opt-in and defaults off:
require_proof = false — the value every existing caller passes — reads no
registry and preserves the self-reported-fill behaviour above byte-for-byte,
mirroring how DstAllowlistEnabled defaults off. When the gate is used, trust
for that fill becomes cryptographic (Guardian quorum) + economic (bond) rather
than economic alone; residual risk is Guardian collusion, shared with all
Wormhole users.
A single Admin address controls:
pause/unpause— can halt all new swaps.set_fee_recipient— redirects fee and slash proceeds.transfer_admin— rotates the admin key (requires both old and new admin to sign).add_allowed_dst_token/set_dst_allowlist_enabled— controls which tokens users can target.
Implication: A compromised admin key can grief the protocol (pause, fee redirection) but cannot steal user funds or solver bonds directly, because no admin function transfers tokens out of user or solver custody. Key rotation requires dual authorization, reducing the single-key-failure surface.
By default, submit_intent accepts any dst_token address. If an admin
enables the allowlist (set_dst_allowlist_enabled(true)), only pre-approved
tokens may be used as destinations.
Implication (allowlist disabled): A user or solver could name a malicious
contract as dst_token. The risk lands on the user who submitted the intent —
they would receive tokens from a contract they did not vet. Enabling the
allowlist before mainnet is strongly recommended.
Implication (allowlist enabled): The allowlist is stored in instance storage, so adding/removing tokens is cheap but centralized under the admin key.
The fill window (FILL_WINDOW = 300 s) starts from accept_intent. The
deadline stored on-chain is now + 300. A solver and a block producer
colluding to manipulate ledger().timestamp() could extend the fill window
slightly, but Stellar's timestamp drift is bounded by consensus rules and is not
a meaningful attack surface in practice.
slash_solver and expire_intent are callable by anyone. This is intentional
— it prevents a solver from dodging accountability by waiting out a pause or
ensuring only friendly callers settle the intent. A griefing scenario where
someone repeatedly calls expire_intent on an already-expired intent is
defended by the IntentNotOpen guard (idempotent after first call).
The protocol publishes a public proof-of-reserves dashboard (see dashboard/ and docs/proof-of-reserves-dashboard.md) that reconciles on-chain solver bond totals against this Assets-at-Risk table. This is a transparency artifact, not an insurance guarantee — it verifies that collateral is on-chain but does not guarantee solver performance or user fund recovery.
The dashboard is continuously updated and independently reproducible (data sources and queries are published). Users should read this threat model and the FAQ in the dashboard for caveats.
The Admin address is the single most sensitive key in the protocol. It
controls pause/unpause, fee routing, the destination-token allowlist, the
source-chain allowlist, and admin rotation. A compromised admin key can halt
the protocol and redirect all fee and slash proceeds. The recommendations below
apply before and after mainnet launch.
Custody transparency: See docs/custody-transparency.md
for the current custody model (who holds the keys, single-key vs. multisig threshold, and last-verified date).
This document is updated operationally whenever keys are rotated.
| Deployment stage | Recommended setup |
|---|---|
| Testnet / staging | Developer EOA on a hardware wallet (Ledger/Trezor) |
| Mainnet launch | 2-of-3 or 3-of-5 multisig (e.g. Gnosis Safe equivalent on Stellar, or a Stellar multi-sig account with threshold set appropriately) |
| Long-term mainnet | Hardware-backed multisig with geographically distributed key holders; a minimum M-of-N threshold of M ≥ 2 |
A design for a multisig admin wrapper is tracked in
docs/114-multisig-admin-design.md.
Until that wrapper is deployed, at minimum the admin key must reside on a
hardware wallet — never a hot wallet or a key stored in plaintext.
- Rotate the admin key with
transfer_admin. Both the outgoing and incoming admin must sign the same transaction, so a typo'd address can never accidentally brick the contract. - After rotation, verify
get_admin()returns the expected new address on-chain before discarding the old key material. - Rotation should be rehearsed on testnet before any live key handover.
-
Hardware wallet required. The admin signing key must never exist only in software (e.g. a
.envfile, a CI secret, or an in-memory key). Use a Ledger, Trezor, or equivalent hardware device for all admin transactions. -
Separate key per environment. Testnet, staging, and mainnet must each have a distinct admin key. Reusing a key across environments means a compromise on a lower-trust environment immediately threatens mainnet.
-
Minimal admin transactions. Admin calls should originate from an air-gapped or dedicated signing machine. Never sign admin transactions from a machine used for general web browsing or software development.
-
Pre-launch checklist. Before enabling mainnet enforcement:
- Populate
add_allowed_src_chainwith every supported source chain. - Call
set_src_chain_allowlist_enabled(true). - Populate
add_allowed_dst_tokenfor every supported output token. - Call
set_dst_allowlist_enabled(true). - Confirm
get_admin()andget_fee_recipient()return the expected multisig addresses. - See
docs/pre-deploy-security-checklist.mdfor the full checklist.
- Populate
-
Monitor fee recipient.
set_fee_recipient(via the two-steppropose_fee_recipient/accept_fee_recipientflow) redirects all protocol fee and slash proceeds. Monitor thefee_recipient_proposedandfee_recipient_updatedevents on-chain; any unexpected proposal should be treated as a potential compromise. -
Pause is not a long-term solution.
pause()is an incident-response tool, not a substitute for fixing a bug. A paused contract still holds solver bonds; resolve the incident and unpause as quickly as safely possible to minimise disruption to solvers and users. -
Post-incident key rotation. Any time there is evidence that the admin key may have been exposed (phishing, malware, shoulder-surfing), rotate immediately via
transfer_adminand notify all registered solvers.
| Can do | Cannot do |
|---|---|
| Pause the contract (halt new swaps) | Transfer user funds directly |
Redirect future fees and slashes via propose_fee_recipient |
Drain solver bonds |
| Add/remove tokens from dst and src allowlists | Cancel or fill intents on behalf of others |
| Rotate itself to a new admin address | Bypass the solver bond slash (still permissionless) |
| Disable allowlist enforcement | Recover already-transferred tokens |
The blast radius of a compromised key is griefing and fee theft, not direct
fund theft. Solver bonds and in-flight intent outputs are only moved by
fill_intent, slash_solver, and rescue_tokens — none of which are callable
unilaterally by the admin without other protocol preconditions being met first
(except rescue_tokens, which is limited to non-bond, non-active-intent tokens).
- Cross-chain proof is opt-in. By default the contract does not verify the
source-chain transaction and trust is economic, not cryptographic. Issue #190
adds an admin-enabled
ProofRegistrygate (fill_intent(..., require_proof = true)) that makes a verified Wormhole deposit a precondition for the fill; until an operator configures it and solvers opt in, the economic-only model above still applies. - Single admin key. There is no multi-sig or timelock on admin operations. Protocol operators should secure the admin key with a hardware wallet or multi-sig wrapper before mainnet.
- Bond slash is proportional to intent size (issue #193).
slash_solverslashesmin(unfilled_output, bond) / 10— an amount scaled to the intent the solver failed to fill — capped at 10 % of the bond (so a well-matched bond is never punished harder than the old flat rate) and floored at 1 stroop (issue #32). Cross-token value comparison between the bond token and the destination token is still out of scope and assumes same-token comparability or an admin-set per-token minimum; a price-oracle-based comparison remains on the roadmap. - Intent re-open after slash. After
slash_solverthe intent is reset toOpen(orPartiallyFilled) with a fresh deadline. This is now bounded:ProtocolConfig.max_slash_cyclescaps how many times an intent can cycle throughOpen → Accepted → Slashedbefore it transitions to the terminalAbandonedstate instead of re-opening. - No allowlist by default. Until an admin calls
set_dst_allowlist_enabled(true), any token address — including malicious contracts — can be used asdst_token.
Previously: deregister_solver removed the SolverRecord entirely. A
re-registration by the same address would create a brand-new record with
last_slash_time = 0, fills_completed = 0, and fills_failed = 0. This
allowed a just-slashed solver to (a) bypass SLASH_COOLDOWN immediately by
deregistering and re-registering, and (b) wipe their entire fill history to
reset compute_reputation_score to a clean slate — defeating the reputation
system's value as a persistent, hard-to-game track record.
Fix (option b — preserve across the cycle): deregister_solver now writes
a ReputationSnapshot to DataKey::SolverReputation(address) before removing
the SolverRecord. register_solver reads that snapshot (if present) and
carries last_slash_time, fills_completed, fills_failed, and total_volume
forward into the new record, then removes the snapshot. A solver who was never
slashed and has no prior fills sees identical behaviour to before (no snapshot
→ zero-initialised fields).
Why option b over option a (block deregistration during cooldown)? Blocking deregistration would add friction to legitimate solver exits that happen to coincide with a recent slash — for example, a solver winding down operations after a network disruption. Preserving the fields instead closes both the cooldown-bypass and the reputation-reset exploits without restricting the deregistration path at all.
Previously: withdraw_bond checked only that the post-withdrawal balance
stayed at or above cfg.min_bond, without checking whether the solver had
active_intents > 0. A solver holding one or more Accepted intents could
withdraw their bond all the way down to the flat min_bond floor, leaving
those already-accepted intents backed by collateral far below the
get_adjusted_min_bond value that was required to accept them in the first
place. This directly undermined the bond's purpose as collateral for in-flight
obligations.
Example: A 500 USDC bond can normally accept multiple intents, each
individually validated at accept-time against get_adjusted_min_bond (e.g.,
150 USDC per intent). The solver could immediately call withdraw_bond to
pull out everything down to min_bond (as low as 50 USDC), leaving those
already-accepted intents backed by 50 USDC total — 67 % under-collateralized
relative to what was required to accept them.
Fix (conservative bound): withdraw_bond now checks if record.active_intents > 0
before allowing any withdrawal, mirroring deregister_solver's stricter
all-or-nothing precedent: a solver with any active intents cannot withdraw at
all. This is a fail-safe, conservative bound; a more precise per-intent
collateral-reservation model (informed by issue #60's enumerable-intents view)
remains a future enhancement but is not necessary for correctness.
Trade-off: A solver who accepts an intent but immediately regrets it must wait for that intent to be slashed or expire before withdrawing further bond. This is correct behaviour — it prevents gaming the bond floor and ensures accepted intents never lack the collateral that was promised at accept-time.
Vortex Protocol offers a security bug bounty program for findings in the intent_settlement and proof_registry contracts. See docs/bug-bounty-program.md for severity tiers, reward structure, and submission process.
When a P1 incident occurs on mainnet (unexpected pause, admin key transfer, fee recipient change, or rescue_tokens invocation), the protocol publishes a postmortem within 5 business days of resolution per docs/incident-postmortem-template.md (issue #301). Postmortems include timeline, root cause, impact assessment, and preventive actions tracked as follow-up issues.
Exception: If the root cause involves a not-yet-fully-patched vulnerability, an initial postmortem may be published with technical details redacted, followed by a full postmortem within a defined safe-harbor period (typically 30 days).
Please do not open a public GitHub issue for security vulnerabilities. Follow the responsible-disclosure process described in the org-level SECURITY.md.
REAL_MONEY=false. No payout is authorized until treasury infrastructure
is funded. Responsible disclosure still follows the process in this
document.