Security considerations for trustbridge-contract.
Related docs: README · ARCHITECTURE · DEPLOYMENT · ADMIN_RUNBOOK
| Threat | Mitigation |
|---|---|
| Impersonation (registering someone else's GitHub username) | stellar_address.require_auth() — only the address owner can register |
| Unauthorized removal | caller must auth as registrant or admin |
| Unauthorized admin-only actions | admin.require_auth() on get_all_registered, get_registered_paginated, set_role, pause/unpause |
| Unauthorized verification actions | caller.require_auth() on verify / revoke_verification; caller must be the admin or hold Role::Verifier (checked via has_role_or_admin) — any other caller gets NotAuthorized |
| Double initialization | AlreadyInitialized error |
| Admin storage mutated after init | No public setter writes ADMIN_KEY — only initialize does, gated by AlreadyInitialized (Issue #97) |
| Malformed or oversized username input | InvalidUsername error, checked before auth and before any write |
| Unicode / homoglyph username spoofing | Byte-wise ASCII validation rejects all non-ASCII bytes; see Unicode Rejection Policy section |
| Consecutive-hyphen username bypass | InvalidUsername error — consecutive hyphens now enforced on-chain |
| Counter drift from rejected calls | Invariant property fuzzing, see REGISTRY_INVARIANTS |
| Stale trust surviving a remove → re-register cycle (a new registrant inheriting the previous owner's verified status or address binding) | remove unconditionally clears the stored record; register on a removed username always starts a fresh, unverified record — see Re-registration After Remove (Issue #93) |
| Compromised or unpinned RPC client dependency | Crate validation checklist below |
| Compromised Verifier key silently revoking payout eligibility | Role::Verifier and Role::Revoker are distinct — Issue #212 |
| Admin force-removing a name without a grace period | Challenge flow enforces on-chain resolve_after delay — Issue #214 |
Off-boarded contractor or bot key lingering as a live Verifier/Revoker forever |
Optional per-grant expiry via set_role_with_expiry, checked lazily by get_role — see Role Expiry |
Stolen or transferred GitHub account staying verified (and payout-eligible) indefinitely |
config_verification's expires_in now drives an actual expiry, checked via is_verification_active — see Time-Bounded Verification |
Single compromised admin key wiping a large slice of the registry in one batch_remove call |
Above a configurable size threshold, batch_remove requires a second, distinct admin-equivalent signature — see Dual-Control batch_remove |
Auditor trusting an unbound, unsigned export_registry.sh JSON dump |
export_attestation binds a page to a SHA-256 digest, version, and ledger for offline comparison — see Signed Export Attestation |
| Concern | Responsibility |
|---|---|
| GitHub identity proof | Admin verification workflow + TrustBridge dashboard |
| Username squatting policy | Social/process layer; contract allows first-come registration |
| Admin key compromise | Operational security; use multisig for admin address |
| GitHub username changes | Off-chain mapping updates; may require re-registration |
A sibling contract (for example, a payout contract) may call this registry
cross-contract. src/version.rs exposes CROSS_CONTRACT_READ_MIN_VERSION /
Version::supports_cross_contract_reads() so such a caller can assert
compatibility the same way it does for batch_verify.
These functions are read-only (no .set / .remove) and require no
authorization beyond the standard pause guard where noted, so a calling
contract can invoke them with no signature from the registry admin or any
registrant:
| Function | Notes |
|---|---|
get_address(github_username) |
Core identity lookup |
has_record(github_username) |
Cheap existence check |
get_record_proof(github_username) |
Light-client existence proof |
get_public_paginated(cursor, limit) |
Paginated read; returns Paused while the registry is paused |
get_stats() |
{ total, verified, ever_verified } |
get_verified_count() / get_ever_verified_count() |
Live / monotonic counts |
get_role(address) |
RBAC lookup, e.g. to gate a payout on Role::Verifier |
is_paused() / is_contract_paused() |
Pause-state check |
is_registration_in_cooldown(github_username) |
Cooldown-window check |
version() / is_compatible(major, minor, patch) |
Version handshake |
max_username_len() / is_username_valid(...) / usernames_match(...) |
Pure validation helpers |
The canonical copy of this list lives in docs/ABI.md §
Cross-Contract Read Interface; keep the two in sync.
get_all_registered, get_registered_page, and get_registered_paginated
each call admin.require_auth() before touching storage. A cross-contract
invocation executes in the calling contract's authorization context — it
cannot supply the registry admin's signature — so these calls fail auth when
invoked from another contract, regardless of the caller's identity or how the
test environment mocks auth. This is deliberate: the registry has no
"trusted contract" allowlist, so a widened export surface would let any
deployed contract exfiltrate the entire registry, not just an intended
consumer. A sibling contract that needs a bulk export must go through the
admin's own off-chain tooling.
This is enforced by tests, not left as a comment:
tests/integration.rs::cross_contract_caller_cannot_run_get_registered_paginated,
…_get_registered_page, …_get_all_registered, and
cross_contract_public_read_surface_is_reachable (the positive control), plus
src/version.rs::test_export_paginated_requires_admin_auth.
The admin address is immutable after initialize unless rotated via the
two-step admin transfer flow introduced in Issue #195.
Admin rotation is a propose → delay → accept flow. There is never a window with two live admins:
- Propose — the current admin calls
propose_admin_transfer(new_admin, delay_seconds). The proposal is written to chain andAdminTransferProposedEventis emitted. During the delay the current admin remains the only admin. - Observe / cancel — watchers have
delay_secondsto detect and respond. The current admin may callcancel_admin_transfer()at any point before execution to abort the proposal (emitsAdminTransferCancelledEvent). A secondpropose_admin_transfercall overwrites the first — useful for correcting a typo during the window. - Accept — after the delay elapses, the proposed new admin calls
execute_admin_transfer(caller)and signs.ADMIN_KEYis atomically rotated: the old admin'sRole::Adminentry is removed and the new admin receives it.AdminTransferExecutedEventis emitted.
Threat-model properties:
- A compromised admin key can only propose a transfer, not execute it — the
candidate address must also sign
execute_admin_transfer. - Self-transfer is not explicitly blocked (the admin may rotate to themselves with a delay), but produces no effective change.
- The zero/burn address is rejected at proposal time (
ZeroAddress). - Pause during a pending proposal does not affect the proposal storage — the
delay timer continues. However,
execute_admin_transferchecksrequire_not_paused, so execution is blocked while paused. get_admin_transfer()exposes the pending proposal for monitoring.
Before Issue #195, ADMIN_KEY was set once in initialize with no
transfer API. Rotation meant redeploying a new instance. The immutable-admin
design and its regression tests are preserved: test_double_initialize_rejected_after_successful_init,
test_issue_97_second_initialize_rejected_with_different_admin, and
test_issue_97_admin_unchanged_across_unrelated_operations continue to pass.
Recommendations:
- Use a multisig or smart account as the admin G-address
- Set a meaningful
delay_seconds(e.g. 86400 = 24 h) to give watchers time to detect unexpected proposals - Never commit private keys or seed phrases
- Monitor
AdminTransferProposedEventin your indexer
The attestation flow (attest_upgrade → upgrade) is optionally enforceable
on-chain via set_attestation_required(true).
attestation_required |
No attestation published | Attestation matches | Expired / mismatch |
|---|---|---|---|
false (default) |
Upgrade proceeds (unattested) | Upgrade proceeds (attested) | AttestationExpired / UnattestedWasm |
true |
AttestationRequired (code 20) |
Upgrade proceeds (attested) | AttestationExpired / UnattestedWasm |
Setting required = true means:
- A hot admin key cannot swap the WASM binary in a single step — it must first publish the hash via
attest_upgrade, wait for watchers, then callupgradewith the same hash. - Clearing attestation (
clear_attestation) and then callingupgradefails withAttestationRequired. - The cooldown still applies independently.
The is_attestation_required() read exposes the current config for monitoring and client-side enforcement.
Threat scenarios:
- Compromised admin key attempts silent upgrade:
upgradefails withAttestationRequiredunless an attestation for that exact hash was published first (observable on-chain by watchers). - Attacker publishes a forged attestation: They still need admin auth on
attest_upgrade, so a compromise of the admin key is the prerequisite — the same threat as before, but now visible before the swap. - Attestation expired before upgrade:
AttestationExpiredis returned; the stale record is cleared. The admin must re-attest with a newexpires_at.
stage_wasm(caller, hash) writes the intended next binary to the on-chain
staged slot before the upgrade transaction is submitted. Any observer can
call get_staged() (no auth) to confirm what is coming.
When a hash is staged, upgrade and execute_upgrade both reject a
mismatching hash with StagedWasmMismatch (code 34). With nothing staged,
both paths are unaffected.
How this differs from attestation:
Attestation (attest_upgrade) |
Staged slot (stage_wasm) |
|
|---|---|---|
| Expiry | Yes (expires_at) |
No (permanent until cleared or consumed) |
| Required flag | Optional (set_attestation_required) |
Advisory — never blocks if absent |
| Error on mismatch | UnattestedWasm (code 13) |
StagedWasmMismatch (code 34) |
| Who reads it | Anyone | Anyone |
Use both together for maximum transparency: stage the hash first (public notice, no expiry), then attest it (time-bounded, optionally required).
The propose_multisig_upgrade → approve_upgrade (×N) → execute_upgrade
flow adds on-chain multi-party authorisation for WASM upgrades.
Properties:
get_upgrade_threshold()is the required number of distinct approvals (default1— existing single-admin behavior, no breaking change).- The proposer counts as one approval; additional
approve_upgradecalls add to the total. execute_upgradeis blocked until the delay elapses and approvals ≥ threshold.- An attacker who steals one key cannot execute an upgrade if
threshold ≥ 2— they would need to compromise M distinct key-holders simultaneously. - Any admin may
cancel_upgrade_proposalat any time, including while paused. - At most one proposal is live at a time.
Scope: This flow covers upgrade proposals only. Other admin functions remain single-admin at the contract level. For full multi-party admin governance, use a Stellar multisig account as the admin address (recommended in any case — see Admin Key Management).
See ADMIN_RUNBOOK.md — Multisig Upgrade Flow for paste-ready CLI recipes and the complete error reference.
When pause mode is active, guarded entry points fail with
ContractError::Paused (code 7). This avoids partial-wave behavior where
some state writes continue while others are frozen.
Paused function matrix:
| Function | Behavior while paused |
|---|---|
register |
Rejected with Paused |
remove |
Rejected with Paused |
verify |
Rejected with Paused |
revoke_verification |
Rejected with Paused |
upgrade |
Rejected with Paused |
migrate |
Rejected with Paused |
set_role / remove_role |
Rejected with Paused |
Allowed while paused:
| Function | Behavior while paused |
|---|---|
get_address, has_record, get_record_proof, get_stats, get_verified_count, get_ever_verified_count |
Allowed read-only lookups |
get_public_paginated |
Allowed (Issue #294). Public read — an indexer/dashboard must keep syncing during a pause. The require_not_paused gate that used to sit here was removed. |
get_all_registered, get_registered_page, get_registered_paginated |
Allowed, but require admin auth — see the conformance matrix in ARCHITECTURE.md |
get_health, is_paused, is_contract_paused, get_pause_reason, version, get_version, is_compatible |
Allowed status and compatibility reads |
pause, unpause, set_paused |
Allowed admin controls for freeze lifecycle |
The full, test-enforced list of public reads that must succeed while paused is
the conformance matrix in
ARCHITECTURE.md,
checked by test_conformance_public_reads_available_while_paused in
tests/integration.rs.
- Registering a username requires the Stellar address owner to sign
- Sponsored registration via
register_sponsoredrequires both the sponsor's signature (sponsor.require_auth()) and the registrant's signature (stellar_address.require_auth()).[!IMPORTANT] Why the sponsor cannot skip address auth: If a sponsor could register a username on behalf of a Stellar address without that address owner's signature, a malicious sponsor could link a victim's GitHub username to a different Stellar address they control, hijacking their future rewards/payouts. Requiring the registrant's signature ensures self-auth protection is never bypassed.
- Re-registration with a new address resets verification status
- There is no on-chain proof of GitHub ownership at registration time — verification is a separate admin step
- Wave #49 locks the address-update invariant: after a verified username is
re-registered to a different Stellar address, the record becomes unverified,
the verified count decreases, and any later
verify()applies to the new address only.
The contract enforces per-username action rate-limiting during register():
- When
cooldownis non-zero,register()checks whetheris_in_cooldown()is true forgithub_username. - If the configured cooldown window has not elapsed since the username's last mutating action,
register()fails withCooldownActive(code 8). - Upon a successful
register(), the username's last action timestamp is updated viaset_last_action().verify()and the username/address-change paths stamp it the same way. - First-time registrations have no recorded prior action timestamp (0), allowing initial registration to succeed immediately.
Enforcement is inline and automatic. There is no public record_action
entry point. An earlier build exported an unauthenticated
record_action(github_username) timestamp setter; because it required no auth,
any caller could push an arbitrary username into cooldown and block its
registration. It was removed in Issue #296. is_registration_in_cooldown()
remains as the read-only view of the enforced state.
Because Soroban handles GitHub registrations permissionlessly (first-come, first-registered), there is a risk of username squatting (someone registering another contributor's GitHub username to redirect their rewards). TrustBridge uses a multi-layered security model to mitigate this risk.
Registration alone does not grant payout readiness. Payout systems and the TrustBridge dashboard require a contributor record to be verified before rewards can be disbursed.
- Verification is performed by the contract admin or a designated verifier after confirming ownership of the GitHub account off-chain (e.g., via OAuth or a cryptographic proof).
- The verifier validates that the registered Stellar address matches the authenticated GitHub user.
- If a squatter registers a name, they cannot pass this verification gate since they cannot prove ownership of the corresponding GitHub account.
If a user registers a username and later needs to transfer it to a different Stellar address, the contract requires both of the following to authorize the transaction:
- The new Stellar address.
- The currently registered Stellar address. This prevents a third party from maliciously taking over a registered username.
If a rightful owner discovers that their GitHub username has been squatted on-chain:
- Report: The owner reports the dispute to the TrustBridge administrators (off-chain).
- Revocation/Removal: The admin verifies the owner's identity, then calls
removeto delete the squatter's record from the contract registry. - Re-registration: The rightful owner registers their correct Stellar address.
- Re-verification: The admin verifies the new record.
- Will they receive my payouts? No. Payouts require the record to be verified. The squatter cannot pass the admin verification check.
- How do I reclaim my username? Open a support ticket / dispute with the TrustBridge administrators. They will remove the squatter's record so you can register your address.
- Does the contract verify my GitHub handle automatically? No. There is no on-chain verification proof of GitHub identity at registration time. Verification is entirely off-chain/administrative.
GitHub logins are case-insensitive: Alice and alice are the same GitHub
account. Before this fix, persistent storage keys were built from the raw
input string, so registering alice and later Alice created two
independent records — a squatter could register a case variant of an
already-registered, already-verified username and siphon future payouts sent
to that variant, since the contract had no way to know the two strings named
the same account.
The fold. Every persistent key namespaced by a username —
(reg, username), (chllng, username), (pend_rev, username),
(lastact, username), (pendrot, username), and the flat/chunked enumeration
index — is built from utils::canonicalize_username, which ASCII-lowercases
the input (crate::storage's private canon helper wraps it at the point
every key is constructed). The fold is byte-wise and ASCII-only: only
b'A'..=b'Z' bytes are lowered, so it can never change the byte length of an
already-ASCII-validated username, and it never attempts a Unicode-aware case
fold. register already rejects non-ASCII usernames outright
(is_valid_github_username), so every key this fold ever runs on is pure
ASCII by construction.
What this closes. Alice, ALICE, and alice all resolve to the same
underlying record: registering any case variant of an existing login updates
that same record rather than creating a new one, so the existing
double-auth transfer protection (old.stellar_address.require_auth()) applies
to a case-variant "takeover" attempt exactly as it would to a same-case
re-registration. Lookups (get_address, has_record, remove, verify,
revoke_verification, …) resolve the same way regardless of the casing the
caller passes in.
Canonical vs raw. Storage, the enumeration index, and every paginated
export (get_registered_paginated, get_public_paginated,
get_all_registered) all report the canonical (lowercased) form of a
username, since that is what the storage key actually is. Domain events
(RegisteredEvent, VerifiedEvent, …) still carry the raw string exactly
as the caller submitted it in that call, for display/audit fidelity —
indexers that need to correlate an event against export/lookup data should
fold the event's username themselves before comparing.
Explicitly out of scope (tracked as separate work): Unicode case folding
(IDNA/UTS46-style normalization) and homoglyph detection (e.g. Cyrillic
а vs ASCII a) — see Unicode Rejection Policy below, which already
rejects all non-ASCII bytes outright, so homoglyph substitution cannot reach
the storage layer in the first place.
Migration note for a deployment with existing mixed-case keys. This
contract has not been deployed to mainnet (see Audit Status below), so no
runtime migration function ships with this fix. If a future deployment ever
needs to reconcile pre-existing Alice/alice-style duplicate records, the
recommended procedure is fully off-chain and uses tooling that already
exists:
- Walk the full registry with
get_registered_paginated(admin-gated) orget_public_paginated, paging untilhas_more == false. - Group the exported
(github_username, ContributorRecord)pairs bycanonicalize_username-equivalent key (i.e. ASCII-lowercased) off-chain. Any group with more than one entry is a pre-fix duplicate. - For each duplicate group, an operator decides the surviving record —
typically the
verifiedone, or the one with the earliestregistered_atif none is verified — following the existing dispute process in Username Squatting Mitigations above. removeevery losing entry in the group (admin-authorized;batch_removefor more than a handful), then confirm viahas_recordthat only the canonical key remains before re-verifying the survivor if needed.
Because canonicalization happens inside set_record/get_record themselves,
once a deployment is running this fix a fresh registration can never
recreate a duplicate — the reconciliation above is a one-time cleanup for
data written before the fix, not an ongoing concern.
register validates the username before require_auth() and before any
storage write. The order matters: a malformed call is rejected at the cheapest
point, no signature is spent on it, and no counter or index entry moves.
| Rule | Value |
|---|---|
| Length | 1 to 39 characters (GitHub's own cap) |
| Allowed characters | a-z, A-Z, 0-9, -, _ (ASCII only) |
| First and last character | Must be alphanumeric |
| Consecutive hyphens | Not allowed (foo--bar is rejected) |
| Unicode / non-ASCII | Rejected — see Unicode Rejection Policy below |
Rejection returns InvalidUsername (code 7).
Validation lives in src/utils.rs and works entirely on a fixed 64-byte stack
buffer. The contract is #![no_std], so the validation path never allocates
and the copy length is bounded before the copy happens.
Deliberate non-goals:
- Underscores are accepted even though GitHub disallows them, so any registration made before validation existed stays readable and removable. Tightening this later would strand those records.
- Case is not normalized on-chain.
Aliceandaliceare distinct keys. Off-chain workflows should match witheq_ignore_ascii_casefromsrc/utils.rswhen comparing a registration against a GitHub identity. - No on-chain proof the username exists on GitHub. Validation checks shape, not ownership. Ownership remains the admin verification step.
GitHub usernames are ASCII-only. Any username containing a non-ASCII byte —
including multi-byte UTF-8 sequences for accented letters (é, ü, ñ), emoji,
CJK characters, or Cyrillic/Arabic/Hebrew script — is rejected with
InvalidUsername.
Unicode homoglyph attacks are a recognized impersonation vector. An attacker registers a username that looks visually identical to a legitimate user's name but uses different Unicode codepoints:
- Cyrillic 'а' (U+0430) looks like ASCII 'a' (U+0061)
- Greek 'ο' (U+03BF) looks like ASCII 'o' (U+006F)
- Cyrillic 'с' (U+0441) looks like ASCII 'c' (U+0063)
A username like аlice (Cyrillic 'а' + ASCII 'lice') appears indistinguishable
from alice in most fonts, but encodes as [0xD0, 0xB0, 0x6C, 0x69, 0x63, 0x65]
instead of [0x61, 0x6C, 0x69, 0x63, 0x65]. Without byte-level validation,
this becomes a credential spoofing attack.
Validation is byte-wise, not glyph-wise:
- Every username is copied into a fixed stack buffer (64 bytes).
- Every byte is checked with
.is_ascii()(returns false for bytes > 0x7F). - Any multi-byte UTF-8 sequence has a leading byte ≥ 0x80, which fails the ASCII check and is immediately rejected.
This makes the homoglyph attack impossible: even if the rendered glyphs look identical, the byte sequences differ and only the ASCII form is accepted.
The following are all rejected (see comprehensive tests in src/utils.rs):
| Category | Example | Codepoint | UTF-8 Encoding |
|---|---|---|---|
| Latin-extended | café |
U+00E9 é | [0xC3, 0xA9] |
| Emoji | user😀 |
U+1F600 | [0xF0, 0x9F, 0x98, 0x80] |
| CJK (Chinese/Japanese/Korean) | 中user |
U+4E2D 中 | [0xE4, 0xB8, 0xAD] |
| Arabic | مuser |
U+0645 م | [0xD9, 0x85] |
| Hebrew | אuser |
U+05D0 א | [0xD7, 0x90] |
| Cyrillic homoglyph | аlice |
U+0430 а | [0xD0, 0xB0] |
| Greek homoglyph | bοb |
U+03BF ο | [0xCF, 0xBF] |
src/utils.rs includes a dedicated test suite for the Unicode rejection policy
(Wave #69 / Issue #70):
test_unicode_latin_extended_rejectedtest_unicode_emoji_rejectedtest_unicode_cjk_rejectedtest_unicode_arabic_and_rtl_rejectedtest_unicode_homoglyph_attack_rejectedtest_unicode_all_non_ascii_rejectedtest_unicode_embedded_at_any_position_rejectedtest_raw_high_byte_rejectedtest_valid_ascii_still_accepted_after_unicode_hardening
These tests confirm that every form of non-ASCII input — whether a visually distinct character like an emoji or a deceptive homoglyph like Cyrillic 'а' — is caught and rejected, while every valid ASCII username shape remains accepted.
Beyond the baseline homoglyph tests in src/utils.rs, tests/homoglyph_corpus.rs
provides an exhaustive test corpus covering additional confusable attack vectors:
Comprehensive lookalike coverage:
- Cyrillic: 23 homoglyphs including а, е, о, р, с, х, у (U+0430–U+0443)
- Greek: 20 homoglyphs including α, ο, ν, ρ, τ (U+03B1–U+03C7)
- Latin extended: 16 diacritic variants (á, é, ñ, ü, ç, etc.)
Invisible characters:
- Zero-width joiner (U+200D), zero-width non-joiner (U+200C)
- Zero-width space (U+200B), word joiner (U+2060)
- Soft hyphen (U+00AD), invisible operators (U+2061–U+2063)
Bidirectional text attacks:
- Left-to-right / right-to-left marks (U+200E, U+200F)
- Bidi embedding and override controls (U+202A–U+202E)
- Directional isolates (U+2066–U+2069)
Advanced confusables:
- Full-width Latin forms (U+FF21–U+FF5A, used in Japanese text)
- Mathematical alphanumeric symbols (U+1D400–U+1D7FF, bold/italic/script variants)
- Superscripts, subscripts, and modifier letters
Documented guarantee: "We reject all non-ASCII." Every codepoint above U+007F
is blocked before it reaches storage, regardless of how it renders. The corpus
tests validate this property against 78+ known confusable characters and ensure
no bypass path exists at the register() entry point.
Run the full corpus: cargo test homoglyph or cargo test unicode
The check adds no allocations and no UTF-8 decoding overhead. It is a per-byte scan over a stack buffer, the same cost profile as the existing alphanumeric and hyphen checks.
- Off-chain tooling (dashboard, indexers) should canonicalize and validate usernames against the GitHub API before submitting them for registration. The on-chain check is a last line of defense, not a substitute for pre-submission validation.
- If GitHub's own username policy changes (e.g. to allow certain Unicode ranges), this validation will need to be relaxed via a contract upgrade and a corresponding audit of the new attack surface.
Every off-chain component that talks to this contract, including the deploy scripts, the dashboard sync job, and any indexer, reaches the network through an RPC client crate. That crate sits between operator keys and the network, so it is in the trust boundary and gets reviewed like contract code.
| Check | How |
|---|---|
| Version is pinned exactly | soroban-client = "=x.y.z" in Cargo.toml, Cargo.lock committed for binaries |
| No known advisories | cargo audit and cargo deny check advisories |
| License is acceptable | cargo deny check licenses |
| No unexpected transitive additions | cargo tree --duplicates and review the lockfile diff |
| Source is the official crate | Confirm the repository field points at the upstream Stellar org, not a fork |
| Registry integrity | cargo verify-project; do not use [patch] or git dependencies for release builds |
| Maintenance signal | Recent releases, open advisories, and responsiveness on upstream issues |
A dependency bump that changes the transitive graph needs the lockfile diff in the PR. Reviewers should be able to see every crate that was added.
- TLS enforced. Reject plain
http://RPC URLs outside of local development. - No secret logging. Secret keys, seed phrases, and signed transaction envelopes must never reach logs, error strings, or telemetry.
- Bounded retries. Retry with exponential backoff and a hard attempt cap, so an outage degrades instead of turning into a self-inflicted flood.
- Explicit timeouts. A client with no timeout turns an RPC stall into a hung deploy job holding an operator key in memory.
- Response validation. Treat RPC responses as untrusted input: check the contract ID, network passphrase, and ledger sequence before acting on them.
- Simulation before submission. Simulate state-changing calls first so a malformed username or an auth failure surfaces without spending fees.
| Failure | Expected behavior | Operator action |
|---|---|---|
| Horizon or RPC outage | Client retries with backoff, then fails loudly. Contract state is unaffected: nothing was submitted. | Fail the job, alert, retry later. Never fall back to an unverified RPC endpoint. |
| RPC rate limiting (HTTP 429) | Backoff honors Retry-After where present. |
Reduce poll frequency, batch reads, or move to a dedicated RPC provider. |
| Invalid env configuration | scripts/deploy.sh refuses to run without ADMIN. Every invoke-* and bindings Makefile target refuses to run without CONTRACT_ID, and invoke-init also requires ADMIN. |
Fix the value rather than exporting a placeholder. NETWORK defaults to testnet, so a mainnet job must state NETWORK=mainnet explicitly. |
| Auth or permission failure | require_auth() panics the invocation and the whole transaction rolls back. Admin-only calls by a non-admin return NotAuthorized. |
Confirm the signing key matches the registrant or the admin address. |
| Partial write during failure | Not possible. Soroban transactions are atomic, and validation runs before the first write. | None. |
| 100+ contributor scale | get_all_registered is a linear full-index scan and grows with registry size. |
Prefer event indexing (see EVENT_INDEXING.md) over repeated full exports. Watch the export benchmark in ABI.md for regressions. |
Contributor onboarding depends on register, so fee spikes or budget
exhaustion are treated as availability risks.
Budget thresholds (current defaults in Makefile):
- CPU instructions:
25_000_000max - Memory bytes:
300_000max
The guard measures two inputs:
- Baseline username (
octocat) - Stressed username (maximum allowed username length)
Run locally:
make bench-register-budgetOverride thresholds when updating the baseline:
make bench-register-budget REGISTER_BUDGET_CPU_MAX=26000000 REGISTER_BUDGET_MEM_MAX=320000Failure output identifies which sample exceeded the budget using
input=baseline or input=max_username_len.
- Reduce writes in
register(avoid unnecessary index/counter touches). - Keep username handling bounded (
MAX_USERNAME_LEN) and avoid extra string copying. - Re-run
make bench-register-budgetand compare against prior output before raising thresholds. - If threshold changes are unavoidable, document rationale in PR notes and update deployment/operator docs accordingly.
Copy .env.example and fill every value explicitly. Configuration rules:
- No implicit network default in production scripts.
NETWORKmust be stated. ADMINis required for mainnet deploys and is not inferred from the local keystore.- Never commit
.env. Only.env.exampleis tracked.
The registry maintains two parallel state values that must always agree:
| State | Storage key | Updated by |
|---|---|---|
COUNT_KEY — registration counter (u32) |
instance storage | register (increment), remove (decrement) |
INDEX_KEY — ordered username vec (Vec<String>) |
instance storage | add_to_index (append), remove_from_index (filter) |
Invariant: get_count(env) == get_index(env).len() at every quiescent point between transactions.
Both values are read by different callers for different purposes:
- Paginated export endpoints (
get_registered_page,get_registered_paginated,get_public_paginated) walkINDEX_KEYfor the actual usernames but exposeCOUNT_KEYas thetotalfield of the response. If they diverge, a client that usestotalto compute page counts will request the wrong number of pages. get_statsreturnsCOUNT_KEYdirectly. Monitoring and dashboard tooling that readsget_statsto show a contributor count will display a wrong number if the counter has drifted.- An index longer than the counter indicates phantom entries — the index holds usernames that the contract believes do not exist. An index shorter than the counter indicates invisible entries — the counter says more contributors exist than are reachable by any export. Both are security-relevant for an audit.
register and remove always update both values in the same transaction:
register (new username):
set_count(get_count + 1)
add_to_index(username) ← appends to INDEX_KEY
remove:
remove_record(username)
remove_from_index(username) ← filters INDEX_KEY
set_count(get_count - 1)
Soroban transactions are atomic, so a partial write that updates one side but not the other cannot leave the invariant broken at rest — either both updates land or neither does.
tests/integration.rs includes a dedicated invariant test suite:
| Test | What it checks |
|---|---|
test_index_invariant_holds_on_empty_registry |
Invariant holds at genesis (count=0, index.len()=0) |
test_index_invariant_holds_after_single_register |
Invariant holds after the first registration |
test_index_invariant_holds_after_register_and_remove |
Invariant holds after removing first, middle, and last entries |
test_index_invariant_holds_after_same_address_reregister |
Re-register to same address does not double-increment counter |
test_index_invariant_holds_after_address_change_reregister |
Re-register to different address does not alter total |
test_index_invariant_holds_at_scale |
Register 10, remove 5 interleaved — check after each removal |
test_index_invariant_unchanged_on_failed_remove |
Failure path: remove on unknown username returns NotRegistered and does not mutate state |
test_index_invariant_unchanged_on_invalid_register |
Failure path: invalid username returns InvalidUsername and does not mutate state |
test_index_invariant_holds_after_remove_then_reregister |
Remove then re-register restores count=1, index.len()=1 |
test_index_invariant_unchanged_by_pause_unpause |
Pause/unpause does not touch count or index |
The helper storage::index_length_invariant_holds(env) encodes get_count == get_index().len() in one place so every test asserts the same invariant without repeating the definition inline.
- Removal of a non-existent username returns
NotRegisteredbefore any write, so count and index are never touched on a failed remove. - Invalid username on register is caught before
require_authand before any write, so count and index are never touched on a rejected registration. - Re-registration (same username, same or different address) follows the
existing.is_some()branch inregister, which does not calladd_to_indexor increment the counter, preserving the invariant. - 100+ contributors: both
COUNT_KEYandINDEX_KEYlive in instance storage. At very large registry sizes theget_all_registeredexport hits the 100-ledger-entry footprint limit; use paginated endpoints instead, but the invariant is unaffected by which export endpoint is used.
Persistent entries on Stellar mainnet have a time-to-live (TTL). If entries expire, data may become unavailable until extended.
Operational teams should:
- Monitor entry TTL via RPC
- Run periodic TTL extension via Stellar CLI (
stellar contract extend) - Document extension cadence in deployment runbooks
The batch_remove function provides an efficient way to clean up multiple registrations in one transaction. It introduces specific security considerations:
Unlike single remove, which allows a registrant to self-remove, batch_remove is strictly admin-only. A registrant attempting to remove their own record via batch_remove will receive NotAuthorized. The batch_remove surface is for administrative cleanup, not self-service.
A batch call does not revert if a single username fails to be removed (e.g., if it was already removed or was never registered). Instead, the failure is tallied in the returned BatchSummary, and the transaction continues. This ensures that one disputed or stale record does not grief an entire cleanup batch. The transaction only aborts if the caller lacks authorization, the contract is paused, or the batch size limit is exceeded.
To prevent a malicious or erroneous caller from exhausting the network CPU/memory budget in a single transaction (and causing an out-of-gas panic that masks partial success), batch_remove enforces a strict maximum batch size (configured via BatchConfig). Submitting a list of usernames larger than this cap immediately reverts the transaction with InvalidBatchSize.
Dashboard operators and auditors need the full failure surface of verify and revoke_verification spelled out.
The matrix below covers every unauthorized and invalid state transition. Each cell maps to an automated
unit test in src/lib.rs (search for #114).
Cross-reference: remove auth negative matrix (Issue #113) · ABI reference
| # | Scenario | Expected error | Code | Test |
|---|---|---|---|---|
| V1 | Contract not yet initialized | NotInitialized |
2 | test_verify_negative_not_initialized |
| V2 | Username not registered | NotRegistered |
4 | test_verify_negative_username_not_registered |
| V3 | Username already verified (double-verify) | AlreadyVerified |
5 | test_verify_negative_already_verified |
| V4 | Caller has no role | NotAuthorized |
3 | test_verify_negative_no_role_caller |
| V5 | Role::Upgrader holder |
NotAuthorized |
3 | test_verify_negative_upgrader_cannot_verify |
| V6 | Admin caller (happy path) | Ok(()) |
— | test_verify_positive_admin_can_verify |
| V7 | Role::Verifier holder (happy path) |
Ok(()) |
— | test_verify_positive_verifier_role_can_verify |
| V8 | Contract is paused | Paused |
7 | test_verify_negative_paused |
| # | Scenario | Expected error | Code | Test |
|---|---|---|---|---|
| R1 | Contract not yet initialized | NotInitialized |
2 | test_revoke_negative_not_initialized |
| R2 | Username not registered | NotRegistered |
4 | test_revoke_negative_username_not_registered |
| R3 | Record not yet verified | NotVerified |
6 | test_revoke_negative_not_verified |
| R4 | Caller has no role | NotAuthorized |
3 | test_revoke_negative_no_role_caller |
| R5 | Role::Upgrader holder |
NotAuthorized |
3 | test_revoke_negative_upgrader_cannot_revoke |
| R6 | Admin caller (happy path) | Ok(()) |
— | test_revoke_positive_admin_can_revoke |
| R7 | Role::Verifier holder (happy path) |
Ok(()) |
— | test_revoke_positive_verifier_role_can_revoke |
| R8 | Contract is paused | Paused |
7 | test_revoke_negative_paused |
caller == admin → allowed
caller has Role::Verifier → allowed
caller has Role::Upgrader → NotAuthorized (code 3)
caller has no role → NotAuthorized (code 3)
Both functions require a caller: Address argument so the contract can call
caller.require_auth() and enforce the role check in a single auditable step.
Only the admin and any address granted Role::Verifier via set_role may
call these functions.
The verify function additionally guards against illegal state transitions:
- Verifying an unregistered username →
NotRegistered(code 4) - Re-verifying an already-verified username →
AlreadyVerified(code 5)
The revoke_verification function guards:
- Revoking from an unregistered username →
NotRegistered(code 4) - Revoking from a username that was never verified →
NotVerified(code 6)
A Verifier or Revoker key — legitimately issued, or compromised — can call
verify, revoke_verification, and batch_verify as fast as it can submit
transactions. During a Wave this bloats the contract event stream and consumes
instruction budget for every other caller. Before this control, pause was the
only brake, and batch_verify made the problem worse because one call could
touch up to MAX_WRITE_BATCH records.
An on-chain counter caps the number of verify/revoke units a single non-admin actor may spend per ledger:
| Path | Units charged |
|---|---|
verify |
1 (charged after auth, before any state read — junk usernames still count) |
revoke_verification |
1 |
batch_verify |
usernames.len() — the requested size, before dedup/skip, charged atomically |
- Key:
(Symbol("vfyrate"), actor_address)in persistent storage, value(ledger_seq, units_spent). - Ledger rollover: the first call in a new ledger observes a stale
ledger_seqand resetsunits_spentto 0. There is no cross-ledger carry and no unbounded growth — a single entry per actor, overwritten in place. - Exceed → reject whole, write nothing: the charge is computed before the
write; if it would push this ledger's spend past the cap, the call returns
VerifyRateLimited(code 30) having mutated no state (no record, no counter, no event). - Configurable:
set_verify_limit(limit)(admin-only).limit == 0disables the check. With no configured value the contract usesDEFAULT_VERIFIES_PER_LEDGER(20).get_verify_limit()is a public read.
The admin is never rate-limited. Callers check is_admin_caller first and
the admin path never reaches charge_verify_rate. Incident response — mass
revoke after a detected fraud, mass verify to clear a backlog — must not be
throttled by the same mechanism that throttles a griefer. An admin key is
already the maximum-trust key in the system; adding a rate limit to it would
only create a denial-of-service against recovery.
- Reads are never rate-limited. Only the mutating verify/revoke paths carry the counter.
- Off-chain / RPC-level rate limiting is out of scope for the contract.
verify() and revoke_verification() are, today, "admin said so": the admin
or a Role::Verifier holder calls verify, and the contract trusts that they
checked GitHub ownership off-chain. There are no proof bytes anywhere in that
path. (This is distinct from the attestation-hash flow in
Two-step WASM upgrade, which binds a WASM
hash for upgrades, not an identity claim.)
src/oracle_proof.rs adds the missing signature-check primitive:
set_oracle_allowlist(env, admin, pubkeys)— admin-gated; replaces the set of Ed25519 public keys whose signatures are accepted.get_oracle_allowlist(env)— public read of the current allowlist.OracleProof { oracle_pubkey, message, signature, expires_at }— a signed attestation from an off-chain oracle.verify_with_proof(env, proof)— checks, in order: the signing key is on the allowlist (NotAuthorizedif not, including an empty allowlist), the proof has not expired (expires_at == 0disables the check — production callers should always set a real value), and finally thatsignatureis a valid Ed25519 signature overmessageunderoracle_pubkey.
Invalid proof fails. An unallowlisted key or an expired proof returns
Err(ContractError::NotAuthorized). An allowlisted key with a signature that
does not verify traps the host invocation instead of returning Err —
soroban_sdk's Env::crypto().ed25519_verify has no non-panicking form, so
this fails the exact same way an invalid require_auth() signature already
does everywhere else in this contract. Tests in src/oracle_proof.rs cover
both failure shapes with a fixed test keypair (valid_allowlisted_proof_passes,
non_allowlisted_key_is_rejected, expired_proof_is_rejected,
tampered_signature_traps).
This is a signature-check interface, not GitHub verification. The
contract has no way to talk to the GitHub API and this change does not give
it one — see Out of Scope above, which still applies unchanged. What an
oracle signs, and how it constructs message (e.g. binding a specific
github_username + stellar_address + expiry into the signed bytes so a
proof can't be replayed against a different subject), is an off-chain
contract between the oracle operator and whoever consumes its proofs; it is
not parsed or enforced by verify_with_proof itself.
Explicitly out of scope:
- Wiring a valid
OracleProofintoverify()/batch_verify()as an alternative to admin/Role::Verifierauth. This module ships the primitive and its tests; integrating it into the verification entry points is separate follow-up work. - Running a production GitHub oracle service that produces these proofs.
register() carries no replay nonce. That is only a gap if a sibling
contract can call register cross-contract (C2C) on a caller's behalf —
without a nonce, a captured call could be replayed. Today that path does not
exist, deliberately:
registerrequiresstellar_address.require_auth().- A cross-contract invocation runs in the calling contract's
authorization context (the same rule documented above for the admin export
functions in Cross-Contract Callers and Admin Exports).
A calling contract cannot produce a signature for an address it does not
control, so it cannot satisfy
stellar_address.require_auth()for anyone but itself. - A relayed C2C
registertherefore fails closed at the host authorization layer — the invocation aborts before any state read or write.
This is enforced by test, not left as a comment:
tests/cross_contract_register_deny.rs::cross_contract_register_on_behalf_of_another_address_is_denied
(expects a panic — missing auth), with
direct_register_by_the_address_owner_still_works as the positive control
proving the same call path succeeds for a real, self-signing registrant.
ABI statement: register is not part of the cross-contract read surface
documented in ABI.md § Cross-Contract Read Interface
and must not be added to it without also adding a replay nonce. Per this
project's "don't half-open C2C" policy, no C2C registration surface is
introduced here — this section documents and tests the current, intentional
denial only. If C2C register is ever built, it must ship a nonce (or
equivalent replay protection) in the same change, not after.
If you discover a security vulnerability:
- Do not open a public GitHub issue
- Email the maintainers or use GitHub Security Advisories on the repository
- Include steps to reproduce, impact assessment, and suggested fix if available
We aim to acknowledge reports within 72 hours.
Wave #39: before an audit or a testnet/mainnet promotion, validate a fresh
deploy against Futurenet to catch threat-model regressions early (e.g. an
initialize gate that silently no-ops, or a lookup that leaks state before
verification).
- Deploy to Futurenet:
ADMIN=G... ./scripts/futurenet_smoke_test.sh - Confirm
get_statsreports{total: 0, verified: 0}on the fresh instance — a nonzero result means the deploy reused stale storage. - Confirm
has_recordreturnsfalsefor an unregistered username — this guards the "no on-chain proof of GitHub ownership" boundary called out above by verifying reads don't fabricate positive results. - Re-run after any change to
initialize,register, or storage key layout, since those are the surfaces the threat model above depends on.
The script is a deploy sanity check, not a substitute for cargo test
(see src/lib.rs and tests/integration.rs for functional coverage).
The verify and revoke_verification functions are admin-only in the CLI documentation. The authoritative examples below use --source = admin. A non-admin caller (including a registrant) receives NotAuthorized and the transaction reverts.
# Verify a contributor (admin must sign)
stellar contract invoke --id $ID --source admin --network testnet --send=yes \
-- verify --caller G... --github-username octocat
# Revoke verification (admin must sign)
stellar contract invoke --id $ID --source admin --network testnet --send=yes \
-- revoke_verification --caller G... --github-username octocatIf a non-admin address attempts either call, the transaction fails with NotAuthorized:
# This will fail — registrant cannot self-verify
stellar contract invoke --id $ID --source registrant --network testnet --send=yes \
-- verify --caller G... --github-username octocat
# Error: NotAuthorized (code 3)Do not construct CLI examples that imply a registrant can self-verify. The contract rejects such calls at the auth layer.
Prior to this change, Role::Verifier could both verify and
revoke_verification. A single compromised key could therefore silently strip
payout eligibility from any contributor.
Role::Verifier — may only call verify.
Role::Revoker — may only call revoke_verification.
Admin — can still do both.
Migration for live deployments: Existing Role::Verifier holders keep their
verify permission unchanged. If an operator previously relied on a Verifier to
also revoke, assign that address Role::Revoker via set_role.
Admin force-remove was previously instant and irreversible. A legitimate registrant could lose their name with no recourse.
start_challenge(caller, github_username) places the name in a locked state for
DEFAULT_CHALLENGE_DELAY_SECS (48 hours). During this window:
- Re-registration by anyone other than the current owner is blocked.
- The current registrant may still
removetheir own record, which clears the challenge atomically — they proved ownership by signing. complete_challengeis gated behind the delay. Calling it beforeresolve_afterreturnsChallengeNotResolvable.
After the delay, the admin calls complete_challenge, which removes the record
and emits both RemovedEvent and ChallengeCompletedEvent.
cancel_challenge is the escape hatch: if the registrant proves ownership off-chain
during the window, the admin cancels the challenge and the registration is preserved.
register accepts an address change when both the outgoing and incoming
addresses sign. Dual auth proves both keys were available at that moment — it
does not prove the holder intended the change. An attacker who phishes a
GitHub session and a wallet signature together holds both keys at once, and the
swap lands instantly, redirecting every future payout before the real holder
sees anything.
The delay window closes that. With set_rotation_delay(seconds) armed:
| Step | Entry point | Effect |
|---|---|---|
| Request | request_address_rotation(username, new_address) |
Records the pending address, emits RotationRequestedEvent. Nothing moves. |
| Wait | — | executable_at = requested_at + delay |
| Execute | execute_address_rotation(username) |
Applies the change, emits RotationExecutedEvent |
| Stop | cancel_address_rotation(caller, username) |
Holder or admin cancels, emits RotationCancelledEvent |
Both addresses still require_auth on the request, exactly as before. What
changes is that the authorisation buys a queued rotation rather than an
immediate one, and the queue is visible: the request event gives indexers and
the holder a window to notice a rotation nobody asked for, and
cancel_address_rotation is how they stop it.
While a rotation is pending:
- Reads return the current address.
get_address,has_record, andget_record_proofall keep reporting the outgoing address until the rotation executes. A pending rotation is a proposal, not a fact. get_pending_rotation(username)exposes the queued address and itsexecutable_at, and works while paused so a holder can always see what is queued against their name.verifystill operates on the record as it stands, against the current address.registerrefuses a direct address change withRotationRequired, so the window cannot be stepped around.- A second request is refused with
RotationPending; cancel first. - A rotation cannot be requested while a challenge is open on the username — a challenge is an unresolved question about ownership, and queuing a rotation underneath it would let the answer change mid-flight.
Executing a rotation clears the verified flag and marks the username pending re-verify, the same policy a direct address change already applied: the verification vouched for the address, and the address has changed.
The delay defaults to 0, which disables all of the above and preserves the
direct dual-auth swap. This matches the existing set_cooldown convention.
Operators handling real payouts should set it — 24h (86400) gives a holder a
day to notice and cancel.
The contract records structured audit log entries into contract storage upon state mutations (initialize, register, remove, verify, batch_verify, pause, unpause, config_verification, set_role).
- Structured compliance record: An on-chain log entry (
AuditLogEntry) persisted in instance storage recording event type (AuditEventType), timestamp, actor address, target username/address, and details. - Operator query surface: Callable on-chain via
get_audit_logs()andget_audit_stats(). - Bounded ring buffer: Maintained up to a maximum cap (100 entries) per contract instance to stay within Soroban memory and footprint boundaries.
- Domain events replacement: Audit log entries complement, but do not replace, Soroban domain events (
RegisteredEvent,VerifiedEvent,RemovedEvent, etc.). Off-chain indexers still rely on domain events for event stream monitoring. - Unbounded historical store: Audit entries are capped on-chain. Complete long-term history across all ledgers should be collected by off-chain indexers from event topics or block archives.
This contract has not been formally audited. Use at your own risk on mainnet until an audit is completed.
For production deployments, consider:
- Independent security audit
- Bug bounty program
- Staged rollout on testnet/futurenet first
- Structured compliance record: An on-chain log entry (
AuditLogEntry) persisted in instance storage recording event type (AuditEventType), timestamp, actor address, target username/address, and details. - Operator query surface: Callable on-chain via
get_audit_logs()andget_audit_stats(). - Bounded ring buffer: Maintained up to a maximum cap (100 entries) per contract instance to stay within Soroban memory and footprint boundaries.
- Domain events replacement: Audit log entries complement, but do not replace, Soroban domain events (
RegisteredEvent,VerifiedEvent,RemovedEvent, etc.). Off-chain indexers still rely on domain events for event stream monitoring. - Unbounded historical store: Audit entries are capped on-chain. Complete long-term history across all ledgers should be collected by off-chain indexers from event topics or block archives.
This contract has not been formally audited. Use at your own risk on mainnet until an audit is completed.
For production deployments, consider:
- Independent security audit
- Bug bounty program
- Staged rollout on testnet/futurenet first
count and verified are maintained incrementally by every mutating call
(register, remove, verify, revoke_verification, batch operations,
…). Under normal operation they never need correcting — the property fuzz
suite (see REGISTRY_INVARIANTS.md) exercises long
random operation sequences precisely to catch a code change that would make
them drift, before it ever reaches testnet or mainnet.
That fuzz coverage does not extend to state that reaches storage by some path other than these public entry points — a bug in a future migration step, a hand-crafted storage write during an incident, or an upgrade that changes the counters' encoding. Nothing on-chain currently detects that class of drift.
repair_index(apply: bool) (admin-only) recomputes count and verified
by walking the chunked username index and checking each entry's stored
record, independent of the counters themselves, and returns a
RepairReport with both the stored and recomputed values.
When to use it:
- After any incident that involved a manual/scripted storage write, a migration, or a WASM upgrade you are not fully confident preserved the counters.
- Whenever
get_stats()orget_health()looks implausible relative to what an off-chain indexer's own tally ofRegistered/Removed/Verifiedevents says it should be. - As a routine post-upgrade sanity check, called once with
apply = false.
How to use it:
- Call with
apply = falsefirst. This is a pure read — it writes nothing — and returns whetherdriftedistruealong with the stored vs. recomputed values for both counters. - Only if
driftedistrueand the recomputed values have been reviewed, call again withapply = trueto write the corrected values. A call that finds no drift never writes, even withapply = true.
What it deliberately does not do: repair_index is never invoked
automatically by any other entry point — a silent repair on every call
would mask the very drift this operation exists to surface, and would let
an issue in one counter path go unnoticed behind an automatic fix on
another. Every repair is an explicit, auditable admin transaction.
Covered by tests/repair_index.rs, which drifts the counters against a
known-good fixture and checks both the dry-run report and the corrected
on-chain state after apply = true.