Skip to content

fix(soroban): make share-token vault immutable#429

Draft
carrion256 wants to merge 5 commits into
spr/refactor/vault-ergonomics/4f330057from
audit/share-token-admin-immutable
Draft

fix(soroban): make share-token vault immutable#429
carrion256 wants to merge 5 commits into
spr/refactor/vault-ergonomics/4f330057from
audit/share-token-admin-immutable

Conversation

@carrion256

@carrion256 carrion256 commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • consolidates the share-token audit remediation in one PR for A-012, A-050, A-093, A-094, and A-097
  • makes the share-token vault binding and metadata effectively immutable after construction while preserving ABI-compatible admin entrypoints that now fail closed
  • hardens share-token contract-address validation, delegated-burn observability, admin rotation, allowance, allowance-expiry, transfer-auth, and vault-invoker security coverage

Audit Context

  • Cluster: share-token-admin
  • Findings:
    • A-012 — Share-token admin can redirect vault-mint privilege without timelock or event
    • A-050 — Share-token metadata can be changed after deployment without timelock or event
    • A-093 — Share-token contract-address validation relies on StrKey prefix inspection
    • A-094 — Share-token burn and burn_from emit indistinguishable burn events
    • A-097 — Share-token tests omit admin rotation and allowance security coverage
  • Base: PR 417 branch spr/refactor/vault-ergonomics/4f330057 at a657f92
  • Tracker: contracts-audit-clusters/share-token-admin.audit.md

Verification

  • cargo fmt --all
  • cargo +1.86.0 test -p templar-soroban-share-token -- --nocapture — blocked before package tests by existing workspace dependency compile failure in stellar-contract-utils 0.7.1 (use of unstable library feature unsigned_is_multiple_of in src/crypto/merkle.rs:102)
  • RUSTFLAGS='-Zcrate-attr=feature(unsigned_is_multiple_of)' cargo +nightly test -p templar-soroban-share-token -- --nocapture — 9 passed
  • post-commit hook: Soroban size-budget-check passed at 93961 bytes

Halborn Finding IDs

Included for Halborn SSC GitHub remediation detection:

  • A-012 / Finding ID 6157e4aa-116a-4fcd-a45f-1ea4f695f5af — Share-token admin can redirect vault-mint privilege without timelock or event
  • A-050 / Finding ID 37a5412f-fb6a-455f-9b9c-3aa8227c255a — Share-token metadata can be changed after deployment without timelock or event
  • A-093 / Finding ID ca6dd926-fd39-405d-b710-af4fe8744727 — Share-token contract-address validation relies on StrKey prefix inspection
  • A-094 / Finding ID 5af46c5f-dbd8-46e6-93fb-0e21dd4aaf69 — Share-token burn and burn_from emit indistinguishable burn events
  • A-097 / Finding ID 12319f5b-fa52-46b1-8b49-d91fbceeca7d — Share-token tests omit admin rotation and allowance security coverage

Folded Share-Token Findings

This PR is the consolidated share-token cluster PR. It now also folds the formerly separate A-050/A-093/A-094/A-097 work; PR #440 and PR #443 are superseded by this PR.

  • A-050 / Nexus 37a5412f-fb6a-455f-9b9c-3aa8227c255a
    • Keeps constructor-time metadata initialization but makes post-deployment set_metadata fail after admin auth with ShareTokenError::MetadataImmutable.
    • Removes the stale operator set-metadata justfile recipe/help.
  • A-093 / Nexus ca6dd926-fd39-405d-b710-af4fe8744727
    • Replaces StrKey text-prefix contract-address validation with soroban_sdk::address_payload::AddressPayload discrimination.
    • Adds account-address rejection coverage for constructor vault binding and set_vault.
  • A-094 / Nexus 5af46c5f-dbd8-46e6-93fb-0e21dd4aaf69
    • Keeps the standard burn event and emits a supplemental burn_from event keyed by spender and owner, so delegated burns are distinguishable from direct burns.
  • A-097 / Nexus 12319f5b-fa52-46b1-8b49-d91fbceeca7d
    • Adds share-token admin rotation, old-admin revocation, allowance, allowance expiry, transfer-auth, and vault-invoker security coverage.

Fold Verification

  • RUSTUP_TOOLCHAIN=1.89.0 cargo fmt --all --check
  • git diff --check
  • RUSTUP_TOOLCHAIN=1.89.0 cargo test -p templar-soroban-share-token -- --nocapture — 20 passed
  • just -f contract/vault/soroban/justfile build
  • just -f contract/vault/soroban/justfile size-budget-check — 93,961 bytes <= 131,072 bytes
  • Post-commit hook reran Soroban size-budget-check for folded commits and passed at 93,961 bytes.

