| Version | Status |
|---|---|
main branch |
Actively maintained |
| Tagged releases | Bug fixes only (critical severity) |
Do NOT open a public GitHub issue for security vulnerabilities.
Use GitHub's private vulnerability reporting on this repository. Reports are visible only to maintainers and create a private fork for developing the fix.
Include the following:
- Description: A clear description of the vulnerability, including affected components.
- Reproduction steps: Minimal steps to reproduce, including any relevant contract addresses or transaction IDs.
- Impact assessment: Your estimate of the exploitability and potential impact (funds at risk, scope, etc.).
- Suggested fix (optional): Any ideas on how to remediate.
| Stage | Target |
|---|---|
| Acknowledgement | Within 48 hours |
| Severity assessment | Within 5 business days |
| Fix timeline communicated | Within 7 business days |
| Critical fix deployed | Within 14 days of confirmed severity |
We follow coordinated disclosure:
- We ask reporters to allow us 90 days to resolve the issue before public disclosure.
- We will publish a full incident report after resolution.
- Responsible reporters will be credited by name (or alias) in the security advisory, unless they prefer anonymity.
A formal bug bounty program will be announced when Phase 2 contracts are deployed on mainnet. Until then, we offer:
- Public acknowledgement for confirmed vulnerabilities
- Invitation to the
security-contributorsGitHub team SFORGEtoken allocation for critical findings (discretionary, at core team's determination)
All completed audit reports are published in docs/audits/ as they become available.
The handover for the Phase 1 external audit (scope, build reproduction, trust model, invariants and deliberate design choices) is docs/audits/external-audit-handover-phase1.md.
A Stage 2 Phase 1 Hardening and Audit Follow-up (#127). Roles such as issuer, reporter and admin were each documented where they are declared, scattered across five contracts and their ADRs. This section gathers every privileged role in one place: what it can do, what it is trusted not to do, and which test proves the limit holds. It does not introduce any new behavior; every power and limit listed here already exists in code, cited by file and line.
All five contracts share one default deployment assumption, recorded here once rather than in every row below: the same key is typically the admin of all four non-governance contracts at deploy time (ADR-002), so compromising that one key compromises every role it holds simultaneously. Production deployments should use distinct admins, or a multisig, per contract.
| Role | Granted by | Powers | Limits |
|---|---|---|---|
| Admin | Constructor (admin: Address, contracts/rwa-asset/src/lib.rs:100); rotated by transfer_admin (:455-466) |
set_issuer (:121), set_paused (:433), update_metadata (:440), set_compliance (:477), transfer_admin |
Cannot mint, burn, or move any holder's balance directly. Cannot lower max_supply below circulating supply, raise or remove a cap once set, or change decimals after issuance (InvalidSupplyCap/DecimalsImmutable, :440-454; tests test_update_metadata_cannot_*). Cannot set a compliance min_level above MAX_COMPLIANCE_LEVEL (InvalidComplianceLevel, #139; test test_set_compliance_rejects_a_min_level_above_the_scale). transfer_admin requires both the outgoing and incoming admin's signature (dual auth, NFR-S-4); test test_transfer_admin_without_the_incoming_signature_is_rejected. |
| Issuer | Granted or revoked by the admin via set_issuer (:121-131); any number of issuers may hold the role at once |
mint (:142, with its own issuer.require_auth()) |
Cannot approve itself: set_issuer is admin-only. Minting still must pass compliance screening on the recipient when configured (require_compliant, :530+), and respects max_supply. |
| Holder | Implicit; anyone the issuer mints to | transfer, approve, burn, transfer_from/burn_from as the authorizing party |
Every transfer requires both parties to satisfy the configured compliance min_level when one is set (require_compliant); a holder's own burn is explicitly allowed to work even after their compliance status lapses, so an exit never depends on remaining verified (test test_burn_is_allowed_after_verification_lapses, compliance contract). |
| Role | Granted by | Powers | Limits |
|---|---|---|---|
| Admin | Constructor; rotated by dual-auth transfer_admin (contracts/compliance/src/lib.rs:124-137) |
set_kyc (:61), revoke_kyc (:74), transfer_admin |
Cannot set a KYC record's level above MAX_LEVEL (3), an expires_at already in the past, or a jurisdiction that is not two ASCII uppercase letters (InvalidLevel/AlreadyExpired/InvalidJurisdiction, IR-08/SF-2026-010). This is the highest-risk role in the protocol by design: a compromised compliance admin can mark any address compliant on every asset that points at this engine, and (before IR-04's fix) could not be rotated out at all; dual-auth transfer_admin is the mitigation now in place. |
| Asset admin (external, per asset) | Any rwa-asset deployment that calls set_compliance(Some(this_engine), min_level) |
Decides whether and at what level this engine's records gate transfers on their asset | Cannot alter the engine's own KYC records; only reads screen/is_compliant. Can disable screening entirely for their asset by calling set_compliance(None, _) (their own admin power, not this engine's). |
| Role | Granted by | Powers | Limits |
|---|---|---|---|
| Admin | Constructor; rotated by dual-auth transfer_admin (contracts/registry/src/lib.rs:184-197) |
register (:79), set_active (:159), transfer_admin |
register does not verify that the registered address is a deployed rwa-asset or that asset_class matches that asset's real metadata (IR-16/SF-2026-018): a registry entry is an assertion by the admin, not independent verification, and the README says so explicitly. active is a label only; nothing in the protocol enforces it (same finding). list_assets pages at most MAX_PAGE_SIZE (100) addresses per call regardless of admin input (PageTooLarge). |
| Role | Granted by | Powers | Limits |
|---|---|---|---|
| Admin | Constructor; rotated by dual-auth transfer_admin (contracts/governance/src/lib.rs) |
transfer_admin |
Holds no power over propose/vote/finalize themselves in Phase 1: governance is advisory and executes nothing (see the contract's own doc comment), so there is no privileged action for this admin to gate yet. |
| Proposer | Implicit; any address, no grant needed | propose |
Bounded voting_period_ledgers (one day to 90 days, IR-14) and a title capped at MAX_TITLE_BYTES. A proposer declares their own vote weight with nothing checking it against a balance in Phase 1, by design: reading a live balance at vote time would let a holder vote, transfer the same tokens onward, and vote again from the recipient. Correct weighting needs balances snapshotted at proposal creation, deferred to the phase that introduces SFORGE. |
| Voter | Implicit; any address | vote, once per proposal (AlreadyVoted) |
No quorum floor exists yet: a single vote can pass a proposal with no minimum participation, since Phase 1 governance gates nothing. The PRD names minimum quorum requirements and an optimistic timelock as the mitigation once execution hooks exist (Phase 3+). |
| Role | Granted by | Powers | Limits |
|---|---|---|---|
| Admin | Constructor; rotated by dual-auth transfer_admin |
add_asset (capped at MAX_ASSETS, test test_add_asset_stops_at_max_assets), set_max_deviation, set_reporter, override_price, transfer_admin |
override_price skips only the per-report deviation check; positivity, the future-timestamp check, and append-only history still apply (ADR-005). This is the widest admin power of any Phase 1-2 contract: unlike every other admin role above, this one can directly move a live price with no deviation bound at all, more than any single reporter can. ADR-005 names the mitigation directly: "the admin of a NAV feed has the same power over a vault's share price as the issuer has over supply, so it should be a multisig and, from Phase 3, governance." |
| Reporter (per asset) | Granted or revoked by the admin via set_reporter, one grant per asset |
report for that one asset only |
Each report is bounded by that asset's configured max_deviation_bps; a reporter authorized for one asset cannot move any other asset's price (NotReporter otherwise). The deviation limit bounds each report individually, not cumulative drift over many reports: ADR-005 notes a compromised reporter can still compound this over several ticks within the limit each time. |
Findings are recorded here once fixed. Every entry below was found during internal review while the protocol was pre-deployment, so no funds were ever at risk, and each is covered by regression tests. SF-2026-003 onward come from the Phase 1 internal security review, whose IDs are given in brackets.
transfer wrote the debit and the credit as two independent storage writes.
When from == to both resolved to the same DataKey::Balance entry, so the
credit overwrote the debit and the holder gained amount from nothing.
total_supply was left untouched, so the sum of balances silently diverged
from the reported supply, and the call could be repeated without limit.
transfer_from carried the identical flaw, reachable by any spender holding
an allowance.
Fixed in 49e3087 and d3252f7. Regression tests:
test_self_transfer_does_not_create_tokens,
test_self_transfer_from_does_not_create_tokens, plus
test_supply_invariant_holds_across_operations, which asserts
sum(balances) == total_supply across a full operation sequence.
No entry point extended the time-to-live of the entries it wrote. Soroban archives entries whose TTL lapses, and an archived entry cannot be read until restored, so a holder who did not transact for long enough would have found their balance unreadable and transfers involving them failing. The contract instance, holding the admin address and pause flag, was subject to the same expiry.
Fixed in 848feda. Policy is centralised in contracts/common/src/storage.rs
and shared by every Phase 1 contract: write paths extend to the network maximum
TTL, read paths stay pure. Since protocol 23 an archived entry is restored
automatically by the transaction that touches it, so the residual exposure is a
restore fee for a holder idle longer than the maximum TTL, not a failed
transfer. State rent means that exposure cannot be removed, only reassigned;
ADR-001 records who pays and why.
The directory index was a single Vec<Address> held in one persistent entry,
and every register rewrote the whole vector. Each address adds 40 bytes, so
at about 1,600 assets the entry crossed the 64 KiB contract-data limit. From
then on every register failed, and without an upgrade path (ADR-003) nothing
could recover the contract in place. NFR-P-3 requires 10,000 assets.
Fixed in 70b761a. Each asset now has its own AssetAt(index) entry, and
list_assets(start, limit) pages at most 100 addresses per call. Regression
tests: test_directory_scales_past_the_former_entry_size_ceiling (2,000
assets, under mainnet limits), and test_directory_scales_to_the_nfr_p_3_size
(10,000 assets, run with --ignored).
update_metadata checked the new metadata only in isolation. The admin could
change decimals after issuance, which resizes every holder's position by a
power of ten, lift a cap back to uncapped, or set a cap below circulating
supply. None of these changes emitted an event.
Fixed in a1c73c2. decimals is now immutable. A cap can move, but never
below total_supply and never back to 0. Regression tests:
test_update_metadata_cannot_change_decimals,
test_update_metadata_cannot_lift_a_cap,
test_update_metadata_cannot_cap_below_circulating_supply.
Only holder operations and set_paused published events. Admin handover,
issuer grants, metadata changes, disabling compliance, KYC decisions, registry
changes and every governance action left no trace for monitors, which
violated NFR-A-1 and NFR-A-3.
Fixed in 8147f6d. Every state-changing entry point now publishes an
event, and each wire format is pinned by a test in its contract's suite.
Only rwa-asset exposed transfer_admin. The admin of compliance,
registry and governance was fixed at deployment for good. A compromised
compliance key could mark any address permanently compliant on every
dependent asset, and could never be rotated out.
Fixed in 2021ed5. All four contracts now have dual-authorization
transfer_admin. Regression tests in each:
test_transfer_admin_without_the_incoming_signature_is_rejected, plus a
test showing that a rotated-out admin loses its powers.
update_metadata and transfer_admin wrote storage without extending its
TTL. That is the SF-2026-002 policy, missed in two places. Since protocol 23
the consequence is a restore fee rather than a failure.
Fixed in a1c73c2. Regression test:
test_admin_write_paths_extend_what_they_write.
Writes are built with a 180-second validity window, but the SDK stopped polling
after about 30 seconds and reported a still-pending transaction as failed. A
caller that retried built a new transaction while the original could still be
included, so a mint or transfer could execute twice. TRY_AGAIN_LATER and
DUPLICATE submissions were also treated as pending.
Fixed in db1677e. Polling now covers the validity window plus 30 seconds. An
unsettled transaction ends in TransactionExpiredError, which is safe to
retry, or TransactionOutcomeUnknownError, which carries the hash to check
first. Regression tests are in sdk/tests/client.test.ts, under "RwaAssetClient
write submission".
toStroops read "1.2.3" as 1.2 and "0x10" as hexadecimal, truncated
precision beyond the asset's decimals, and accepted numbers that had already
lost precision. fromStroops rendered 100 whole tokens at zero decimals as
"0.1". hexToBytes32 turned non-hex digits into zero bytes. Any of these
could put a wrong amount into a transfer, or a wrong document hash on chain.
Fixed in c0c0543. The helpers now reject malformed input instead of guessing at
it. Regression tests are in sdk/tests/utils.test.ts.
None of these gave a path to funds. Each is fixed, and the internal review records the detail and the regression tests.
| ID | Review | Finding | Fixed in |
|---|---|---|---|
| SF-2026-010 | IR-08 | set_kyc accepted out-of-range levels, past expiries and malformed jurisdictions (low) |
e01ff66 |
| SF-2026-011 | IR-09 | The token surface diverged from SEP-41, and allowances never expired (low) | 0e7b905 |
| SF-2026-012 | IR-10 | The Rust toolchain was unpinned, so builds were not reproducible (low) | d1f0ad7 |
| SF-2026-013 | IR-11 | Dependency review never ran, and the SDK's npm dependencies were never audited (low) | d1f0ad7 |
| SF-2026-014 | IR-12 | CI trusted a mutable action tag and an unverified CLI download (low) | d1f0ad7 |
| SF-2026-015 | IR-13 | deploy.sh would deploy placeholder metadata to mainnet (low) |
0c91166 |
| SF-2026-016 | IR-14 | propose accepted a zero voting period and unbounded titles (low) |
2f736c0 |
| SF-2026-017 | IR-15 | A self-transfer_from emitted a phantom Transfer event (info) |
0e7b905 |
| SF-2026-018 | IR-16 | Registry entries were not documented as unverified (info) | 4f04c60 |
| SF-2026-019 | IR-17 | Constructor require_auth was asserted by no test (info) |
d9fc624 |