From 3e3fa71f2c06ea5ab6472031b107bd6ef4091c60 Mon Sep 17 00:00:00 2001 From: tyreenn Date: Thu, 22 Oct 2026 11:39:56 +0100 Subject: [PATCH] feat(vault,dx): add vault config setters, devcontainer, vscode tasks, and make doctor Resolve #69, #81, #82, and #86: - Add VaultContract foundation, ADR-006 mutability policy, and admin setters set_compliance and set_max_share_supply with ComplianceSet and MaxShareSupplySet events - Enforce MAX_COMPLIANCE_LEVEL (3) in constructor and set_compliance (InvalidComplianceLevel = 16) and tighten-only cap transitions (InvalidSupplyCap = 17) - Amend ADR-002, ADR-003, and ADR-004 for vault entry points and error discriminants - Add .devcontainer/devcontainer.json pinned to rust-toolchain.toml, stellar-cli 27.1.0, and Node 22 with sdk npm ci on creation - Add .vscode/tasks.json for make build, make lint, make test, and npm test - Add make doctor target and scripts/doctor.sh to check rustc, wasm32v1-none, stellar-cli, node, and npm versions --- .devcontainer/devcontainer.json | 27 + .github/workflows/contracts-ci.yml | 1 + .vscode/tasks.json | 46 + CHANGELOG.md | 4 + CONTRIBUTING.md | 21 +- Cargo.lock | 11 + Cargo.toml | 1 + Makefile | 7 +- README.md | 3 +- .../tests/test_compliance_contract.rs | 46 +- contracts/governance/src/error.rs | 10 +- contracts/governance/src/events.rs | 8 +- contracts/governance/src/lib.rs | 28 +- contracts/governance/tests/test_governance.rs | 30 +- .../governance/tests/test_resource_limits.rs | 9 +- contracts/oracle-adapter/src/error.rs | 2 +- contracts/oracle-adapter/src/events.rs | 2 + contracts/oracle-adapter/src/lib.rs | 4 +- .../tests/test_oracle_adapter.rs | 19 +- contracts/registry/src/error.rs | 2 +- contracts/registry/src/events.rs | 2 + contracts/registry/src/lib.rs | 2 +- contracts/registry/tests/test_registry.rs | 10 +- contracts/rwa-asset/src/lib.rs | 12 +- .../rwa-asset/tests/test_holder_count.rs | 29 +- contracts/rwa-asset/tests/test_rwa_asset.rs | 2 +- contracts/vault/Cargo.toml | 21 + contracts/vault/src/compliance.rs | 14 + contracts/vault/src/error.rs | 49 + contracts/vault/src/events.rs | 75 ++ contracts/vault/src/lib.rs | 583 +++++++++++ contracts/vault/src/test.rs | 122 +++ contracts/vault/src/token.rs | 12 + .../vault/tests/test_constructor_auth.rs | 63 ++ contracts/vault/tests/test_vault.rs | 957 ++++++++++++++++++ docs/architecture/002-auth-patterns.md | 16 + docs/architecture/003-upgrade-path.md | 7 +- .../004-cross-contract-interaction.md | 22 +- docs/architecture/006-vault.md | 106 ++ scripts/doctor.sh | 127 +++ tools/benchmark/src/benches/compliance.rs | 6 +- tools/benchmark/src/benches/governance.rs | 6 +- tools/benchmark/src/benches/oracle_adapter.rs | 6 +- tools/benchmark/src/benches/registry.rs | 6 +- tools/benchmark/src/benches/rwa_asset.rs | 6 +- 45 files changed, 2404 insertions(+), 138 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .vscode/tasks.json create mode 100644 contracts/vault/Cargo.toml create mode 100644 contracts/vault/src/compliance.rs create mode 100644 contracts/vault/src/error.rs create mode 100644 contracts/vault/src/events.rs create mode 100644 contracts/vault/src/lib.rs create mode 100644 contracts/vault/src/test.rs create mode 100644 contracts/vault/src/token.rs create mode 100644 contracts/vault/tests/test_constructor_auth.rs create mode 100644 contracts/vault/tests/test_vault.rs create mode 100644 docs/architecture/006-vault.md create mode 100755 scripts/doctor.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..448fcd5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "StellarForge Development Container", + "image": "mcr.microsoft.com/devcontainers/rust:1-1-bookworm", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22" + } + }, + "containerEnv": { + "STELLAR_CLI_VERSION": "27.1.0", + "STELLAR_CLI_SHA256": "9915fe63753d7154c26bb9e6410cf1d1ff9727a9b378e663c2790ab7a266af94" + }, + "postCreateCommand": "rustup toolchain install && rustup default \"$(sed -n 's/^channel = \"\\(.*\\)\"/\\1/p' rust-toolchain.toml)\" && curl -sSfL -o /tmp/stellar-cli.tar.gz \"https://github.com/stellar/stellar-cli/releases/download/v${STELLAR_CLI_VERSION}/stellar-cli-${STELLAR_CLI_VERSION}-x86_64-unknown-linux-gnu.tar.gz\" && echo \"${STELLAR_CLI_SHA256} /tmp/stellar-cli.tar.gz\" | sha256sum --check --strict && sudo tar xzf /tmp/stellar-cli.tar.gz -C /usr/local/bin stellar && rm /tmp/stellar-cli.tar.gz && cd sdk && npm ci", + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-vscode.vscode-typescript-next", + "GitHub.vscode-pull-request-github", + "usernamehw.errorlens" + ] + } + } +} diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml index 552f5db..29b8be7 100644 --- a/.github/workflows/contracts-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -108,6 +108,7 @@ jobs: stellar contract build --manifest-path contracts/compliance/Cargo.toml stellar contract build --manifest-path contracts/governance/Cargo.toml stellar contract build --manifest-path contracts/oracle-adapter/Cargo.toml + stellar contract build --manifest-path contracts/vault/Cargo.toml # Published with the artifacts, so the hash of a deployed contract's wasm # can be checked against the commit that built it. diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7bec29e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,46 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "make build", + "type": "shell", + "command": "make build", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$rustc", + "detail": "Build all Soroban WASM contracts" + }, + { + "label": "make lint", + "type": "shell", + "command": "make lint", + "group": "build", + "problemMatcher": "$rustc", + "detail": "Run Clippy linter across the Rust workspace" + }, + { + "label": "make test", + "type": "shell", + "command": "make test", + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": "$rustc", + "detail": "Run all Rust contract tests" + }, + { + "label": "npm test", + "type": "shell", + "command": "npm test", + "options": { + "cwd": "${workspaceFolder}/sdk" + }, + "group": "test", + "problemMatcher": [], + "detail": "Run TypeScript SDK unit tests in sdk/" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 097b5dc..5ac0d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `vault`: fractionalization vault contract foundation (`contracts/vault`) and `ADR-006` (`docs/architecture/006-vault.md`), plus admin setters `set_compliance(compliance, min_level)` and tighten-only `set_max_share_supply(max_share_supply)` emitting `ComplianceSet` and `MaxShareSupplySet` (#69). Both `__constructor` and `set_compliance` enforce `min_compliance_level <= MAX_COMPLIANCE_LEVEL` (`3`, matching `compliance::MAX_LEVEL`) with `VaultError::InvalidComplianceLevel` (`16`), and `set_max_share_supply` rejects negative, loosening, or below-supply caps with `VaultError::InvalidSupplyCap` (`17`). +- `.devcontainer/devcontainer.json` with the pinned Rust toolchain (`rust-toolchain.toml`), `wasm32v1-none`, `stellar-cli` `27.1.0`, and Node.js 22 preinstalled, running `npm ci` in `sdk/` on creation (#81). +- `.vscode/tasks.json` defining `make build`, `make lint`, `make test`, and `npm test` grouped as build and test tasks (#82). +- `make doctor` target (`scripts/doctor.sh`) checking `rustc`, `wasm32v1-none`, `stellar-cli`, `node`, and `npm` versions against the repository's pinned expectations (#86). - `rwa-asset`: holder count tracking (#146). `holder_count()` returns the number of addresses currently holding a strictly positive balance. The counter is incremented when a balance crosses 0 → >0 (first receive) and decremented when a balance hits exactly 0 (full burn or full send). `set_holder_cap(cap)` lets the admin set an optional ceiling on the holder count (Reg D style); any `mint`, `transfer`, `transfer_from` or `forced_transfer` that would add a new holder when the count already equals the cap is rejected with `ExceedsHolderCap` (error 25). `holder_count_cap()` returns the configured ceiling or `None`. The new `HolderCapSet` event is emitted by `set_holder_cap`, including when the cap is removed. A property test (`tests/test_holder_count.rs`) verifies the invariant `holder_count == count(addresses with balance > 0)` after every step in a 50-operation sequence of mints, burns, and transfers, both with and without an active cap. - `rwa-asset`: `set_governance`, `propose_cap_increase`/`approve_cap_increase`/`cancel_cap_increase` and `pending_cap_increase`. `update_metadata` still refuses to loosen a supply cap by itself (IR-02); raising one now requires the admin to propose a value and a separately configured `governance` address to approve it, mirroring `propose_admin`/`accept_admin`'s two-step shape. See [ADR-007](docs/architecture/007-governance-controlled-supply-cap.md) for why this is a second address rather than the `governance` contract itself (#148). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b902500..6ba3328 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,9 @@ cargo install cargo-watch # auto-rerun tests on save git clone https://github.com/0xLizTech/stellarforge.git cd stellarforge +# Verify required toolchain versions (rustc, wasm32v1-none, stellar-cli, node, npm) +make doctor + # Build everything make build @@ -91,6 +94,21 @@ make sdk-install make sdk-test ``` +### Devcontainer & Codespaces + +If you use GitHub Codespaces or the VS Code Dev Containers extension, `.devcontainer/devcontainer.json` provisions a ready-to-use environment with the pinned Rust toolchain (`rust-toolchain.toml`), the `wasm32v1-none` target, `stellar-cli` (`27.1.0`, matching CI), and Node.js 22 preinstalled, and runs `npm ci` in `sdk/` automatically on container creation so `make test` and `npm test` work immediately without manual setup. + +### VS Code Tasks + +`.vscode/tasks.json` defines Command Palette tasks (**Terminal: Run Task**) grouped into build and test tasks: + +| Task label | Group | Command | Description | +|---|---|---|---| +| `make build` | `build` (default) | `make build` | Build all Soroban WASM contracts | +| `make lint` | `build` | `make lint` | Run Clippy linter across the Rust workspace | +| `make test` | `test` (default) | `make test` | Run all Rust contract tests | +| `npm test` | `test` | `npm test` (in `sdk/`) | Run TypeScript SDK unit tests | + ### Running a single contract's tests ```bash @@ -127,7 +145,8 @@ stellarforge/ │ ├── registry/ # Global registry of deployed asset contracts │ ├── compliance/ # KYC/AML record management │ ├── governance/ # On-chain proposal & voting -│ └── oracle-adapter/ # SEP-40 NAV price feed (Phase 2) +│ ├── oracle-adapter/ # SEP-40 NAV price feed (Phase 2) +│ └── vault/ # Fractionalization vault & SEP-41 share token (Phase 2) ├── sdk/ # @stellarforge-protocol/sdk (TypeScript) │ ├── src/ │ │ ├── client.ts # RwaAssetClient, ComplianceClient diff --git a/Cargo.lock b/Cargo.lock index aa11080..c1ae321 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1963,6 +1963,17 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vault" +version = "0.1.0" +dependencies = [ + "compliance", + "oracle-adapter", + "rwa-asset", + "soroban-sdk", + "stellarforge-common", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index ce84755..6a1727a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "contracts/compliance", "contracts/governance", "contracts/oracle-adapter", + "contracts/vault", "tools/benchmark", ] resolver = "2" diff --git a/Makefile b/Makefile index 12da6ce..1dc6e94 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build test fmt lint clean deploy-testnet help +.PHONY: all build test fmt lint clean doctor deploy-testnet help # ─── Variables ──────────────────────────────────────────────────────────────── NETWORK ?= testnet @@ -16,6 +16,7 @@ build: $(STELLAR_CLI) contract build --manifest-path contracts/compliance/Cargo.toml $(STELLAR_CLI) contract build --manifest-path contracts/governance/Cargo.toml $(STELLAR_CLI) contract build --manifest-path contracts/oracle-adapter/Cargo.toml + $(STELLAR_CLI) contract build --manifest-path contracts/vault/Cargo.toml @echo "Build complete. Artifacts in target/wasm32v1-none/release/" test: @@ -34,6 +35,9 @@ clean: $(CARGO) clean rm -rf sdk/dist sdk/node_modules +doctor: + ./scripts/doctor.sh + # ─── SDK ────────────────────────────────────────────────────────────────────── sdk-install: cd sdk && npm ci @@ -63,6 +67,7 @@ help: @echo " make fmt Format Rust source code" @echo " make lint Run Clippy linter" @echo " make clean Remove build artifacts" + @echo " make doctor Check local Rust, stellar-cli, and Node toolchain" @echo " make sdk-build Build the TypeScript SDK" @echo " make sdk-test Run SDK unit tests" @echo " make deploy-testnet Deploy all four contracts to Stellar testnet" diff --git a/README.md b/README.md index c427012..42dcb3a 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ stellarforge/ │ ├── registry/ # Global asset registry │ ├── compliance/ # KYC/AML compliance engine │ ├── governance/ # On-chain governance -│ └── oracle-adapter/ # SEP-40 NAV price feed (Phase 2) +│ ├── oracle-adapter/ # SEP-40 NAV price feed (Phase 2) +│ └── vault/ # Fractionalization vault & SEP-41 share token (Phase 2) ├── sdk/ │ ├── src/ │ │ ├── client.ts # Contract interaction clients diff --git a/contracts/compliance/tests/test_compliance_contract.rs b/contracts/compliance/tests/test_compliance_contract.rs index 6849e94..89784b4 100644 --- a/contracts/compliance/tests/test_compliance_contract.rs +++ b/contracts/compliance/tests/test_compliance_contract.rs @@ -9,11 +9,9 @@ use soroban_sdk::{ }; use compliance::{ - AdminTransferred, ComplianceContract, ComplianceContractClient, ComplianceError, DataKey, - KycRecord, KycRevoked, KycSet, Paused, MAX_LEVEL, - KycRecord, KycRevoked, KycSet, MAX_BATCH_SIZE, MAX_LEVEL, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, ComplianceContract, - ComplianceContractClient, ComplianceError, DataKey, KycRecord, KycRevoked, KycSet, MAX_LEVEL, + ComplianceContractClient, ComplianceError, DataKey, KycRecord, KycRevoked, KycSet, Paused, + MAX_BATCH_SIZE, MAX_LEVEL, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -367,7 +365,8 @@ fn test_transfer_admin_extends_the_instance() { let new_admin = Address::generate(&h.env); h.env.ledger().with_mut(|li| li.sequence_number += IDLE); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -624,6 +623,9 @@ fn test_set_paused_emits_event() { assert_eq!( h.env.events().all(), std::vec![Paused { paused: true }.to_xdr(&h.env, &h.contract_id)], + ); +} + // ─── Batch KYC set/revoke (#140) ─────────────────────────────────────────── #[test] @@ -688,6 +690,15 @@ fn test_set_paused_requires_admin() { contract: &h.contract_id, fn_name: "set_paused", args: (true,).into_val(&h.env), + sub_invokes: &[], + }, + }]); + + assert!(h.client.try_set_paused(&true).is_err()); + assert!(!h.client.paused()); +} + +#[test] fn test_set_kyc_batch_requires_admin() { let h = setup(); let subject = Address::generate(&h.env); @@ -706,8 +717,14 @@ fn test_set_kyc_batch_requires_admin() { }, }]); - assert!(h.client.try_set_paused(&true).is_err()); - assert!(!h.client.paused()); + assert!(h + .client + .try_set_kyc_batch(&Vec::from_array( + &h.env, + [(subject.clone(), h.record(LEVEL_BASIC, NEVER_EXPIRES))], + )) + .is_err()); + assert!(h.client.get_kyc(&subject).is_none()); } #[test] @@ -763,25 +780,18 @@ fn test_unpausing_allows_set_kyc_again() { assert_eq!(h.client.get_kyc(&subject).unwrap().level, LEVEL_BASIC); } -/// transfer_admin must stay callable while paused, matching rwa-asset's own -/// exemption, so a compromised admin key can still be rotated out. +/// propose_admin/accept_admin must stay callable while paused, matching +/// rwa-asset's own exemption, so a compromised admin key can still be rotated out. #[test] fn test_paused_does_not_block_transfer_admin() { let h = setup(); let new_admin = Address::generate(&h.env); h.client.set_paused(&true); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.client.admin(), new_admin); - assert!(h - .client - .try_set_kyc_batch(&Vec::from_array( - &h.env, - [(subject.clone(), h.record(LEVEL_BASIC, NEVER_EXPIRES))], - )) - .is_err()); - assert!(h.client.get_kyc(&subject).is_none()); } /// The partial-failure case the issue asks for: one bad record must not let diff --git a/contracts/governance/src/error.rs b/contracts/governance/src/error.rs index 8337254..9be2985 100644 --- a/contracts/governance/src/error.rs +++ b/contracts/governance/src/error.rs @@ -47,16 +47,16 @@ pub enum GovernanceError { /// `list_proposals` was given a `limit` above `MAX_PAGE_SIZE`. PageTooLarge = 14, /// `set_proposal_deposit` was given a negative amount. - InvalidDepositAmount = 12, + InvalidDepositAmount = 15, /// `accept_admin` was called with no transfer pending, or after it was /// cancelled or already accepted. - NoPendingAdminTransfer = 12, + NoPendingAdminTransfer = 16, /// `set_quorum` was given a negative value. - InvalidQuorum = 13, + InvalidQuorum = 17, /// `set_pass_threshold_bps` was given a value outside /// `MIN_PASS_THRESHOLD_BPS..=MAX_PASS_THRESHOLD_BPS`. - InvalidPassThresholdBps = 14, + InvalidPassThresholdBps = 18, /// `propose` matches another currently-active proposal's title and /// description hash. - DuplicateActiveProposal = 15, + DuplicateActiveProposal = 19, } diff --git a/contracts/governance/src/events.rs b/contracts/governance/src/events.rs index 9b919fd..2571b27 100644 --- a/contracts/governance/src/events.rs +++ b/contracts/governance/src/events.rs @@ -64,10 +64,6 @@ pub struct ProposalDepositRefunded { pub amount: i128, } -/// Published by `transfer_admin`. The incoming admin is data rather than a -/// topic, matching `rwa-asset`, so one topic filter on the outgoing address -/// finds every handover away from a key being watched. -#[contractevent(topics = ["transfer_admin"], data_format = "single-value")] /// Published by `propose_admin`. Distinct from `AdminTransferred`: the role /// has not moved yet, only a handover has been proposed. An indexer watching /// an admin address for the moment it loses control should key on @@ -102,6 +98,8 @@ pub struct ProposalCancelled { #[topic] pub proposal_id: u64, pub cancelled_by: Address, +} + /// Published by `set_proposal_deposit` (#134). `token` is `None` when the /// admin has disabled the deposit requirement, matching how `rwa-asset` /// reports `compliance: None` in `ComplianceSet` for the same "disabled" @@ -111,6 +109,8 @@ pub struct ProposalCancelled { pub struct DepositConfigured { pub token: Option
, pub amount: i128, +} + /// Published by `cancel_admin_transfer`. #[contractevent(topics = ["cancel_admin_transfer"], data_format = "single-value")] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/governance/src/lib.rs b/contracts/governance/src/lib.rs index 9fa5e0d..9023b17 100644 --- a/contracts/governance/src/lib.rs +++ b/contracts/governance/src/lib.rs @@ -21,26 +21,14 @@ mod events; pub use error::GovernanceError; pub use events::{ - AdminTransferred, ProposalCancelled, ProposalCreated, ProposalFinalized, VoteCast, + AdminTransferCancelled, AdminTransferProposed, AdminTransferred, DepositConfigured, + PassThresholdSet, ProposalCancelled, ProposalCreated, ProposalDepositRefunded, + ProposalFinalized, QuorumSet, VoteCast, }; use soroban_sdk::{ - contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, String, - Symbol, Vec, - AdminTransferred, DepositConfigured, ProposalCreated, ProposalDepositRefunded, - ProposalFinalized, VoteCast, -}; - -use soroban_sdk::{ - contract, contractimpl, contracttype, panic_with_error, symbol_short, token, Address, Env, - String, Symbol, - AdminTransferCancelled, AdminTransferProposed, AdminTransferred, PassThresholdSet, - ProposalCreated, ProposalFinalized, QuorumSet, VoteCast, -}; - -use soroban_sdk::{ - contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Bytes, BytesN, - Env, String, Symbol, + contract, contractimpl, contracttype, panic_with_error, symbol_short, token, Address, Bytes, + BytesN, Env, String, Symbol, Vec, }; use stellarforge_common::{extend_instance, extend_persistent, storage::DAY_IN_LEDGERS}; @@ -626,6 +614,10 @@ impl GovernanceContract { events::DepositConfigured { token, amount: stored_amount, + } + .publish(&env); + } + /// Sets the minimum total votes (`votes_for + votes_against`) a proposal /// needs before `finalize` will even evaluate its threshold (#135). Zero /// disables the check, matching `finalize`'s pre-#135 behavior. @@ -685,6 +677,8 @@ impl GovernanceContract { fn require_admin(env: &Env) { Self::admin(env.clone()).require_auth(); + } + pub fn pass_threshold_bps(env: Env) -> u32 { env.storage() .instance() diff --git a/contracts/governance/tests/test_governance.rs b/contracts/governance/tests/test_governance.rs index da3ee70..d7ffd5a 100644 --- a/contracts/governance/tests/test_governance.rs +++ b/contracts/governance/tests/test_governance.rs @@ -9,16 +9,11 @@ use soroban_sdk::{ }; use governance::{ - AdminTransferred, DataKey, GovernanceContract, GovernanceContractClient, GovernanceError, - ProposalCancelled, ProposalCreated, ProposalFinalized, ProposalStatus, VoteCast, MAX_PAGE_SIZE, + AdminTransferCancelled, AdminTransferProposed, AdminTransferred, DataKey, DepositConfigured, + GovernanceContract, GovernanceContractClient, GovernanceError, PassThresholdSet, + ProposalCancelled, ProposalCreated, ProposalDepositRefunded, ProposalFinalized, ProposalStatus, + QuorumSet, VoteCast, DEFAULT_PASS_THRESHOLD_BPS, MAX_PAGE_SIZE, MAX_PASS_THRESHOLD_BPS, MAX_TITLE_BYTES, MAX_VOTING_PERIOD_LEDGERS, MIN_VOTING_PERIOD_LEDGERS, - AdminTransferred, DataKey, DepositConfigured, GovernanceContract, GovernanceContractClient, - GovernanceError, ProposalCreated, ProposalDepositRefunded, ProposalFinalized, ProposalStatus, - VoteCast, MAX_TITLE_BYTES, MAX_VOTING_PERIOD_LEDGERS, MIN_VOTING_PERIOD_LEDGERS, - AdminTransferCancelled, AdminTransferProposed, AdminTransferred, DataKey, GovernanceContract, - GovernanceContractClient, GovernanceError, PassThresholdSet, ProposalCreated, - ProposalFinalized, ProposalStatus, QuorumSet, VoteCast, DEFAULT_PASS_THRESHOLD_BPS, - MAX_PASS_THRESHOLD_BPS, MAX_TITLE_BYTES, MAX_VOTING_PERIOD_LEDGERS, MIN_VOTING_PERIOD_LEDGERS, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -845,7 +840,8 @@ fn test_transfer_admin_extends_the_instance() { .ledger() .with_mut(|li| li.sequence_number += VOTING_PERIOD * 10); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -1114,6 +1110,9 @@ fn test_cancellation_after_a_single_against_vote_is_also_rejected() { assert_eq!( res, Err(Ok(GovernanceError::ProposalAlreadyHasVotes.into())) + ); +} + // ─── Proposal deposit (#134) ─────────────────────────────────────────────── #[test] @@ -1292,7 +1291,9 @@ fn test_list_proposals_returns_an_empty_page_when_there_are_none() { #[test] fn test_pages_partition_proposals_in_creation_order() { let h = setup(); - let ids: std::vec::Vec = (0..7).map(|_| h.propose()).collect(); + let ids: std::vec::Vec = (0..7) + .map(|i| h.propose_distinct(&std::format!("{i}"))) + .collect(); let mut walked = std::vec::Vec::new(); let mut start = 0; @@ -1311,8 +1312,8 @@ fn test_pages_partition_proposals_in_creation_order() { #[test] fn test_a_page_past_the_end_is_empty() { let h = setup(); - h.propose(); - h.propose(); + h.propose_distinct("a"); + h.propose_distinct("b"); assert_eq!(h.client.list_proposals(&2, &MAX_PAGE_SIZE).len(), 0); assert_eq!(h.client.list_proposals(&u32::MAX, &MAX_PAGE_SIZE).len(), 0); @@ -1334,6 +1335,9 @@ fn test_list_proposals_reflects_cancelled_status() { let page = h.client.list_proposals(&0, &MAX_PAGE_SIZE); assert_eq!(page.get(0).unwrap().status, ProposalStatus::Cancelled); +} + +#[test] fn test_propose_collects_the_configured_deposit() { let h = setup(); let (token_address, mint) = setup_token(&h.env, &h.admin); diff --git a/contracts/governance/tests/test_resource_limits.rs b/contracts/governance/tests/test_resource_limits.rs index 41b2d63..0837758 100644 --- a/contracts/governance/tests/test_resource_limits.rs +++ b/contracts/governance/tests/test_resource_limits.rs @@ -47,11 +47,12 @@ const VOTING_PERIOD: u32 = 17_280; // governance::MIN_VOTING_PERIOD_LEDGERS const REGRESSION_THRESHOLD_PERCENT: u64 = 20; // Baselines captured on this development machine's test host (see the module -// doc comment). Each is the instruction count the matching test below -// recorded at the time this file was written. -const PROPOSE_BASELINE_INSNS: u64 = 124_347; +// doc comment). Updated after #134 (deposit fields), #135 (quorum and pass +// threshold evaluation), and #136 (SHA-256 content-hash duplicate check on +// propose and active-hash removal on finalize). +const PROPOSE_BASELINE_INSNS: u64 = 194_672; const VOTE_BASELINE_INSNS: u64 = 179_636; -const FINALIZE_BASELINE_INSNS: u64 = 113_920; +const FINALIZE_BASELINE_INSNS: u64 = 145_748; struct Harness<'a> { env: Env, diff --git a/contracts/oracle-adapter/src/error.rs b/contracts/oracle-adapter/src/error.rs index e02f256..842fa0b 100644 --- a/contracts/oracle-adapter/src/error.rs +++ b/contracts/oracle-adapter/src/error.rs @@ -45,5 +45,5 @@ pub enum OracleError { ContractPaused = 14, /// `accept_admin` was called with no transfer pending, or after it was /// cancelled or already accepted. - NoPendingAdminTransfer = 14, + NoPendingAdminTransfer = 15, } diff --git a/contracts/oracle-adapter/src/events.rs b/contracts/oracle-adapter/src/events.rs index dc17148..5129342 100644 --- a/contracts/oracle-adapter/src/events.rs +++ b/contracts/oracle-adapter/src/events.rs @@ -86,6 +86,8 @@ pub struct AdminTransferred { #[derive(Clone, Debug, Eq, PartialEq)] pub struct Paused { pub paused: bool, +} + /// Published by `cancel_admin_transfer`. #[contractevent(topics = ["cancel_admin_transfer"], data_format = "single-value")] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/oracle-adapter/src/lib.rs b/contracts/oracle-adapter/src/lib.rs index f55fb96..ba4db85 100644 --- a/contracts/oracle-adapter/src/lib.rs +++ b/contracts/oracle-adapter/src/lib.rs @@ -45,10 +45,8 @@ mod math; pub use error::OracleError; pub use events::{ - AdminTransferred, AssetAdded, MaxDeviationSet, Paused, PriceOverridden, PriceReported, - ReporterSet, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, AssetAdded, MaxDeviationSet, - PriceOverridden, PriceReported, ReporterSet, + Paused, PriceOverridden, PriceReported, ReporterSet, }; pub use math::{check_price_deviation, tick_of, DeviationCheck}; diff --git a/contracts/oracle-adapter/tests/test_oracle_adapter.rs b/contracts/oracle-adapter/tests/test_oracle_adapter.rs index 18af832..39f773b 100644 --- a/contracts/oracle-adapter/tests/test_oracle_adapter.rs +++ b/contracts/oracle-adapter/tests/test_oracle_adapter.rs @@ -10,13 +10,10 @@ use soroban_sdk::{ }; use oracle_adapter::{ - AdminTransferred, Asset, AssetAdded, AssetConfig, DataKey, MaxDeviationSet, - OracleAdapterContract, OracleAdapterContractClient, OracleError, Paused, PriceData, - PriceOverridden, PriceReported, ReporterSet, MAX_ASSETS, MAX_DECIMALS, MAX_RECORDS, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, Asset, AssetAdded, AssetConfig, DataKey, MaxDeviationSet, OracleAdapterContract, OracleAdapterContractClient, - OracleError, PriceData, PriceOverridden, PriceReported, ReporterSet, MAX_ASSETS, MAX_DECIMALS, - MAX_RECORDS, + OracleError, Paused, PriceData, PriceOverridden, PriceReported, ReporterSet, MAX_ASSETS, + MAX_DECIMALS, MAX_RECORDS, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -784,7 +781,8 @@ fn test_transfer_admin_extends_the_instance() { let new_admin = Address::generate(&h.env); env_advance_ledger(&h.env, 600_000); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -857,6 +855,9 @@ fn test_set_paused_emits_event() { assert_eq!( h.env.events().all(), std::vec![Paused { paused: true }.to_xdr(&h.env, &h.contract_id)], + ); +} + #[test] fn test_accept_admin_with_no_pending_transfer_fails() { let h = setup(); @@ -1000,9 +1001,13 @@ fn test_paused_does_not_block_transfer_admin() { let new_admin = Address::generate(&h.env); h.client.set_paused(&true); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.client.admin(), new_admin); +} + +#[test] fn test_accept_admin_emits_event() { let h = setup(); let new_admin = Address::generate(&h.env); diff --git a/contracts/registry/src/error.rs b/contracts/registry/src/error.rs index 9108fc9..8fe46c8 100644 --- a/contracts/registry/src/error.rs +++ b/contracts/registry/src/error.rs @@ -31,5 +31,5 @@ pub enum RegistryError { ContractPaused = 6, /// `accept_admin` was called with no transfer pending, or after it was /// cancelled or already accepted. - NoPendingAdminTransfer = 6, + NoPendingAdminTransfer = 7, } diff --git a/contracts/registry/src/events.rs b/contracts/registry/src/events.rs index d57f4b4..a42f429 100644 --- a/contracts/registry/src/events.rs +++ b/contracts/registry/src/events.rs @@ -58,6 +58,8 @@ pub struct AdminTransferred { #[derive(Clone, Debug, Eq, PartialEq)] pub struct Paused { pub paused: bool, +} + /// Published by `cancel_admin_transfer`. #[contractevent(topics = ["cancel_admin_transfer"], data_format = "single-value")] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index ca5fe1f..e101b16 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -7,9 +7,9 @@ mod error; mod events; pub use error::RegistryError; -pub use events::{ActiveSet, AdminTransferred, AssetRegistered, Paused}; pub use events::{ ActiveSet, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, AssetRegistered, + Paused, }; use soroban_sdk::{ diff --git a/contracts/registry/tests/test_registry.rs b/contracts/registry/tests/test_registry.rs index d033c16..519ad04 100644 --- a/contracts/registry/tests/test_registry.rs +++ b/contracts/registry/tests/test_registry.rs @@ -10,10 +10,8 @@ use soroban_sdk::{ }; use registry::{ - ActiveSet, AdminTransferred, AssetEntry, AssetRegistered, DataKey, Paused, RegistryContract, - RegistryContractClient, RegistryError, MAX_PAGE_SIZE, ActiveSet, AdminTransferCancelled, AdminTransferProposed, AdminTransferred, AssetEntry, - AssetRegistered, DataKey, RegistryContract, RegistryContractClient, RegistryError, + AssetRegistered, DataKey, Paused, RegistryContract, RegistryContractClient, RegistryError, MAX_PAGE_SIZE, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; @@ -461,7 +459,8 @@ fn test_transfer_admin_extends_the_instance() { let new_admin = Address::generate(&h.env); h.env.ledger().with_mut(|li| li.sequence_number += IDLE); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.instance_ttl(), INSTANCE_BUMP_AMOUNT); } @@ -764,7 +763,8 @@ fn test_paused_does_not_block_transfer_admin() { let new_admin = Address::generate(&h.env); h.client.set_paused(&true); - h.client.transfer_admin(&new_admin); + h.client.propose_admin(&new_admin); + h.client.accept_admin(); assert_eq!(h.client.admin(), new_admin); } diff --git a/contracts/rwa-asset/src/lib.rs b/contracts/rwa-asset/src/lib.rs index abd344a..d1feff9 100644 --- a/contracts/rwa-asset/src/lib.rs +++ b/contracts/rwa-asset/src/lib.rs @@ -9,8 +9,8 @@ pub use error::RwaError; pub use events::{ AdminTransferCancelled, AdminTransferProposed, AdminTransferred, Approve, Burn, CapIncreaseCancelled, CapIncreaseProposed, ComplianceSet, ForcedTransfer, FrozenSet, - GovernanceSet, HolderCapSet, IssuerSet, LockupDateSet, MetadataUpdated, Mint, Paused, - Transfer, TransferFrom, + GovernanceSet, HolderCapSet, IssuerSet, LockupDateSet, MetadataUpdated, Mint, Paused, Transfer, + TransferFrom, }; use stellarforge_common::{extend_instance, extend_persistent}; @@ -1207,9 +1207,7 @@ impl RwaAssetContract { Some(v) => v, None => panic_with_error!(env, RwaError::Overflow), }; - env.storage() - .persistent() - .set(&DataKey::HolderCount, &next); + env.storage().persistent().set(&DataKey::HolderCount, &next); extend_persistent(env, &DataKey::HolderCount); } @@ -1224,9 +1222,7 @@ impl RwaAssetContract { .get(&DataKey::HolderCount) .unwrap_or(0); let next = current.saturating_sub(1); - env.storage() - .persistent() - .set(&DataKey::HolderCount, &next); + env.storage().persistent().set(&DataKey::HolderCount, &next); extend_persistent(env, &DataKey::HolderCount); } diff --git a/contracts/rwa-asset/tests/test_holder_count.rs b/contracts/rwa-asset/tests/test_holder_count.rs index 5fb0e76..ca23bf7 100644 --- a/contracts/rwa-asset/tests/test_holder_count.rs +++ b/contracts/rwa-asset/tests/test_holder_count.rs @@ -5,10 +5,7 @@ //! After any sequence of mints, burns and transfers, the stored `holder_count` //! must equal the number of addresses whose `balance` is strictly positive. -use soroban_sdk::{ - testutils::Address as _, - Address, Bytes, Env, String, -}; +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env, String}; use rwa_asset::{AssetMetadata, RwaAssetContract, RwaAssetContractClient, RwaError}; @@ -360,11 +357,7 @@ fn test_holder_count_invariant() { // Burn if balance is positive let bal = client.balance(addr); if bal > 0 { - let burn_amount = if bal >= 5 * UNIT { - 5 * UNIT - } else { - bal - }; + let burn_amount = if bal >= 5 * UNIT { 5 * UNIT } else { bal }; client.burn(addr, &burn_amount); } } @@ -372,11 +365,7 @@ fn test_holder_count_invariant() { // Transfer to another holder let bal = client.balance(addr); if bal > 0 { - let transfer_amount = if bal >= 3 * UNIT { - 3 * UNIT - } else { - bal - }; + let transfer_amount = if bal >= 3 * UNIT { 3 * UNIT } else { bal }; client.transfer(addr, other, &transfer_amount); } } @@ -425,22 +414,14 @@ fn test_holder_count_invariant_with_cap() { 1 => { let bal = client.balance(addr); if bal > 0 { - let burn_amount = if bal >= 5 * UNIT { - 5 * UNIT - } else { - bal - }; + let burn_amount = if bal >= 5 * UNIT { 5 * UNIT } else { bal }; client.burn(addr, &burn_amount); } } 2 => { let bal = client.balance(addr); if bal > 0 { - let transfer_amount = if bal >= 3 * UNIT { - 3 * UNIT - } else { - bal - }; + let transfer_amount = if bal >= 3 * UNIT { 3 * UNIT } else { bal }; // May fail if `other` has zero balance and count is at cap let _ = client.try_transfer(addr, other, &transfer_amount); } diff --git a/contracts/rwa-asset/tests/test_rwa_asset.rs b/contracts/rwa-asset/tests/test_rwa_asset.rs index 4af43f5..e9588d8 100644 --- a/contracts/rwa-asset/tests/test_rwa_asset.rs +++ b/contracts/rwa-asset/tests/test_rwa_asset.rs @@ -9,7 +9,7 @@ use soroban_sdk::{ }; use rwa_asset::{ - AssetMetadata, DataKey, FrozenSet, ForcedTransfer, LockupDateSet, RwaAssetContract, + AssetMetadata, DataKey, ForcedTransfer, FrozenSet, LockupDateSet, RwaAssetContract, RwaAssetContractClient, RwaError, }; use stellarforge_common::storage::INSTANCE_BUMP_AMOUNT; diff --git a/contracts/vault/Cargo.toml b/contracts/vault/Cargo.toml new file mode 100644 index 0000000..139792c --- /dev/null +++ b/contracts/vault/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vault" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { workspace = true } +stellarforge-common = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +rwa-asset = { path = "../rwa-asset", features = ["testutils"] } +compliance = { path = "../compliance", features = ["testutils"] } +oracle-adapter = { path = "../oracle-adapter", features = ["testutils"] } diff --git a/contracts/vault/src/compliance.rs b/contracts/vault/src/compliance.rs new file mode 100644 index 0000000..d7a8254 --- /dev/null +++ b/contracts/vault/src/compliance.rs @@ -0,0 +1,14 @@ +//! Binding to the external compliance contract. +//! +//! Declared as a client interface following ADR-004: the vault only needs the +//! shape of one call (`screen`), and an operator may point it at any contract +//! implementing that shape without rebuilding this crate. + +use soroban_sdk::{contractclient, Address, Env}; + +#[contractclient(name = "ComplianceClient")] +pub trait ComplianceInterface { + /// Returns true when `subject` holds a valid, unexpired verification + /// record at or above `min_level`. + fn screen(env: Env, subject: Address, min_level: u32) -> bool; +} diff --git a/contracts/vault/src/error.rs b/contracts/vault/src/error.rs new file mode 100644 index 0000000..5289a1f --- /dev/null +++ b/contracts/vault/src/error.rs @@ -0,0 +1,49 @@ +use soroban_sdk::contracterror; + +/// Failure modes of the vault contract. +/// +/// Discriminants are part of the contract's public interface: clients match on +/// the numeric code, so existing variants must keep their values and new ones +/// must be appended. Renumbering silently changes what a deployed client +/// believes went wrong (see ADR-003). +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum VaultError { + /// Configuration or admin state was absent when an entry point needed it. + NotInitialized = 1, + /// `exchange_rate` was less than 1. + InvalidExchangeRate = 2, + /// `max_share_supply` was negative. + InvalidMaxSupply = 3, + /// `lockup_secs` exceeded `MAX_LOCKUP_SECS`. + InvalidLockup = 4, + /// `name` or `symbol` was empty. + InvalidMetadata = 5, + /// Underlying contract failed decimals call or is not a valid token. + InvalidUnderlying = 6, + /// The contract is paused; state-changing calls are refused. + ContractPaused = 7, + /// Amount was zero or negative. + InvalidAmount = 8, + /// Holder's balance is below the requested amount. + InsufficientBalance = 9, + /// Spender's allowance is below the requested amount. + InsufficientAllowance = 10, + /// An arithmetic operation overflowed `i128`. + Overflow = 11, + /// `approve` set a non-zero allowance whose `live_until_ledger` has already passed. + InvalidExpiration = 12, + /// A party to the transfer lacks the required verification level. + NotCompliant = 13, + /// The owner's shares are currently locked (`now < locked_until(from)`). + SharesLocked = 14, + /// Minting shares would exceed `max_share_supply`. + ExceedsMaxSupply = 15, + /// `min_compliance_level` in `VaultConfig` or `set_compliance` exceeded + /// `MAX_COMPLIANCE_LEVEL` (3, matching `compliance::MAX_LEVEL`). + InvalidComplianceLevel = 16, + /// `set_max_share_supply` tried to raise or remove a supply cap, set a + /// negative cap, or set a cap below the circulating share supply. + InvalidSupplyCap = 17, +} diff --git a/contracts/vault/src/events.rs b/contracts/vault/src/events.rs new file mode 100644 index 0000000..d2f1387 --- /dev/null +++ b/contracts/vault/src/events.rs @@ -0,0 +1,75 @@ +//! Contract event definitions for the vault contract. +//! +//! Topic lists and data formats match Stellar token conventions (SEP-41) +//! and `rwa-asset`, ensuring existing indexers and explorers consume them unmodified. + +use soroban_sdk::{contractevent, Address}; + +/// Published by `transfer`, in SEP-41's current format: the amount, plus the +/// recipient's muxed id when `to` was a muxed address. +#[contractevent(topics = ["transfer"], data_format = "map")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Transfer { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, + pub to_muxed_id: Option, +} + +/// Published by `transfer_from`. SEP-41 specifies the amount alone here, +/// because `transfer_from` takes no muxed address. +#[contractevent(topics = ["transfer"], data_format = "single-value")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferFrom { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, +} + +/// SEP-41 format: data is `[amount, live_until_ledger]`. +#[contractevent(topics = ["approve"], data_format = "vec")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Approve { + #[topic] + pub owner: Address, + #[topic] + pub spender: Address, + pub amount: i128, + pub live_until_ledger: u32, +} + +#[contractevent(topics = ["paused"], data_format = "single-value")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Paused { + pub paused: bool, +} + +#[contractevent(topics = ["transfer_admin"], data_format = "single-value")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminTransferred { + #[topic] + pub previous: Address, + pub new_admin: Address, +} + +/// Published by `set_compliance`. `compliance` is `None` when screening was +/// switched off, which is the case ADR-004 singles out for monitoring. +#[contractevent(topics = ["set_compliance"], data_format = "map")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComplianceSet { + pub compliance: Option
, + pub min_level: u32, +} + +/// Published by `set_max_share_supply`. Carries both the previous and new cap +/// so a monitor can reconstruct cap changes from the event stream alone. +#[contractevent(topics = ["set_max_share_supply"], data_format = "map")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxShareSupplySet { + pub previous: i128, + pub max_share_supply: i128, +} diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs new file mode 100644 index 0000000..eb69100 --- /dev/null +++ b/contracts/vault/src/lib.rs @@ -0,0 +1,583 @@ +#![no_std] + +//! Institutional-grade fractionalization vault contract for Stellar/Soroban. +//! +//! The vault wraps an underlying RWA asset into fractional shares. +//! Each deployment wraps exactly one underlying `rwa-asset` (ADR-006). +//! +//! Shares follow SEP-41 (balance, spendable allowance, transfer with muxed +//! addresses, name, symbol, decimals) with one deliberate protocol invariant: +//! **there is no public `burn` or `burn_from`**. Burning shares outside of an +//! authenticated redemption would permanently strand underlying assets in +//! the vault that no share could ever redeem. + +mod compliance; +mod error; +mod events; +#[cfg(test)] +mod test; +mod token; + +pub use compliance::{ComplianceClient, ComplianceInterface}; +pub use error::VaultError; +pub use events::{ + AdminTransferred, Approve, ComplianceSet, MaxShareSupplySet, Paused, Transfer, TransferFrom, +}; +pub use token::{UnderlyingTokenClient, UnderlyingTokenInterface}; + +use stellarforge_common::{extend_instance, extend_persistent}; + +use soroban_sdk::{ + contract, contractimpl, contracttype, panic_with_error, symbol_short, Address, Env, + MuxedAddress, String, Symbol, +}; + +// ─── Storage Keys ──────────────────────────────────────────────────────────── + +const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); +const PAUSED_KEY: Symbol = symbol_short!("PAUSED"); +const CONFIG_KEY: Symbol = symbol_short!("CONFIG"); +const DECIMALS_KEY: Symbol = symbol_short!("DECIMALS"); + +/// Upper bound on `VaultConfig::lockup_secs`: 10 years (315,360,000 seconds). +/// Beyond this, positions are effectively locked indefinitely. +pub const MAX_LOCKUP_SECS: u64 = 10 * 365 * 24 * 60 * 60; + +/// Upper bound on `min_compliance_level`: 0 none, 1 basic, 2 full, +/// 3 accredited, matching `compliance::MAX_LEVEL`. Declared locally rather +/// than imported from the `compliance` crate so the vault stays bound only to +/// `ComplianceInterface` (ADR-004). A level above 3 could never be satisfied +/// by any record `compliance` stores and would permanently fail `NotCompliant`. +pub const MAX_COMPLIANCE_LEVEL: u32 = 3; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Balance(Address), + TotalSupply, + UnderlyingHeld, + Allowance(Address, Address), + LockedUntil(Address), +} + +/// A spender's allowance and the last ledger it may be spent in (SEP-41). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AllowanceValue { + pub amount: i128, + /// After this ledger the allowance reads as zero. + pub live_until_ledger: u32, +} + +// ─── Data Types ─────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VaultConfig { + /// The underlying token contract address. + pub underlying: Address, + /// Fixed ratio of shares minted per underlying token (must be >= 1). + pub exchange_rate: i128, + /// Maximum share supply cap (0 = uncapped; negative values rejected). + pub max_share_supply: i128, + /// Lock-up period in seconds applied to newly minted positions. + pub lockup_secs: u64, + /// Human-readable name of the share token. + pub name: String, + /// Symbol of the share token (e.g. "vREIT"). + pub symbol: String, + /// Optional compliance contract for KYC screening. + pub compliance: Option
, + /// Minimum verification level required (0..3). + pub min_compliance_level: u32, +} + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct VaultContract; + +#[contractimpl] +impl VaultContract { + // ── Initialisation ──────────────────────────────────────────────────────── + + /// Configures the contract as part of the deploy transaction. + /// + /// Constructor requires `admin.require_auth()` (IR-17, ADR-002). + /// Share decimals are read immutably from the underlying token. + pub fn __constructor(env: Env, admin: Address, config: VaultConfig) { + admin.require_auth(); + Self::validate_config(&env, &config); + + // Share decimals: read from the underlying token contract. + // A non-token underlying that fails this call refuses deployment. + let decimals = match UnderlyingTokenClient::new(&env, &config.underlying).try_decimals() { + Ok(Ok(d)) => d, + _ => panic_with_error!(&env, VaultError::InvalidUnderlying), + }; + + env.storage().instance().set(&ADMIN_KEY, &admin); + env.storage().instance().set(&PAUSED_KEY, &false); + env.storage().instance().set(&CONFIG_KEY, &config); + env.storage().instance().set(&DECIMALS_KEY, &decimals); + + env.storage() + .persistent() + .set(&DataKey::TotalSupply, &0_i128); + env.storage() + .persistent() + .set(&DataKey::UnderlyingHeld, &0_i128); + + extend_instance(&env); + extend_persistent(&env, &DataKey::TotalSupply); + extend_persistent(&env, &DataKey::UnderlyingHeld); + } + + // ── SEP-41 Share Token: Reads ───────────────────────────────────────────── + + pub fn balance(env: Env, owner: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(owner)) + .unwrap_or(0) + } + + pub fn total_supply(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::TotalSupply) + .unwrap_or(0) + } + + pub fn allowance(env: Env, owner: Address, spender: Address) -> i128 { + Self::read_allowance(&env, &DataKey::Allowance(owner, spender)).amount + } + + pub fn decimals(env: Env) -> u32 { + match env.storage().instance().get(&DECIMALS_KEY) { + Some(d) => d, + None => panic_with_error!(&env, VaultError::NotInitialized), + } + } + + pub fn name(env: Env) -> String { + Self::config(env).name + } + + pub fn symbol(env: Env) -> String { + Self::config(env).symbol + } + + /// Timestamp until which the holder's shares are locked from transfer and redemption. + pub fn locked_until(env: Env, holder: Address) -> u64 { + env.storage() + .persistent() + .get(&DataKey::LockedUntil(holder)) + .unwrap_or(0) + } + + // ── SEP-41 Share Token: Allowances ──────────────────────────────────────── + + /// Set `spender`'s allowance over `owner`'s balance, spendable through + /// ledger `live_until_ledger` (SEP-41). + pub fn approve( + env: Env, + owner: Address, + spender: Address, + amount: i128, + live_until_ledger: u32, + ) { + owner.require_auth(); + Self::require_not_paused(&env); + if amount < 0 { + panic_with_error!(&env, VaultError::InvalidAmount); + } + if amount > 0 && live_until_ledger < env.ledger().sequence() { + panic_with_error!(&env, VaultError::InvalidExpiration); + } + + let key = DataKey::Allowance(owner.clone(), spender.clone()); + env.storage().persistent().set( + &key, + &AllowanceValue { + amount, + live_until_ledger, + }, + ); + + extend_instance(&env); + extend_persistent(&env, &key); + + events::Approve { + owner, + spender, + amount, + live_until_ledger, + } + .publish(&env); + } + + // ── SEP-41 Share Token: Transfers ───────────────────────────────────────── + + /// Transfer shares from caller to recipient. + /// + /// Execution order: + /// 1. Auth: `from.require_auth()` + /// 2. Not paused: `require_not_paused` + /// 3. Positive amount: `require_positive` + /// 4. Lock-up check: `now < locked_until(from)` + /// 5. Compliance screen: `from` and `to` (if compliance configured) + /// 6. Checked arithmetic and balance mutation + pub fn transfer(env: Env, from: Address, to: MuxedAddress, amount: i128) { + let to_muxed_id = to.id(); + let to_addr = to.address(); + + from.require_auth(); + Self::require_not_paused(&env); + Self::require_positive(&env, amount); + Self::require_unlocked(&env, &from); + Self::require_compliant(&env, &from); + Self::require_compliant(&env, &to_addr); + + let from_bal = Self::balance(env.clone(), from.clone()); + if from_bal < amount { + panic_with_error!(&env, VaultError::InsufficientBalance); + } + + // Self-transfer is a no-op; avoid double-write credit overwrite. + if from == to_addr { + extend_instance(&env); + return; + } + + let to_bal = Self::balance(env.clone(), to_addr.clone()); + let to_new = Self::checked_add(&env, to_bal, amount); + + Self::write_balance(&env, &from, from_bal - amount); + Self::write_balance(&env, &to_addr, to_new); + extend_instance(&env); + + events::Transfer { + from, + to: to_addr, + amount, + to_muxed_id, + } + .publish(&env); + } + + /// Transfer shares from `from` to `to` using caller's (`spender`) allowance. + /// + /// The owner's lock is checked, not the spender's. + pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { + spender.require_auth(); + Self::require_not_paused(&env); + Self::require_positive(&env, amount); + Self::require_unlocked(&env, &from); + Self::require_compliant(&env, &from); + Self::require_compliant(&env, &to); + + Self::spend_allowance(&env, &from, &spender, amount); + + let from_bal = Self::balance(env.clone(), from.clone()); + if from_bal < amount { + panic_with_error!(&env, VaultError::InsufficientBalance); + } + + if from == to { + extend_instance(&env); + return; + } + + let to_bal = Self::balance(env.clone(), to.clone()); + let to_new = Self::checked_add(&env, to_bal, amount); + + Self::write_balance(&env, &from, from_bal - amount); + Self::write_balance(&env, &to, to_new); + extend_instance(&env); + + events::TransferFrom { from, to, amount }.publish(&env); + } + + // ── Administration & State Views ────────────────────────────────────────── + + pub fn admin(env: Env) -> Address { + match env.storage().instance().get(&ADMIN_KEY) { + Some(a) => a, + None => panic_with_error!(&env, VaultError::NotInitialized), + } + } + + pub fn paused(env: Env) -> bool { + env.storage().instance().get(&PAUSED_KEY).unwrap_or(false) + } + + pub fn config(env: Env) -> VaultConfig { + match env.storage().instance().get(&CONFIG_KEY) { + Some(c) => c, + None => panic_with_error!(&env, VaultError::NotInitialized), + } + } + + pub fn underlying_held(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::UnderlyingHeld) + .unwrap_or(0) + } + + /// Emergency pause taking effect in the same ledger (NFR-R-2). + pub fn set_paused(env: Env, paused: bool) { + Self::require_admin(&env); + env.storage().instance().set(&PAUSED_KEY, &paused); + extend_instance(&env); + events::Paused { paused }.publish(&env); + } + + /// Hand over admin privileges. Authorized by both existing and new admin (NFR-S-4). + pub fn transfer_admin(env: Env, new_admin: Address) { + Self::require_admin(&env); + new_admin.require_auth(); + let previous = Self::admin(env.clone()); + env.storage().instance().set(&ADMIN_KEY, &new_admin); + extend_instance(&env); + + events::AdminTransferred { + previous, + new_admin, + } + .publish(&env); + } + + /// Point the vault at a compliance contract, or pass `None` to disable + /// screening entirely (#69). + /// + /// `min_level` is the verification level both parties to a share transfer + /// must meet (0 none, 1 basic, 2 full, 3 accredited, matching + /// `compliance::MAX_LEVEL`). Values above [`MAX_COMPLIANCE_LEVEL`] are + /// rejected with [`VaultError::InvalidComplianceLevel`]. + pub fn set_compliance(env: Env, compliance: Option
, min_level: u32) { + Self::require_admin(&env); + if min_level > MAX_COMPLIANCE_LEVEL { + panic_with_error!(&env, VaultError::InvalidComplianceLevel); + } + let mut cfg = Self::config(env.clone()); + cfg.compliance = compliance.clone(); + cfg.min_compliance_level = min_level; + env.storage().instance().set(&CONFIG_KEY, &cfg); + extend_instance(&env); + + events::ComplianceSet { + compliance, + min_level, + } + .publish(&env); + } + + /// Tighten the maximum share supply cap, or set an initial finite cap on + /// an uncapped vault (#69). + /// + /// Matching `rwa-asset` (#54 / IR-02), the admin may lower a finite cap or + /// introduce a finite cap on an uncapped vault (`current == 0`), provided + /// the new cap is not below `total_supply()`. Raising a finite cap, + /// lifting a finite cap to uncapped (`0`), setting a negative cap, or + /// setting a cap below circulating supply is refused with + /// [`VaultError::InvalidSupplyCap`]. + pub fn set_max_share_supply(env: Env, max_share_supply: i128) { + Self::require_admin(&env); + if max_share_supply < 0 { + panic_with_error!(&env, VaultError::InvalidSupplyCap); + } + let mut cfg = Self::config(env.clone()); + let previous = cfg.max_share_supply; + let supply = Self::total_supply(env.clone()); + let loosens_cap = previous > 0 && (max_share_supply == 0 || max_share_supply > previous); + let below_supply = max_share_supply > 0 && max_share_supply < supply; + if loosens_cap || below_supply { + panic_with_error!(&env, VaultError::InvalidSupplyCap); + } + + cfg.max_share_supply = max_share_supply; + env.storage().instance().set(&CONFIG_KEY, &cfg); + extend_instance(&env); + + events::MaxShareSupplySet { + previous, + max_share_supply, + } + .publish(&env); + } + + // ── Central Internal Balance Write Path ─────────────────────────────────── + + /// Internal balance writer: the single central point where share balances change. + /// + /// Every balance mutation (transfers, mints, burns) routes through this function. + /// Future issues (#60 checkpoints) hook this exact entry point to record + /// ledger-bounded historical balances. + pub(crate) fn write_balance(env: &Env, holder: &Address, new_balance: i128) { + let key = DataKey::Balance(holder.clone()); + env.storage().persistent().set(&key, &new_balance); + extend_persistent(env, &key); + } + + /// Internal share mint helper for deposit operations (#58). + #[allow(dead_code)] // Called by deposit (#58) and balance checkpoints (#60) + pub(crate) fn mint_shares(env: &Env, to: &Address, amount: i128) { + Self::require_positive(env, amount); + let total = Self::total_supply(env.clone()); + let new_total = Self::checked_add(env, total, amount); + + let cfg = Self::config(env.clone()); + if cfg.max_share_supply > 0 && new_total > cfg.max_share_supply { + panic_with_error!(env, VaultError::ExceedsMaxSupply); + } + + let bal = Self::balance(env.clone(), to.clone()); + let new_bal = Self::checked_add(env, bal, amount); + + Self::write_balance(env, to, new_bal); + + env.storage() + .persistent() + .set(&DataKey::TotalSupply, &new_total); + extend_persistent(env, &DataKey::TotalSupply); + extend_instance(env); + } + + /// Internal share burn helper for redeem operations (#58). + #[allow(dead_code)] // Called by redeem (#58) and balance checkpoints (#60) + pub(crate) fn burn_shares(env: &Env, from: &Address, amount: i128) { + Self::require_positive(env, amount); + let bal = Self::balance(env.clone(), from.clone()); + if bal < amount { + panic_with_error!(env, VaultError::InsufficientBalance); + } + let total = Self::total_supply(env.clone()); + let new_total = Self::checked_sub(env, total, amount); + let new_bal = bal - amount; + + Self::write_balance(env, from, new_bal); + + env.storage() + .persistent() + .set(&DataKey::TotalSupply, &new_total); + extend_persistent(env, &DataKey::TotalSupply); + extend_instance(env); + } + + /// Internal helper to update underlying held balance for deposit/redeem (#58). + #[allow(dead_code)] // Called by deposit and redeem (#58) + pub(crate) fn set_underlying_held(env: &Env, amount: i128) { + env.storage() + .persistent() + .set(&DataKey::UnderlyingHeld, &amount); + extend_persistent(env, &DataKey::UnderlyingHeld); + extend_instance(env); + } + + /// Internal helper to set lock-up timestamp for a holder. + #[allow(dead_code)] // Called by deposit lock-up enforcement (#58) + pub(crate) fn set_locked_until(env: &Env, holder: &Address, until: u64) { + let key = DataKey::LockedUntil(holder.clone()); + env.storage().persistent().set(&key, &until); + extend_persistent(env, &key); + extend_instance(env); + } + + // ── Private Helpers ─────────────────────────────────────────────────────── + + fn require_admin(env: &Env) { + Self::admin(env.clone()).require_auth(); + } + + fn require_not_paused(env: &Env) { + if Self::paused(env.clone()) { + panic_with_error!(env, VaultError::ContractPaused); + } + } + + fn require_positive(env: &Env, amount: i128) { + if amount <= 0 { + panic_with_error!(env, VaultError::InvalidAmount); + } + } + + fn require_unlocked(env: &Env, holder: &Address) { + let now = env.ledger().timestamp(); + let locked_until = Self::locked_until(env.clone(), holder.clone()); + if now < locked_until { + panic_with_error!(env, VaultError::SharesLocked); + } + } + + fn require_compliant(env: &Env, party: &Address) { + let cfg = Self::config(env.clone()); + let Some(compliance) = cfg.compliance else { + return; + }; + let client = ComplianceClient::new(env, &compliance); + if !client.screen(party, &cfg.min_compliance_level) { + panic_with_error!(env, VaultError::NotCompliant); + } + } + + fn read_allowance(env: &Env, key: &DataKey) -> AllowanceValue { + match env.storage().persistent().get::<_, AllowanceValue>(key) { + Some(allowance) if allowance.live_until_ledger >= env.ledger().sequence() => allowance, + _ => AllowanceValue { + amount: 0, + live_until_ledger: 0, + }, + } + } + + fn spend_allowance(env: &Env, from: &Address, spender: &Address, amount: i128) { + let key = DataKey::Allowance(from.clone(), spender.clone()); + let current = Self::read_allowance(env, &key); + if current.amount < amount { + panic_with_error!(env, VaultError::InsufficientAllowance); + } + env.storage().persistent().set( + &key, + &AllowanceValue { + amount: current.amount - amount, + live_until_ledger: current.live_until_ledger, + }, + ); + extend_persistent(env, &key); + } + + fn validate_config(env: &Env, config: &VaultConfig) { + if config.exchange_rate < 1 { + panic_with_error!(env, VaultError::InvalidExchangeRate); + } + if config.max_share_supply < 0 { + panic_with_error!(env, VaultError::InvalidMaxSupply); + } + if config.lockup_secs > MAX_LOCKUP_SECS { + panic_with_error!(env, VaultError::InvalidLockup); + } + if config.name.is_empty() || config.symbol.is_empty() { + panic_with_error!(env, VaultError::InvalidMetadata); + } + if config.min_compliance_level > MAX_COMPLIANCE_LEVEL { + panic_with_error!(env, VaultError::InvalidComplianceLevel); + } + } + + fn checked_add(env: &Env, a: i128, b: i128) -> i128 { + match a.checked_add(b) { + Some(v) => v, + None => panic_with_error!(env, VaultError::Overflow), + } + } + + #[allow(dead_code)] // Called by burn_shares for redeem (#58) + fn checked_sub(env: &Env, a: i128, b: i128) -> i128 { + match a.checked_sub(b) { + Some(v) => v, + None => panic_with_error!(env, VaultError::Overflow), + } + } +} diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs new file mode 100644 index 0000000..e9eb90c --- /dev/null +++ b/contracts/vault/src/test.rs @@ -0,0 +1,122 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env, String}; + +#[contract] +struct MockToken; + +#[contractimpl] +impl MockToken { + pub fn decimals(_env: Env) -> u32 { + 7 + } +} + +fn setup() -> (Env, Address, Address, VaultContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying: underlying.clone(), + exchange_rate: 1, + max_share_supply: 1_000_000_000, + lockup_secs: 86400, + name: String::from_str(&env, "Test Vault"), + symbol: String::from_str(&env, "tVAULT"), + compliance: None, + min_compliance_level: 0, + }; + + let contract_id = env.register(VaultContract, (&admin, &config)); + let client = VaultContractClient::new(&env, &contract_id); + + (env, admin, contract_id, client) +} + +#[test] +fn test_internal_mint_and_burn_helpers() { + let (env, _admin, contract_id, client) = setup(); + let alice = Address::generate(&env); + + env.as_contract(&contract_id, || { + VaultContract::mint_shares(&env, &alice, 500); + }); + + assert_eq!(client.balance(&alice), 500); + assert_eq!(client.total_supply(), 500); + + env.as_contract(&contract_id, || { + VaultContract::burn_shares(&env, &alice, 200); + }); + + assert_eq!(client.balance(&alice), 300); + assert_eq!(client.total_supply(), 300); +} + +#[test] +#[should_panic(expected = "Error(Contract, #15)")] +fn test_internal_mint_exceeds_max_supply_rejected() { + let (env, _admin, contract_id, _client) = setup(); + let alice = Address::generate(&env); + + env.as_contract(&contract_id, || { + VaultContract::mint_shares(&env, &alice, 1_000_000_001); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_internal_burn_insufficient_balance_rejected() { + let (env, _admin, contract_id, _client) = setup(); + let alice = Address::generate(&env); + + env.as_contract(&contract_id, || { + VaultContract::mint_shares(&env, &alice, 100); + VaultContract::burn_shares(&env, &alice, 101); + }); +} + +#[test] +fn test_internal_set_underlying_held_and_locked_until() { + let (env, _admin, contract_id, client) = setup(); + let alice = Address::generate(&env); + + env.as_contract(&contract_id, || { + VaultContract::set_underlying_held(&env, 750); + VaultContract::set_locked_until(&env, &alice, 12345); + }); + + assert_eq!(client.underlying_held(), 750); + assert_eq!(client.locked_until(&alice), 12345); +} + +#[test] +#[should_panic(expected = "Error(Contract, #11)")] +fn test_internal_mint_overflow_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + let config = VaultConfig { + underlying, + exchange_rate: 1, + max_share_supply: 0, + lockup_secs: 0, + name: String::from_str(&env, "Test Vault"), + symbol: String::from_str(&env, "tVAULT"), + compliance: None, + min_compliance_level: 0, + }; + let contract_id = env.register(VaultContract, (&admin, &config)); + let alice = Address::generate(&env); + + env.as_contract(&contract_id, || { + VaultContract::mint_shares(&env, &alice, i128::MAX); + VaultContract::mint_shares(&env, &alice, 1); + }); +} diff --git a/contracts/vault/src/token.rs b/contracts/vault/src/token.rs new file mode 100644 index 0000000..158334f --- /dev/null +++ b/contracts/vault/src/token.rs @@ -0,0 +1,12 @@ +//! Binding to the underlying token contract. +//! +//! Used at construction to query the underlying token's `decimals()`, which +//! the vault adopts for its share token and stores immutably. + +use soroban_sdk::{contractclient, Env}; + +#[contractclient(name = "UnderlyingTokenClient")] +pub trait UnderlyingTokenInterface { + /// Returns the number of decimal places for the token. + fn decimals(env: Env) -> u32; +} diff --git a/contracts/vault/tests/test_constructor_auth.rs b/contracts/vault/tests/test_constructor_auth.rs new file mode 100644 index 0000000..37b9b8f --- /dev/null +++ b/contracts/vault/tests/test_constructor_auth.rs @@ -0,0 +1,63 @@ +#![cfg(test)] + +//! The constructor's `admin.require_auth()` stops a deployer naming an +//! admin who never agreed to the role (IR-17, ADR-002). +//! +//! `Env::register` authorizes constructor calls itself, but under `mock_all_auths` +//! it records which authorizations the constructor demanded. Asserting that +//! record ensures that removing `require_auth` fails the test. + +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, + Address, Env, IntoVal, String, Symbol, +}; + +use vault::{VaultConfig, VaultContract}; + +#[contract] +struct MockUnderlying; + +#[contractimpl] +impl MockUnderlying { + pub fn decimals(_env: Env) -> u32 { + 7 + } +} + +#[test] +fn test_constructor_requires_the_admin_to_authorize() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let underlying = env.register(MockUnderlying, ()); + + let config = VaultConfig { + underlying: underlying.clone(), + exchange_rate: 1, + max_share_supply: 1_000_000, + lockup_secs: 86400, + name: String::from_str(&env, "Vault Share"), + symbol: String::from_str(&env, "vRWA"), + compliance: None, + min_compliance_level: 0, + }; + + let id = env.register(VaultContract, (&admin, &config)); + + assert_eq!( + env.auths(), + std::vec![( + admin.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + id, + Symbol::new(&env, "__constructor"), + (admin, config).into_val(&env), + )), + sub_invocations: std::vec![], + }, + )] + ); +} diff --git a/contracts/vault/tests/test_vault.rs b/contracts/vault/tests/test_vault.rs new file mode 100644 index 0000000..e6ac4a0 --- /dev/null +++ b/contracts/vault/tests/test_vault.rs @@ -0,0 +1,957 @@ +#![cfg(test)] + +use soroban_sdk::{ + contract, contractimpl, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Events as _, Ledger, MuxedAddress as _, + }, + Address, Env, MuxedAddress, String, +}; + +use compliance::{ComplianceContract, ComplianceContractClient, KycRecord}; +use soroban_sdk::{Event, IntoVal}; +use stellarforge_common::storage::{persistent_bump_amount, INSTANCE_BUMP_AMOUNT}; +use vault::{ + AdminTransferred, Approve, ComplianceSet, DataKey, MaxShareSupplySet, Paused, Transfer, + TransferFrom, VaultConfig, VaultContract, VaultContractClient, VaultError, + MAX_COMPLIANCE_LEVEL, MAX_LOCKUP_SECS, +}; + +// ─── Test Helpers & Fixtures ────────────────────────────────────────────────── + +#[contract] +struct MockToken; + +#[contractimpl] +impl MockToken { + pub fn decimals(_env: Env) -> u32 { + 7 + } +} + +struct TestHarness { + env: Env, + admin: Address, + contract_id: Address, + client: VaultContractClient<'static>, + underlying: Address, + compliance_id: Option
, +} + +impl TestHarness { + fn set_balance(&self, holder: &Address, amount: i128) { + self.env.as_contract(&self.contract_id, || { + self.env + .storage() + .persistent() + .set(&DataKey::Balance(holder.clone()), &amount); + let current_total: i128 = self + .env + .storage() + .persistent() + .get(&DataKey::TotalSupply) + .unwrap_or(0); + self.env + .storage() + .persistent() + .set(&DataKey::TotalSupply, &(current_total + amount)); + }); + } + + fn set_locked_until(&self, holder: &Address, timestamp: u64) { + self.env.as_contract(&self.contract_id, || { + self.env + .storage() + .persistent() + .set(&DataKey::LockedUntil(holder.clone()), ×tamp); + }); + } +} + +fn setup_vault(compliance: Option
, min_level: u32) -> TestHarness { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying: underlying.clone(), + exchange_rate: 2, + max_share_supply: 1_000_000_000, + lockup_secs: 86400, + name: String::from_str(&env, "Institutional RWA Vault Share"), + symbol: String::from_str(&env, "vRWA-01"), + compliance: compliance.clone(), + min_compliance_level: min_level, + }; + + let contract_id = env.register(VaultContract, (&admin, &config)); + let client = VaultContractClient::new(&env, &contract_id); + + TestHarness { + env, + admin, + contract_id, + client, + underlying, + compliance_id: compliance, + } +} + +fn default_setup() -> TestHarness { + setup_vault(None, 0) +} + +fn setup_with_compliance() -> TestHarness { + let env = Env::default(); + env.mock_all_auths(); + let comp_admin = Address::generate(&env); + let comp_id = env.register(ComplianceContract, (&comp_admin,)); + + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying: underlying.clone(), + exchange_rate: 1, + max_share_supply: 10_000_000, + lockup_secs: 3600, + name: String::from_str(&env, "Compliant Vault Share"), + symbol: String::from_str(&env, "cVAULT"), + compliance: Some(comp_id.clone()), + min_compliance_level: 2, + }; + + let contract_id = env.register(VaultContract, (&admin, &config)); + let client = VaultContractClient::new(&env, &contract_id); + + TestHarness { + env, + admin, + contract_id, + client, + underlying, + compliance_id: Some(comp_id), + } +} + +// ─── 1. Configuration & Constructor Tests ───────────────────────────────────── + +#[test] +fn test_constructor_initial_state() { + let h = default_setup(); + assert_eq!(h.client.admin(), h.admin); + assert!(!h.client.paused()); + assert_eq!(h.client.decimals(), 7); + assert_eq!(h.client.total_supply(), 0); + assert_eq!(h.client.underlying_held(), 0); + assert_eq!( + h.client.name(), + String::from_str(&h.env, "Institutional RWA Vault Share") + ); + assert_eq!(h.client.symbol(), String::from_str(&h.env, "vRWA-01")); + + let cfg = h.client.config(); + assert_eq!(cfg.underlying, h.underlying); + assert_eq!(cfg.exchange_rate, 2); + assert_eq!(cfg.max_share_supply, 1_000_000_000); + assert_eq!(cfg.lockup_secs, 86400); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_constructor_rejects_zero_exchange_rate() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying, + exchange_rate: 0, + max_share_supply: 1_000, + lockup_secs: 60, + name: String::from_str(&env, "V"), + symbol: String::from_str(&env, "V"), + compliance: None, + min_compliance_level: 0, + }; + + env.register(VaultContract, (&admin, &config)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_constructor_rejects_negative_max_supply() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying, + exchange_rate: 1, + max_share_supply: -1, + lockup_secs: 60, + name: String::from_str(&env, "V"), + symbol: String::from_str(&env, "V"), + compliance: None, + min_compliance_level: 0, + }; + + env.register(VaultContract, (&admin, &config)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_constructor_rejects_excessive_lockup() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying, + exchange_rate: 1, + max_share_supply: 1_000, + lockup_secs: MAX_LOCKUP_SECS + 1, + name: String::from_str(&env, "V"), + symbol: String::from_str(&env, "V"), + compliance: None, + min_compliance_level: 0, + }; + + env.register(VaultContract, (&admin, &config)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_constructor_rejects_empty_name() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying, + exchange_rate: 1, + max_share_supply: 1_000, + lockup_secs: 60, + name: String::from_str(&env, ""), + symbol: String::from_str(&env, "V"), + compliance: None, + min_compliance_level: 0, + }; + + env.register(VaultContract, (&admin, &config)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_constructor_rejects_empty_symbol() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying, + exchange_rate: 1, + max_share_supply: 1_000, + lockup_secs: 60, + name: String::from_str(&env, "V"), + symbol: String::from_str(&env, ""), + compliance: None, + min_compliance_level: 0, + }; + + env.register(VaultContract, (&admin, &config)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_constructor_rejects_non_token_underlying() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let fake_underlying = Address::generate(&env); + + let config = VaultConfig { + underlying: fake_underlying, + exchange_rate: 1, + max_share_supply: 1_000, + lockup_secs: 60, + name: String::from_str(&env, "Vault"), + symbol: String::from_str(&env, "V"), + compliance: None, + min_compliance_level: 0, + }; + + env.register(VaultContract, (&admin, &config)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #16)")] +fn test_constructor_rejects_compliance_level_above_max() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + + let config = VaultConfig { + underlying, + exchange_rate: 1, + max_share_supply: 1_000, + lockup_secs: 60, + name: String::from_str(&env, "Vault"), + symbol: String::from_str(&env, "V"), + compliance: None, + min_compliance_level: MAX_COMPLIANCE_LEVEL + 1, + }; + + env.register(VaultContract, (&admin, &config)); +} + +// ─── 2. Emergency Pause & Admin Handover Tests ──────────────────────────────── + +#[test] +fn test_set_paused_takes_effect_in_same_ledger() { + let h = default_setup(); + assert!(!h.client.paused()); + + h.client.set_paused(&true); + assert_eq!( + h.env.events().all().events(), + std::vec![Paused { paused: true }.to_xdr(&h.env, &h.contract_id)], + ); + assert!(h.client.paused()); + + h.client.set_paused(&false); + assert_eq!( + h.env.events().all().events(), + std::vec![Paused { paused: false }.to_xdr(&h.env, &h.contract_id)], + ); + assert!(!h.client.paused()); +} + +#[test] +fn test_state_changes_rejected_when_paused() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 1000); + + h.client.set_paused(&true); + + let muxed_bob = MuxedAddress::from(bob.clone()); + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &100), + Err(Ok(VaultError::ContractPaused.into())) + ); + + assert_eq!( + h.client.try_approve(&alice, &bob, &500, &1000), + Err(Ok(VaultError::ContractPaused.into())) + ); + + assert_eq!( + h.client.try_transfer_from(&bob, &alice, &bob, &100), + Err(Ok(VaultError::ContractPaused.into())) + ); +} + +#[test] +fn test_transfer_admin_dual_auth() { + let h = default_setup(); + let new_admin = Address::generate(&h.env); + + h.client.transfer_admin(&new_admin); + assert_eq!( + h.env.events().all().events(), + std::vec![AdminTransferred { + previous: h.admin.clone(), + new_admin: new_admin.clone(), + } + .to_xdr(&h.env, &h.contract_id)], + ); + assert_eq!(h.client.admin(), new_admin); +} + +#[test] +fn test_set_compliance_rebinds_and_clears_screening() { + let h = default_setup(); + let comp_admin = Address::generate(&h.env); + let comp_a = h.env.register(ComplianceContract, (&comp_admin,)); + let comp_b = h.env.register(ComplianceContract, (&comp_admin,)); + let comp_a_client = ComplianceContractClient::new(&h.env, &comp_a); + let comp_b_client = ComplianceContractClient::new(&h.env, &comp_b); + + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 1_000); + let muxed_bob = MuxedAddress::from(bob.clone()); + + // Bind to comp_a at level 2. + h.client.set_compliance(&Some(comp_a.clone()), &2); + assert_eq!( + h.env.events().all().events(), + std::vec![ComplianceSet { + compliance: Some(comp_a.clone()), + min_level: 2, + } + .to_xdr(&h.env, &h.contract_id)], + ); + let cfg = h.client.config(); + assert_eq!(cfg.compliance, Some(comp_a)); + assert_eq!(cfg.min_compliance_level, 2); + + // Unverified transfer is now refused. + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &100), + Err(Ok(VaultError::NotCompliant.into())) + ); + + // Enroll Alice and Bob on comp_b at level 1, then rebind vault to comp_b at level 1. + comp_b_client.set_kyc( + &alice, + &KycRecord { + jurisdiction: String::from_str(&h.env, "US"), + level: 1, + expires_at: 0, + }, + ); + comp_b_client.set_kyc( + &bob, + &KycRecord { + jurisdiction: String::from_str(&h.env, "US"), + level: 1, + expires_at: 0, + }, + ); + let _ = comp_a_client; + h.client.set_compliance(&Some(comp_b.clone()), &1); + assert!(h.client.try_transfer(&alice, &muxed_bob, &100).is_ok()); + + // Revoke Bob on comp_b -> blocked again; then clear compliance -> succeeds. + comp_b_client.revoke_kyc(&bob); + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &100), + Err(Ok(VaultError::NotCompliant.into())) + ); + + h.client.set_compliance(&None, &0); + assert_eq!( + h.env.events().all().events(), + std::vec![ComplianceSet { + compliance: None, + min_level: 0, + } + .to_xdr(&h.env, &h.contract_id)], + ); + assert!(h.client.try_transfer(&alice, &muxed_bob, &100).is_ok()); +} + +#[test] +fn test_set_compliance_rejects_level_above_max() { + let h = default_setup(); + let comp = Address::generate(&h.env); + + assert_eq!( + h.client + .try_set_compliance(&Some(comp), &(MAX_COMPLIANCE_LEVEL + 1)), + Err(Ok(VaultError::InvalidComplianceLevel.into())) + ); +} + +#[test] +fn test_set_compliance_requires_admin_auth() { + let h = default_setup(); + let comp = Address::generate(&h.env); + + h.env.mock_auths(&[]); + assert!(h + .client + .try_set_compliance(&Some(comp.clone()), &1) + .is_err()); + + h.env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &h.admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &h.contract_id, + fn_name: "set_compliance", + args: (Some(comp.clone()), 1_u32).into_val(&h.env), + sub_invokes: &[], + }, + }]); + h.client.set_compliance(&Some(comp.clone()), &1); + assert_eq!(h.client.config().compliance, Some(comp)); +} + +#[test] +fn test_set_max_share_supply_tightens_cap_and_caps_uncapped_vault() { + let h = default_setup(); + assert_eq!(h.client.config().max_share_supply, 1_000_000_000); + + // Tighten from 1_000_000_000 down to 500_000. + h.client.set_max_share_supply(&500_000); + assert_eq!( + h.env.events().all().events(), + std::vec![MaxShareSupplySet { + previous: 1_000_000_000, + max_share_supply: 500_000, + } + .to_xdr(&h.env, &h.contract_id)], + ); + assert_eq!(h.client.config().max_share_supply, 500_000); + + // Setting the same cap is a valid no-change tightening. + h.client.set_max_share_supply(&500_000); + assert_eq!(h.client.config().max_share_supply, 500_000); + + // Uncapped vault (0) can be given a finite cap at or above total_supply. + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let underlying = env.register(MockToken, ()); + let uncapped_cfg = VaultConfig { + underlying, + exchange_rate: 1, + max_share_supply: 0, + lockup_secs: 60, + name: String::from_str(&env, "Uncapped Vault"), + symbol: String::from_str(&env, "uVAULT"), + compliance: None, + min_compliance_level: 0, + }; + let uncapped_id = env.register(VaultContract, (&admin, &uncapped_cfg)); + let uncapped_client = VaultContractClient::new(&env, &uncapped_id); + + uncapped_client.set_max_share_supply(&250_000); + assert_eq!(uncapped_client.config().max_share_supply, 250_000); +} + +#[test] +fn test_set_max_share_supply_rejects_raising_cap() { + let h = default_setup(); + h.client.set_max_share_supply(&500_000); + + assert_eq!( + h.client.try_set_max_share_supply(&500_001), + Err(Ok(VaultError::InvalidSupplyCap.into())) + ); + assert_eq!(h.client.config().max_share_supply, 500_000); +} + +#[test] +fn test_set_max_share_supply_rejects_removing_cap() { + let h = default_setup(); + h.client.set_max_share_supply(&500_000); + + assert_eq!( + h.client.try_set_max_share_supply(&0), + Err(Ok(VaultError::InvalidSupplyCap.into())) + ); + assert_eq!(h.client.config().max_share_supply, 500_000); +} + +#[test] +fn test_set_max_share_supply_rejects_cap_below_total_supply() { + let h = default_setup(); + let alice = Address::generate(&h.env); + h.set_balance(&alice, 600); + + assert_eq!( + h.client.try_set_max_share_supply(&599), + Err(Ok(VaultError::InvalidSupplyCap.into())) + ); + + // Exact circulating supply is allowed. + h.client.set_max_share_supply(&600); + assert_eq!(h.client.config().max_share_supply, 600); +} + +#[test] +fn test_set_max_share_supply_rejects_negative_cap() { + let h = default_setup(); + assert_eq!( + h.client.try_set_max_share_supply(&-1), + Err(Ok(VaultError::InvalidSupplyCap.into())) + ); +} + +#[test] +fn test_set_max_share_supply_requires_admin_auth() { + let h = default_setup(); + + h.env.mock_auths(&[]); + assert!(h.client.try_set_max_share_supply(&500_000).is_err()); + + h.env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &h.admin, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &h.contract_id, + fn_name: "set_max_share_supply", + args: (500_000_i128,).into_val(&h.env), + sub_invokes: &[], + }, + }]); + h.client.set_max_share_supply(&500_000); + assert_eq!(h.client.config().max_share_supply, 500_000); +} + +// ─── 3. Amount, Balance & Allowance Validations ─────────────────────────────── + +#[test] +fn test_transfer_rejects_zero_or_negative_amount() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 1000); + + let muxed = MuxedAddress::from(bob); + assert_eq!( + h.client.try_transfer(&alice, &muxed, &0), + Err(Ok(VaultError::InvalidAmount.into())) + ); + assert_eq!( + h.client.try_transfer(&alice, &muxed, &-50), + Err(Ok(VaultError::InvalidAmount.into())) + ); +} + +#[test] +fn test_approve_rejects_negative_amount() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + + assert_eq!( + h.client.try_approve(&alice, &bob, &-10, &100), + Err(Ok(VaultError::InvalidAmount.into())) + ); +} + +#[test] +fn test_approve_rejects_past_expiration() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + + h.env.ledger().set_sequence_number(50); + assert_eq!( + h.client.try_approve(&alice, &bob, &100, &49), + Err(Ok(VaultError::InvalidExpiration.into())) + ); + + // Revocation with 0 amount is allowed even in the past. + assert!(h.client.try_approve(&alice, &bob, &0, &10).is_ok()); +} + +#[test] +fn test_transfer_rejects_insufficient_balance() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 100); + + let muxed = MuxedAddress::from(bob); + assert_eq!( + h.client.try_transfer(&alice, &muxed, &101), + Err(Ok(VaultError::InsufficientBalance.into())) + ); +} + +#[test] +fn test_transfer_from_rejects_insufficient_allowance() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let spender = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 1000); + + h.client.approve(&alice, &spender, &200, &1000); + + assert_eq!( + h.client.try_transfer_from(&spender, &alice, &bob, &201), + Err(Ok(VaultError::InsufficientAllowance.into())) + ); +} + +// ─── 4. Lock-up Exact Boundary Tests ────────────────────────────────────────── + +#[test] +fn test_lockup_exact_boundary_enforcement() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 500); + + let lock_expiry = 10_000_u64; + h.set_locked_until(&alice, lock_expiry); + + let muxed = MuxedAddress::from(bob.clone()); + + // 1 second before lock expiry: MUST be refused with SharesLocked. + h.env.ledger().set_timestamp(lock_expiry - 1); + assert_eq!( + h.client.try_transfer(&alice, &muxed, &100), + Err(Ok(VaultError::SharesLocked.into())) + ); + + // Exactly at lock expiry: MUST succeed. + h.env.ledger().set_timestamp(lock_expiry); + assert!(h.client.try_transfer(&alice, &muxed, &100).is_ok()); + assert_eq!(h.client.balance(&alice), 400); + assert_eq!(h.client.balance(&bob), 100); +} + +#[test] +fn test_transfer_from_refused_for_locked_owner() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let spender = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 500); + + let lock_expiry = 50_000_u64; + h.set_locked_until(&alice, lock_expiry); + + h.client.approve(&alice, &spender, &300, &1000); + + h.env.ledger().set_timestamp(lock_expiry - 1); + assert_eq!( + h.client.try_transfer_from(&spender, &alice, &bob, &100), + Err(Ok(VaultError::SharesLocked.into())) + ); + + h.env.ledger().set_timestamp(lock_expiry); + assert!(h + .client + .try_transfer_from(&spender, &alice, &bob, &100) + .is_ok()); + assert_eq!(h.client.balance(&bob), 100); + assert_eq!(h.client.allowance(&alice, &spender), 200); +} + +// ─── 5. Compliance Screening Tests ──────────────────────────────────────────── + +#[test] +fn test_compliance_screening_enforced() { + let h = setup_with_compliance(); + let comp_client = ComplianceContractClient::new(&h.env, h.compliance_id.as_ref().unwrap()); + + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 1000); + + let muxed_bob = MuxedAddress::from(bob.clone()); + + // Neither is compliant -> fails. + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &100), + Err(Ok(VaultError::NotCompliant.into())) + ); + assert_eq!(h.client.balance(&alice), 1000); + assert_eq!(h.client.balance(&bob), 0); + + // Set Alice compliant (level 2) but Bob unverified -> fails. + comp_client.set_kyc( + &alice, + &KycRecord { + jurisdiction: String::from_str(&h.env, "US"), + level: 2, + expires_at: 0, // never expires + }, + ); + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &100), + Err(Ok(VaultError::NotCompliant.into())) + ); + assert_eq!(h.client.balance(&alice), 1000); + assert_eq!(h.client.balance(&bob), 0); + + // Set Bob to level 1 (below min level 2) -> fails. + comp_client.set_kyc( + &bob, + &KycRecord { + jurisdiction: String::from_str(&h.env, "US"), + level: 1, + expires_at: 0, + }, + ); + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &100), + Err(Ok(VaultError::NotCompliant.into())) + ); + assert_eq!(h.client.balance(&alice), 1000); + assert_eq!(h.client.balance(&bob), 0); + + // Set Bob to level 2 -> transfer succeeds. + comp_client.set_kyc( + &bob, + &KycRecord { + jurisdiction: String::from_str(&h.env, "US"), + level: 2, + expires_at: 0, + }, + ); + assert!(h.client.try_transfer(&alice, &muxed_bob, &100).is_ok()); + assert_eq!(h.client.balance(&alice), 900); + assert_eq!(h.client.balance(&bob), 100); + + // Revoke Alice -> transfer fails and changes no state. + comp_client.revoke_kyc(&alice); + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &100), + Err(Ok(VaultError::NotCompliant.into())) + ); + assert_eq!(h.client.balance(&alice), 900); + assert_eq!(h.client.balance(&bob), 100); +} + +#[test] +fn test_unscreened_when_no_compliance_configured() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 500); + + let muxed = MuxedAddress::from(bob.clone()); + assert!(h.client.try_transfer(&alice, &muxed, &250).is_ok()); + assert_eq!(h.client.balance(&bob), 250); +} + +// ─── 6. Muxed Address, Events & Overflow Tests ─────────────────────────────── + +#[test] +fn test_muxed_transfer_credits_base_account() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let muxed = MuxedAddress::generate(&h.env); + let bob = muxed.address(); + let muxed_id = muxed.id(); + h.set_balance(&alice, 1000); + + h.client.transfer(&alice, &muxed, &300); + assert_eq!( + h.env.events().all().events(), + std::vec![Transfer { + from: alice.clone(), + to: bob.clone(), + amount: 300, + to_muxed_id: muxed_id, + } + .to_xdr(&h.env, &h.contract_id)], + ); + + assert_eq!(h.client.balance(&alice), 700); + assert_eq!(h.client.balance(&bob), 300); +} + +#[test] +fn test_approve_and_transfer_from_publish_sep41_events() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let spender = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 1000); + + h.client.approve(&alice, &spender, &400, &1000); + assert_eq!( + h.env.events().all().events(), + std::vec![Approve { + owner: alice.clone(), + spender: spender.clone(), + amount: 400, + live_until_ledger: 1000, + } + .to_xdr(&h.env, &h.contract_id)], + ); + + h.client.transfer_from(&spender, &alice, &bob, &250); + assert_eq!( + h.env.events().all().events(), + std::vec![TransferFrom { + from: alice, + to: bob, + amount: 250, + } + .to_xdr(&h.env, &h.contract_id)], + ); +} + +#[test] +fn test_transfer_overflow_rejected() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.env.as_contract(&h.contract_id, || { + h.env + .storage() + .persistent() + .set(&DataKey::Balance(alice.clone()), &100_i128); + h.env + .storage() + .persistent() + .set(&DataKey::Balance(bob.clone()), &i128::MAX); + }); + + let muxed_bob = MuxedAddress::from(bob); + assert_eq!( + h.client.try_transfer(&alice, &muxed_bob, &1), + Err(Ok(VaultError::Overflow.into())) + ); +} + +#[test] +fn test_self_transfer_is_noop() { + let h = default_setup(); + let alice = Address::generate(&h.env); + h.set_balance(&alice, 1000); + + let muxed = MuxedAddress::from(alice.clone()); + h.client.transfer(&alice, &muxed, &300); + assert!( + h.env.events().all().events().is_empty(), + "self-transfer no-op published an event" + ); + + assert_eq!(h.client.balance(&alice), 1000); +} + +// ─── 7. Storage TTL Policy ─────────────────────────────────────────────────── + +#[test] +fn test_storage_instance_and_persistent_ttl_bumped() { + let h = default_setup(); + let alice = Address::generate(&h.env); + let bob = Address::generate(&h.env); + h.set_balance(&alice, 1000); + h.client.approve(&alice, &bob, &200, &1000); + h.client + .transfer(&alice, MuxedAddress::from(bob.clone()), &100); + + let instance_ttl = h + .env + .as_contract(&h.contract_id, || h.env.storage().instance().get_ttl()); + assert_eq!(instance_ttl, INSTANCE_BUMP_AMOUNT); + + let expected_persistent = persistent_bump_amount(&h.env); + let balance_ttl = h.env.as_contract(&h.contract_id, || { + h.env + .storage() + .persistent() + .get_ttl(&DataKey::Balance(bob.clone())) + }); + assert_eq!(balance_ttl, expected_persistent); + + let allowance_ttl = h.env.as_contract(&h.contract_id, || { + h.env + .storage() + .persistent() + .get_ttl(&DataKey::Allowance(alice.clone(), bob.clone())) + }); + assert_eq!(allowance_ttl, expected_persistent); +} diff --git a/docs/architecture/002-auth-patterns.md b/docs/architecture/002-auth-patterns.md index 2aa995c..f5aade4 100644 --- a/docs/architecture/002-auth-patterns.md +++ b/docs/architecture/002-auth-patterns.md @@ -237,3 +237,19 @@ Trying the flow on testnet before building it settled three things: - **`authorizeEntry` checks nothing.** It signs with whatever key it is given, so the SDK compares the signer with each entry's address before signing, instead of leaving the mismatch to surface as a rejected transaction. + +## Amendment (2026-10) — Phase 2 `vault` authorization map (#57, #69) + +The fractionalization `vault` contract (ADR-006) follows the same explicit-parameter and `require_admin` patterns as `rwa-asset`, with no public `burn` or `burn_from` entry points: + +| Contract | Entry point | Must authenticate | Notes | +|---|---|---|---| +| `vault` | `__constructor` | the named `admin` | Asserted in `tests/test_constructor_auth.rs` | +| | `transfer` | `from` | SEP-41 share transfer | +| | `approve` | `owner` | SEP-41 allowance with `live_until_ledger` | +| | `transfer_from` | `spender` | Not `from` — the allowance is the authority | +| | `set_paused` | admin | Same-ledger emergency pause | +| | `transfer_admin` | admin **and** `new_admin` | NFR-S-4 | +| | `set_compliance` | admin | Rebinds or clears compliance screening (#69) | +| | `set_max_share_supply` | admin | Tighten-only share supply cap (#69) | +| | `balance`, `total_supply`, `allowance`, `decimals`, `name`, `symbol`, `locked_until`, `admin`, `paused`, `config`, `underlying_held` | nobody | Pure reads | diff --git a/docs/architecture/003-upgrade-path.md b/docs/architecture/003-upgrade-path.md index e568479..8bac732 100644 --- a/docs/architecture/003-upgrade-path.md +++ b/docs/architecture/003-upgrade-path.md @@ -38,10 +38,11 @@ Fixing a contract means deploying a new one and moving traffic to it. How well t | `registry` | Asset directory | Redeploy, re-`register` each asset. The directory is a convenience index, re-derivable from the operator's records | | `governance` | Proposals and votes | Redeploy. History is preserved in events; in-flight proposals are lost | | `rwa-asset` | **Holder balances** | **There is none** | +| `vault` | **Share balances & underlying custody** | **There is none** (every holder would have to redeem and re-deposit) | -That last row is the important one. Balances live in the asset contract's own persistent storage, keyed by holder address. A redeployed asset contract starts at zero supply with zero holders, and nothing in the protocol can move the old balances across — reading another contract's storage directly is not something Soroban permits, and even a migration entry point on the new contract would need every holder to act. +That last pair is the important one. Balances live in the asset or vault contract's own persistent storage, keyed by holder address. A redeployed asset or vault contract starts at zero supply with zero holders, and nothing in the protocol can move the old balances across — reading another contract's storage directly is not something Soroban permits, and even a migration entry point on the new contract would need every holder to act. -**So `rwa-asset` is, for practical purposes, permanent once it has holders.** Not by an explicit decision to make it so, but as a consequence of two decisions taken together: no upgrade entry point, and state that cannot be exported. +**So `rwa-asset` and `vault` are, for practical purposes, permanent once they have holders.** Not by an explicit decision to make them so, but as a consequence of two decisions taken together: no upgrade entry point, and state that cannot be exported. ### Error discriminants are frozen @@ -49,7 +50,7 @@ Every `#[contracterror]` enum pins its variants with `#[repr(u32)]` and explicit > Discriminants are part of the contract's public interface: clients match on the numeric code, so existing variants must keep their values and new ones must be appended. Renumbering silently changes what a deployed client believes went wrong. -The current allocations are `RwaError` 1–14, `GovernanceError` 1–11, `RegistryError` 1–5, `ComplianceError` 1–5, and for Phase 2 `OracleError` 1–13. +The current allocations are `RwaError` 1–25, `GovernanceError` 1–16, `RegistryError` 1–7, `ComplianceError` 1–8, and for Phase 2 `OracleError` 1–14 and `VaultError` 1–17. The word doing the work is *silently*. A renumbered variant does not break a build or fail a test. A deployed SDK that maps 11 to "not compliant" keeps mapping 11 to "not compliant", and simply starts telling people the wrong thing about why their transfer failed. Nothing anywhere reports an error, which is the property that makes this worth an ADR rather than a comment. diff --git a/docs/architecture/004-cross-contract-interaction.md b/docs/architecture/004-cross-contract-interaction.md index 95b0b81..31c5b92 100644 --- a/docs/architecture/004-cross-contract-interaction.md +++ b/docs/architecture/004-cross-contract-interaction.md @@ -62,16 +62,18 @@ The reasoning is ADR-001's, applied: a KYC record is written once and read forev ### What gets screened, and what deliberately does not -| Operation | Screened | -|---|---| -| `mint` | `to` | -| `transfer` | `from` and `to` | -| `transfer_from` | `from` and `to` | -| `burn` | nobody | -| `burn_from` | nobody | -| `approve` | nobody | - -`transfer_from` screens the owner and the recipient, not the spender. The spender moves value it does not own; the parties to the movement are the ones a regulator cares about. +| Contract | Operation | Screened | +|---|---|---| +| `rwa-asset` | `mint` | `to` | +| `rwa-asset` | `transfer` | `from` and `to` | +| `rwa-asset` | `transfer_from` | `from` and `to` | +| `rwa-asset` | `forced_transfer` | `from` and `to` | +| `rwa-asset` | `burn`, `burn_from`, `approve` | nobody | +| `vault` | `transfer` | `from` and `to` | +| `vault` | `transfer_from` | `from` and `to` | +| `vault` | `approve`, `set_compliance`, `set_max_share_supply` | nobody | + +`transfer_from` (on both `rwa-asset` and `vault`) screens the owner and the recipient, not the spender. The spender moves value it does not own; the parties to the movement are the ones a regulator cares about. On `vault`, `set_compliance(compliance, min_level)` (#69) binds or clears the compliance contract at runtime using the same `ComplianceInterface::screen` trait and `MAX_COMPLIANCE_LEVEL = 3` ceiling as `rwa-asset`. **`burn` screening nobody is deliberate.** A holder whose KYC has lapsed — expired record, revoked verification — can still destroy their own tokens. The alternative traps them: unable to transfer, unable to exit, holding a position they cannot close. Burning reduces supply and moves value to no one, so there is no counterparty to screen and no transfer to prevent. An audit checklist that flags "a balance-changing operation with no compliance check" will flag this line; it is correct as written. `burn_from`, added with SEP-41 alignment (IR-09), screens nobody for the same reason: it destroys a holder's tokens rather than moving them to anyone. diff --git a/docs/architecture/006-vault.md b/docs/architecture/006-vault.md new file mode 100644 index 0000000..8baba6e --- /dev/null +++ b/docs/architecture/006-vault.md @@ -0,0 +1,106 @@ +# ADR-006: Fractionalization Vault & Share Token Architecture + +**Status:** Accepted +**Date:** 2026-09-18 +**Authors:** StellarForge Core Team + +--- + +## Context + +Institutional Real World Assets (RWAs) often possess large unit valuations (e.g., commercial real estate, private credit facilities) that require fractionalization to facilitate broader investor participation and on-chain liquidity (PRD §5.4, FR-06). + +However, tokenized vault design in Web3 suffers from critical security risks: +1. **Oracle Manipulation Threats (PRD §10.1):** Malicious actors manipulate oracle price feeds to deposit undervalued assets or redeem disproportionately large shares. +2. **ERC-4626 Inflation / Donation Attacks:** First-depositor attacks manipulate the internal exchange rate (share price) through direct asset donations to the vault contract. +3. **Regulatory Compliance and Lock-ups (PRD §11.4):** Securities laws (e.g., SEC Reg D holding periods) mandate strict transfer restrictions and accredited investor verification for all share movements. +4. **Historical Balance Queries for Yield (FR-07-2):** Fair dividend and yield distribution requires deterministic, historical balance verification at discrete snapshot ledgers. + +Phase 1 contracts have no upgrade path (ADR-003). Therefore, the vault foundation and share token mechanics must be secure and immutable from deployment. + +--- + +## Decision + +### 1. The vault is its own SEP-41 share token +Each deployed vault contract wraps exactly one underlying `rwa-asset`. The vault contract implements the SEP-41 token interface (`balance`, `total_supply`, `allowance`, `approve`, `transfer`, `transfer_from`, `decimals`, `name`, `symbol`), with **one deliberate exception: there is no public `burn` or `burn_from`**. + +Redemption (#58) is the only valid mechanism by which shares leave circulation. Exposing a public `burn` would permanently strand underlying assets in the vault with no corresponding shares to redeem them, breaking the vault's core solvency invariant. + +### 2. Fixed exchange rate, redeemed in kind +- Depositing `X` underlying tokens mints exactly `X × exchange_rate` shares (FR-06-2). +- Redeeming `Y` shares returns exactly `Y / exchange_rate` underlying tokens (FR-06-3). +- `exchange_rate` is a positive integer fixed at contract construction. Redemptions must be exact integer multiples of `exchange_rate`. + +*Reasoning:* If deposit and redemption exchange rates are fixed and decoupled from variable market prices, oracle manipulation cannot extract value from the vault. This also inherently eliminates the ERC-4626 first-depositor inflation attack. + +### 3. NAV is reported, never traded on +Net Asset Value (`nav()` and `nav_per_share()`) is reported by reading primary and secondary SEP-40 oracles (`oracle-adapter`, ADR-005) with staleness validation and divergence circuit breakers. However, `deposit` and `redeem` never query oracle feeds. + +### 4. Internal accounting +`underlying_held` tracks verified contract deposits minus redemptions. Tokens sent directly to the vault contract without invoking `deposit` are treated as uncredited donations and cannot be redeemed. + +### 5. Per-holder lock-up enforcement +Deposits set `locked_until = max(current, now + lockup_secs)`. While `now < locked_until(holder)`, the holder is strictly prohibited from redeeming or transferring shares (including via `transfer_from`). + +### 6. Compliance screening on share movements +Every share transfer screens both the sender and recipient via `screen(subject, min_level)` (ADR-004). +**The vault's own contract address must hold a valid KYC record** in the underlying asset's compliance contract so that deposit and redemption transfers succeed. + +### 7. Balance checkpoints +Balance mutations route through a single internal write path (`write_balance`), allowing #60 to hook historical snapshots (`balance_at(holder, ledger)`) for `yield-distributor`. + +### 8. `VaultConfig` mutability policy (#69) +Phase 1 and 2 contracts expose no upgrade entry point (ADR-003), so any field without an explicit setter is frozen for the life of the deployment. Each field of `VaultConfig` (plus `decimals`) is ruled on individually: + +| Field | Mutability | Setter / Mechanism | Rationale | +|---|---|---|---| +| `compliance`, `min_compliance_level` | **Mutable** | `set_compliance(compliance, min_level)` | A deployment-time mistake or a retired compliance contract would otherwise brick all share transfers and redemptions (`NotCompliant`) forever with no recovery path. Matches `rwa-asset::set_compliance`. Both `__constructor` and `set_compliance` reject `min_level > MAX_COMPLIANCE_LEVEL` (`3`, matching `compliance::MAX_LEVEL`) with `VaultError::InvalidComplianceLevel` (`16`). | +| `max_share_supply` | **Tighten-only** | `set_max_share_supply(max_share_supply)` | Matches `rwa-asset` (#54 / IR-02): the admin may lower a finite cap or introduce a finite cap on an uncapped vault (`0`), provided the new cap is not below `total_supply()`. Raising a finite cap or lifting it to uncapped (`0`) is refused with `VaultError::InvalidSupplyCap` (`17`), letting the cap respond to a downward appraisal with zero dilution risk. | +| `lockup_secs` | **Immutable** | Set once in `__constructor` | Lock-up is tracked per holder as a single rolling timestamp (`locked_until = max(current, now + lockup_secs)`). Raising `lockup_secs` post-deployment would let an admin trap an existing holder's previously unlocked balance on any follow-on deposit, altering the terms they subscribed under; lowering it mid-flight would weaken the regulatory holding-period guarantee (e.g., SEC Reg D) existing holders relied on. | +| `underlying`, `exchange_rate`, `decimals` | **Immutable** | Set once in `__constructor` | Define what a share is and anchor the backing invariant (`total_supply() == underlying_held() × exchange_rate`). Changing any of them after a single share exists breaks backing solvency or re-denominates every holder's balance. | +| `name`, `symbol` | **Immutable** | Set once in `__constructor` | Identify the share token instrument. Unlike `rwa-asset` (which bundles `name` and `symbol` with `legal_doc_hash` and `max_supply` in `update_metadata`), the vault has no `legal_doc_hash` of its own and exposes a dedicated `set_max_share_supply` entry point, so freezing `name` and `symbol` prevents post-issuance ticker spoofing. | + +### 9. Architectural conventions +- Deployment configuration via `VaultConfig` requiring `admin.require_auth()` (IR-17, ADR-002). +- Share `decimals` read immutably from the underlying token at construction. +- Emergency pause (`set_paused`) taking effect in the same ledger (NFR-R-2). +- Dual-authorization admin handover (`transfer_admin`, NFR-S-4). +- Admin-authorized `set_compliance` and tighten-only `set_max_share_supply`, emitting `ComplianceSet` and `MaxShareSupplySet` events (#69). +- Storage TTL policies maintained via `stellarforge-common` (ADR-001). +- Frozen error discriminants (`VaultError` 1–17, ADR-003). + +### Core Invariants +- `total_supply() == underlying_held() × exchange_rate` after every state transition. +- $\sum \text{balance}(\text{holder}) == \text{total\_supply}()$. +- `underlying_held() <= underlying.balance(vault)`. +- `total_supply() <= max_share_supply` when a supply cap is configured. +- No sequence of transactions permits transfers or redemptions while `now < locked_until(holder)`. +- Oracle outputs never influence deposit or redemption amounts. + +--- + +## Alternatives Considered + +1. **NAV-Priced Subscriptions and Redemptions:** + *Rejected.* Exposing value-moving operations to oracle feeds re-introduces oracle manipulation vectors (§10.1). +2. **ERC-4626 Dynamic Pricing:** + *Rejected.* Prone to rounding exploits, donation attacks, and requires fractional math approximations. +3. **A Separate Share-Token Contract:** + *Rejected.* A two-contract architecture doubles cross-contract call hops, increases invocation gas overhead, and introduces split-state desynchronization risks. +4. **Lock-Up Lots Per Deposit:** + *Rejected.* Tracking individual deposit lots requires unbounded storage vectors and variable gas costs. Rolling single-timestamp expiration (`max(current, now + lockup_secs)`) provides bounded $O(1)$ verification. +5. **Third-Party `deposit(to)`:** + *Rejected.* Allowing arbitrary third-party deposit recipients would allow malicious actors to extend another holder's lock-up against their will. + +--- + +## Consequences + +- **Unredeemable Dust:** When `exchange_rate > 1`, share quantities that are not integer multiples of `exchange_rate` cannot be redeemed individually. +- **Stranded Direct Transfers:** Direct token transfers to the vault address do not increment `underlying_held` and cannot be recovered. +- **SEP-41 Deviation:** Deliberate omission of public `burn` and `burn_from` preserves backing solvency. +- **Vault KYC Requirement:** The vault contract address must be registered and verified in the underlying compliance contract before accepting deposits. +- **Lapsed-KYC Holders:** A holder whose KYC expires cannot transfer or redeem. Unlike `rwa-asset.burn` (which stays open for uncompliant holders), the vault cannot offer a burn without stranding underlying assets. Lapsed holders must renew compliance or undergo legal off-chain settlement. +- **Compliance Rebinding Trust Assumption (#69):** As on `rwa-asset`, `set_compliance` lets the admin re-point the vault at a replacement compliance contract or pass `None` to disable screening. Every change publishes `ComplianceSet` so monitors can detect when screening is altered or disabled. +- **One-Way Supply Cap Tightening (#69):** `set_max_share_supply` can only lower a finite cap or cap a previously uncapped vault at or above `total_supply()`. A cap lowered in error cannot be raised on the same vault deployment, and `lockup_secs` cannot be adjusted after deployment without redeploying a new vault. diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..af653f0 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# doctor.sh — Verify that the local StellarForge development toolchain is +# installed and matches the versions pinned across the repository. +# +# Usage: +# make doctor +# ./scripts/doctor.sh +# +# Checks every required tool in one pass rather than stopping at the first +# failure, printing the expected version beside each result and exiting +# non-zero if anything is missing or mismatched. + +set -uo pipefail + +EXPECTED_RUSTC="1.98.1" +if [[ -f rust-toolchain.toml ]]; then + parsed_rustc=$(sed -n 's/^channel *= *"\([^"]*\)".*/\1/p' rust-toolchain.toml) + [[ -n "$parsed_rustc" ]] && EXPECTED_RUSTC="$parsed_rustc" +fi + +EXPECTED_WASM_TARGET="wasm32v1-none" +EXPECTED_STELLAR_CLI="27.1.0" +if [[ -f .github/workflows/contracts-ci.yml ]]; then + parsed_stellar=$(sed -n 's/^ *STELLAR_CLI_VERSION: *"\([^"]*\)".*/\1/p' .github/workflows/contracts-ci.yml) + [[ -n "$parsed_stellar" ]] && EXPECTED_STELLAR_CLI="$parsed_stellar" +fi + +EXPECTED_NODE_MIN="22.12.0" +EXPECTED_NPM_MIN="10.0.0" + +failures=0 + +pass() { + printf " [OK] %-18s %-20s (expected: %s)\n" "$1" "$2" "$3" +} + +fail() { + printf " [FAIL] %-18s %-20s (expected: %s)\n" "$1" "$2" "$3" >&2 + failures=$((failures + 1)) +} + +version_ge() { + local actual="$1" + local required="$2" + local IFS=. + local i a=($actual) r=($required) + for ((i = 0; i < ${#r[@]}; i++)); do + local av="${a[i]:-0}" + local rv="${r[i]:-0}" + if ((10#$av > 10#$rv)); then + return 0 + elif ((10#$av < 10#$rv)); then + return 1 + fi + done + return 0 +} + +echo "Checking StellarForge toolchain..." + +# 1. rustc +if command -v rustc >/dev/null 2>&1; then + rustc_ver=$(rustc --version 2>/dev/null | awk '{print $2}') + if [[ "$rustc_ver" == "$EXPECTED_RUSTC" ]]; then + pass "rustc" "$rustc_ver" "$EXPECTED_RUSTC" + else + fail "rustc" "${rustc_ver:-unknown}" "$EXPECTED_RUSTC" + fi +else + fail "rustc" "missing" "$EXPECTED_RUSTC" +fi + +# 2. wasm32v1-none target +if command -v rustup >/dev/null 2>&1; then + if rustup target list --installed 2>/dev/null | grep -qx "$EXPECTED_WASM_TARGET"; then + pass "wasm target" "$EXPECTED_WASM_TARGET" "$EXPECTED_WASM_TARGET installed" + else + fail "wasm target" "not installed" "$EXPECTED_WASM_TARGET installed" + fi +else + fail "wasm target" "rustup missing" "$EXPECTED_WASM_TARGET installed" +fi + +# 3. stellar-cli +if command -v stellar >/dev/null 2>&1; then + stellar_ver=$(stellar --version 2>/dev/null | head -n 1 | awk '{print $2}') + if [[ "$stellar_ver" == "$EXPECTED_STELLAR_CLI" ]]; then + pass "stellar-cli" "$stellar_ver" "$EXPECTED_STELLAR_CLI" + else + fail "stellar-cli" "${stellar_ver:-unknown}" "$EXPECTED_STELLAR_CLI" + fi +else + fail "stellar-cli" "missing" "$EXPECTED_STELLAR_CLI" +fi + +# 4. node +if command -v node >/dev/null 2>&1; then + node_ver=$(node --version 2>/dev/null | sed 's/^v//') + if [[ -n "$node_ver" ]] && version_ge "$node_ver" "$EXPECTED_NODE_MIN"; then + pass "node" "$node_ver" ">= $EXPECTED_NODE_MIN" + else + fail "node" "${node_ver:-unknown}" ">= $EXPECTED_NODE_MIN" + fi +else + fail "node" "missing" ">= $EXPECTED_NODE_MIN" +fi + +# 5. npm +if command -v npm >/dev/null 2>&1; then + npm_ver=$(npm --version 2>/dev/null | tr -d '[:space:]') + if [[ -n "$npm_ver" ]] && version_ge "$npm_ver" "$EXPECTED_NPM_MIN"; then + pass "npm" "$npm_ver" ">= $EXPECTED_NPM_MIN" + else + fail "npm" "${npm_ver:-unknown}" ">= $EXPECTED_NPM_MIN" + fi +else + fail "npm" "missing" ">= $EXPECTED_NPM_MIN" +fi + +if ((failures > 0)); then + echo "" >&2 + echo "doctor: $failures check(s) failed. See CONTRIBUTING.md for installation instructions." >&2 + exit 1 +fi + +echo "" +echo "All toolchain checks passed." diff --git a/tools/benchmark/src/benches/compliance.rs b/tools/benchmark/src/benches/compliance.rs index 6ffb3df..7b710c4 100644 --- a/tools/benchmark/src/benches/compliance.rs +++ b/tools/benchmark/src/benches/compliance.rs @@ -52,8 +52,10 @@ pub fn run() -> Vec { rows.push(measure(&env, CONTRACT, "revoke_kyc")); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/governance.rs b/tools/benchmark/src/benches/governance.rs index a3f5ab7..7263909 100644 --- a/tools/benchmark/src/benches/governance.rs +++ b/tools/benchmark/src/benches/governance.rs @@ -46,8 +46,10 @@ pub fn run() -> Vec { rows.push(measure(&env, CONTRACT, "admin")); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/oracle_adapter.rs b/tools/benchmark/src/benches/oracle_adapter.rs index b39e491..1a94c51 100644 --- a/tools/benchmark/src/benches/oracle_adapter.rs +++ b/tools/benchmark/src/benches/oracle_adapter.rs @@ -85,8 +85,10 @@ pub fn run() -> Vec { rows.push(measure(&env, CONTRACT, "set_max_deviation")); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/registry.rs b/tools/benchmark/src/benches/registry.rs index a32712a..1d0750b 100644 --- a/tools/benchmark/src/benches/registry.rs +++ b/tools/benchmark/src/benches/registry.rs @@ -46,8 +46,10 @@ pub fn run() -> Vec { client.set_paused(&false); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows } diff --git a/tools/benchmark/src/benches/rwa_asset.rs b/tools/benchmark/src/benches/rwa_asset.rs index 084232c..93e6a57 100644 --- a/tools/benchmark/src/benches/rwa_asset.rs +++ b/tools/benchmark/src/benches/rwa_asset.rs @@ -108,8 +108,10 @@ pub fn run() -> Vec { client.set_compliance(&None, &0); let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - rows.push(measure(&env, CONTRACT, "transfer_admin")); + client.propose_admin(&new_admin); + rows.push(measure(&env, CONTRACT, "propose_admin")); + client.accept_admin(); + rows.push(measure(&env, CONTRACT, "accept_admin")); rows }