This change is Reviewable

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f6d075da-202f-4647-9fe0-182bc87a58de

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Enforces immutability for vault and metadata, adds VaultImmutable and MetadataImmutable errors, emits a supplemental burn_from event with spender/from topics, switches contract detection to AddressPayload, enables soroban-sdk hazmat-address, expands tests for these behaviors, and removes the Justfile set-metadata recipe/help entry.

Changes

Share Token Immutability and Event Emission

Layer / File(s) Summary
Immutability error types
contract/vault/soroban/share-token/src/types.rs
ShareTokenError adds VaultImmutable and MetadataImmutable variants (discriminants 4 and 5).
burn_from event publication
contract/vault/soroban/share-token/src/lib.rs
FungibleBurnable::burn_from publishes a burn_from event with spender and from topics and the burned amount.
Immutability enforcement in vault and metadata
contract/vault/soroban/share-token/src/lib.rs
set_vault and set_metadata now reject changes by panicking with immutability errors; imports adjusted for address payload handling.
AddressPayload-based contract address detection
contract/vault/soroban/share-token/src/lib.rs
is_contract_address now converts an Address to AddressPayload and matches on ContractIdHash instead of using a string/byte heuristic.
Hazmat-address dependency feature
contract/vault/soroban/share-token/Cargo.toml
Adds the hazmat-address feature to the soroban-sdk dependency.
Test imports and utilities
contract/vault/soroban/share-token/src/tests.rs
Test imports updated to include Soroban test utilities and XDR event/value types for event filtering and topic assertions.
Test helpers and common setup
contract/vault/soroban/share-token/src/tests.rs
VaultCaller extended with approve and burn_from helpers; init_env and account_address helpers added and used by constructor/set_vault tests.
burn_from event and authorization test expansion
contract/vault/soroban/share-token/src/tests.rs
New positive test verifies burn_from emits spender/from topics and updates balance/allowance; added negative tests for missing/over/expired allowance, direct-caller rejection, admin rotation, and rebinding regressions.
set-metadata recipe and help text removal
contract/vault/soroban/justfile
Removes the set-metadata recipe and its listing from the Justfile help output.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • peer2f00l
  • royalf00l
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(soroban): make share-token vault immutable' accurately summarizes the main change: making the vault binding immutable after construction.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing all audit findings addressed, implementation approach, verification steps, and specific modifications.
Docstring Coverage ✅ Passed Docstring coverage is 80.65% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch audit/share-token-admin-immutable

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • LINEAR integration encountered authorization issues. Please disconnect and reconnect the integration in the CodeRabbit UI.

Comment @coderabbitai help to get the list of available commands and usage tips.

@carrion256

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contract/vault/soroban/share-token/src/tests.rs`:
- Around line 452-468: Update the test
admin_cannot_change_metadata_after_deployment to not just #[should_panic] but to
explicitly catch the error from invoking set_metadata, assert the error is the
exact metadata-immutability error string/variant expected, and then read the
token's metadata (use the contract's read/getters such as
get_name/get_symbol/get_decimals or the equivalent query methods on the token
instance) to assert that name, symbol and decimals remain unchanged; locate the
invocation of set_metadata and replace the blanket panic expectation with a
try/catch (or Result) assert on the error plus explicit state reads on token to
confirm no partial write occurred.
- Around line 109-120: Replace the broad #[should_panic] in the test
set_vault_rejects_account_address with an explicit assertion that invoking
set_vault with an account-style address returns the specific InvalidInput
contract error: call env.invoke_contract for the token's "set_vault" with
account_address(&env) and assert the result is an Err containing
ContractError::InvalidInput (or map the returned error code to that variant);
keep the test name and use the symbols set_vault, account_address, and
ContractError::InvalidInput to locate and update the behavior check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 115236c2-27e5-4119-84b0-e7034165c408

📥 Commits

Reviewing files that changed from the base of the PR and between a657f92 and f8dedf6.

📒 Files selected for processing (5)
  • contract/vault/soroban/justfile
  • contract/vault/soroban/share-token/Cargo.toml
  • contract/vault/soroban/share-token/src/lib.rs
  • contract/vault/soroban/share-token/src/tests.rs
  • contract/vault/soroban/share-token/src/types.rs
💤 Files with no reviewable changes (1)
  • contract/vault/soroban/justfile

Comment thread contract/vault/soroban/share-token/src/tests.rs
Comment thread contract/vault/soroban/share-token/src/tests.rs
@carrion256

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
contract/vault/soroban/share-token/src/tests.rs (1)

89-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace broad #[should_panic] with exact contract-error assertions in negative-path tests.

These tests currently pass on any panic, including unrelated regressions. Switch to try_invoke_contract::<(), ShareTokenError>(...) and assert specific variants for each path to lock behavior.

Also applies to: 249-280, 282-378

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/share-token/src/tests.rs` around lines 89 - 107, The
test constructor_rejects_account_vault_address (and other negative-path tests in
this file) currently use #[should_panic]; replace each with a call to
try_invoke_contract::<(), ShareTokenError>(...) targeting the
SorobanShareTokenContract invocation and assert the returned Err matches the
specific ShareTokenError variant expected (e.g., InvalidVaultAddress or similar)
instead of relying on any panic; locate the contract call registration code and
swap the env.register invocation wrapped by try_invoke_contract, then use
pattern matching or assert_eq! on the Err to ensure the exact ShareTokenError is
produced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contract/vault/soroban/share-token/src/tests.rs`:
- Around line 163-215: The test relies on calling env.events().all() as if it
clears events; instead record the current event count into a baseline before
calling VaultCaller::burn_from and then assert that the new events length equals
baseline + 2; locate the setup where VaultCaller::mint and VaultCaller::approve
are invoked and replace the unused env.events().all() call with code that
captures let baseline =
env.events().all().filter_by_contract(&token).events().len() (or equivalent),
then after VaultCaller::burn_from compute let events =
env.events().all().filter_by_contract(&token) and
assert_eq!(events.events().len(), baseline + 2) while keeping the same checks
against the burn_from event body.

---

Duplicate comments:
In `@contract/vault/soroban/share-token/src/tests.rs`:
- Around line 89-107: The test constructor_rejects_account_vault_address (and
other negative-path tests in this file) currently use #[should_panic]; replace
each with a call to try_invoke_contract::<(), ShareTokenError>(...) targeting
the SorobanShareTokenContract invocation and assert the returned Err matches the
specific ShareTokenError variant expected (e.g., InvalidVaultAddress or similar)
instead of relying on any panic; locate the contract call registration code and
swap the env.register invocation wrapped by try_invoke_contract, then use
pattern matching or assert_eq! on the Err to ensure the exact ShareTokenError is
produced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fda34278-3319-4fc3-8965-45a2dd13b6c2

📥 Commits

Reviewing files that changed from the base of the PR and between f8dedf6 and 467fdf8.

📒 Files selected for processing (1)
  • contract/vault/soroban/share-token/src/tests.rs

Comment on lines +163 to +215
#[test]
fn burn_from_emits_supplemental_spender_event() {
let (env, _admin, vault, token) = setup();
let from = Address::generate(&env);
let spender = Address::generate(&env);

env.as_contract(&vault, || {
VaultCaller::mint(env.clone(), token.clone(), from.clone(), 1000);
});
env.as_contract(&vault, || {
VaultCaller::approve(
env.clone(),
token.clone(),
from.clone(),
spender.clone(),
400,
300,
);
});
env.events().all();

env.as_contract(&vault, || {
VaultCaller::burn_from(
env.clone(),
token.clone(),
spender.clone(),
from.clone(),
250,
);
});

let events = env.events().all().filter_by_contract(&token);
assert_eq!(events.events().len(), 2);
let burn_from_event = &events.events()[1];
let ContractEventBody::V0(body) = &burn_from_event.body;
assert_eq!(body.topics.len(), 3);
assert_eq!(
body.topics[0],
ScVal::try_from_val(&env, &symbol_short!("burn_from")).unwrap()
);
let spender_val: Val = spender.clone().into_val(&env);
let from_val: Val = from.clone().into_val(&env);
let amount_val: Val = 250i128.into_val(&env);
assert_eq!(
body.topics[1],
ScVal::try_from_val(&env, &spender_val).unwrap()
);
assert_eq!(
body.topics[2],
ScVal::try_from_val(&env, &from_val).unwrap()
);
assert_eq!(body.data, ScVal::try_from_val(&env, &amount_val).unwrap());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In soroban-sdk testutils, does calling env.events().all() clear/reset accumulated events, or is it purely a read operation?

💡 Result:

In the soroban-sdk testutils, calling env.events.all is purely a read operation [1][2]. It retrieves the events that have been accumulated in the environment up to that point and does not clear or reset them [1][3]. The events remain in the environment's state, allowing you to call env.events.all multiple times within a test to inspect the accumulated event history [1][3]. If you need to clear events between specific test steps, you would typically need to create a new environment (Env::default) or manage the event state manually, as the SDK does not provide a built-in method to clear the event buffer on an existing environment instance [1][4].

Citations:


🏁 Script executed:

# Find the token contract implementation to understand what events are emitted
fd -e rs . contract/vault/soroban/share-token/src/ | grep -v test | head -20

Repository: Templar-Protocol/contracts

Length of output: 164


🏁 Script executed:

# Look at the token implementation to see what events are emitted by mint/approve/burn_from
cat contract/vault/soroban/share-token/src/lib.rs | head -100

Repository: Templar-Protocol/contracts

Length of output: 2778


🏁 Script executed:

# Search for event emissions in the token code
rg "emit_" contract/vault/soroban/share-token/src/lib.rs -A 3 -B 1

Repository: Templar-Protocol/contracts

Length of output: 565


🏁 Script executed:

# Search for stellar_tokens references or vendored code in the repo
find . -name "stellar_tokens*" -o -name "*stellar*tokens*" 2>/dev/null | head -20

Repository: Templar-Protocol/contracts

Length of output: 52


🏁 Script executed:

# Check Cargo.toml to see how stellar_tokens is included
cat contract/vault/soroban/share-token/Cargo.toml

Repository: Templar-Protocol/contracts

Length of output: 691


🏁 Script executed:

# Search for any test utilities or mocking around mint/approve events
rg "VaultCaller::mint|VaultCaller::approve" contract/vault/soroban/share-token/src/tests.rs -B 2 -A 5 | head -60

Repository: Templar-Protocol/contracts

Length of output: 1601


🏁 Script executed:

# Find the VaultCaller implementation
rg "impl VaultCaller|fn mint|fn approve" contract/vault/soroban/share-token/src/tests.rs -B 2 -A 10 | head -80

Repository: Templar-Protocol/contracts

Length of output: 755


🏁 Script executed:

# Also search for any event assertions in the test file to understand the pattern
rg "assert_eq.*len\(\)" contract/vault/soroban/share-token/src/tests.rs -B 3 -A 1

Repository: Templar-Protocol/contracts

Length of output: 347


🏁 Script executed:

# Search for all event assertions in the test file to understand the pattern
rg "events\(\)\.all\(\)" contract/vault/soroban/share-token/src/tests.rs -B 5 -A 5

Repository: Templar-Protocol/contracts

Length of output: 628


🏁 Script executed:

# Check if there are other tests that verify event counts and how they handle baseline
rg "#\[test\]" contract/vault/soroban/share-token/src/tests.rs -A 30 | grep -A 30 "burn_from_emits"

Repository: Templar-Protocol/contracts

Length of output: 827


Event baseline is not isolated; test relies on fragile implicit assumption about mint/approve event emission.

The test calls env.events().all() on line 182 without assigning the result, suggesting an intent to clear events. However, this is purely a read operation and does not clear accumulated events in the Soroban SDK. The subsequent assertion assert_eq!(events.events().len(), 2) implicitly assumes that VaultCaller::mint() and VaultCaller::approve() do not emit token contract events—this assumption works today but is fragile and undocumented. If stellar_tokens::Base behavior changes to emit events, the test will break unexpectedly.

Explicitly baseline the event count before burn_from() to make the invariant visible and robust:

Suggested fix
-    env.events().all();
+    let before = env.events().all().filter_by_contract(&token).events().len();

     env.as_contract(&vault, || {
         VaultCaller::burn_from(
             env.clone(),
             token.clone(),
             spender.clone(),
             from.clone(),
             250,
         );
     });

     let events = env.events().all().filter_by_contract(&token);
-    assert_eq!(events.events().len(), 2);
-    let burn_from_event = &events.events()[1];
+    let new_events = &events.events()[before..];
+    assert_eq!(new_events.len(), 2);
+    let burn_from_event = &new_events[1];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contract/vault/soroban/share-token/src/tests.rs` around lines 163 - 215, The
test relies on calling env.events().all() as if it clears events; instead record
the current event count into a baseline before calling VaultCaller::burn_from
and then assert that the new events length equals baseline + 2; locate the setup
where VaultCaller::mint and VaultCaller::approve are invoked and replace the
unused env.events().all() call with code that captures let baseline =
env.events().all().filter_by_contract(&token).events().len() (or equivalent),
then after VaultCaller::burn_from compute let events =
env.events().all().filter_by_contract(&token) and
assert_eq!(events.events().len(), baseline + 2) while keeping the same checks
against the burn_from event body.

…Nexus ca6dd926-fd39-405d-b710-af4fe8744727 Nexus 5af46c5f-dbd8-46e6-93fb-0e21dd4aaf69 Nexus 12319f5b-fa52-46b1-8b49-d91fbceeca7d)

Trace: 3c5d098
…3 Nexus 37a5412f-fb6a-455f-9b9c-3aa8227c255a Nexus ca6dd926-fd39-405d-b710-af4fe8744727)
@carrion256 carrion256 force-pushed the audit/share-token-admin-immutable branch from 467fdf8 to 8470b7f Compare May 19, 2026 08:28
…xus 37a5412f-fb6a-455f-9b9c-3aa8227c255a Nexus ca6dd926-fd39-405d-b710-af4fe8744727)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant