From 1ef9b54a36bd6664fdc225057b881e2fc7d6c122 Mon Sep 17 00:00:00 2001 From: northersubair <294447266+northersubair@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:25:12 +0100 Subject: [PATCH 1/2] Pin Rust toolchain, document crates, add deploy scripts and threat model SC-53: pin the toolchain - Add contracts/rust-toolchain.toml pinning Rust 1.96.0 with rustfmt, clippy and the wasm32-unknown-unknown target. - Every contracts CI job now reads the channel from that file instead of floating `stable`, so a new stable release cannot spontaneously break CI and local builds match CI exactly. SC-54: per-crate documentation - Add a README to each of the five crates covering purpose, invariants, storage layout, an entrypoint table with auth requirements, emitted events, errors, and a worked example. - Add a workspace README with the crate map and an explicit explanation of the assetsup/contrib relationship. - Add crate-level //! docs to every lib.rs; cargo doc --no-deps is now warning-free. SC-55: reproducible deployment - Add scripts/deploy.sh handling build, optimize, deploy, initialize and verify, wiring cross-contract addresses in dependency order. - Contract ids are written to a git-ignored deployments/.json with a committed example. No key material is read, written, or logged. SC-56: threat model and checklist - Add contracts/SECURITY.md with the trust model, per-contract assets at risk, known accepted risks, a pre-deployment checklist, and a disclosure process. - Add a PR template that references the checklist for contract changes. The entrypoint tables record the authorization gaps found while documenting: assetsup's register_asset, update_asset_metadata, transfer_asset_ownership and retire_asset check a caller-supplied address without calling require_auth on it, and several asset-maintenance entrypoints have no authorization at all. These are documented here and tracked for fixing in SC-42. Closes #1217 Closes #1218 Closes #1219 Closes #1220 --- .github/pull_request_template.md | 59 ++++++ .github/workflows/CI.yaml | 35 +++- contracts/.gitignore | 8 +- contracts/README.md | 119 +++++++++++ contracts/SECURITY.md | 171 ++++++++++++++++ contracts/asset-maintenance/README.md | 160 +++++++++++++++ contracts/asset-maintenance/src/lib.rs | 25 +++ contracts/assetsup/README.md | 219 ++++++++++++++++++++ contracts/assetsup/src/lib.rs | 29 +++ contracts/assetsup/src/types.rs | 4 +- contracts/contrib/README.md | 186 +++++++++++++++++ contracts/contrib/src/lib.rs | 28 +++ contracts/deployments/testnet.example.json | 12 ++ contracts/multisig-wallet/README.md | 151 ++++++++++++++ contracts/multisig-wallet/src/lib.rs | 27 +++ contracts/multisig_transfer/README.md | 126 ++++++++++++ contracts/multisig_transfer/src/lib.rs | 30 +++ contracts/rust-toolchain.toml | 14 ++ contracts/scripts/README.md | 91 +++++++++ contracts/scripts/deploy.sh | 227 +++++++++++++++++++++ 20 files changed, 1708 insertions(+), 13 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 contracts/README.md create mode 100644 contracts/SECURITY.md create mode 100644 contracts/asset-maintenance/README.md create mode 100644 contracts/assetsup/README.md create mode 100644 contracts/contrib/README.md create mode 100644 contracts/deployments/testnet.example.json create mode 100644 contracts/multisig-wallet/README.md create mode 100644 contracts/multisig_transfer/README.md create mode 100644 contracts/rust-toolchain.toml create mode 100644 contracts/scripts/README.md create mode 100755 contracts/scripts/deploy.sh diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..0cadc0113 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ + + +## Summary + + + +## Linked issues + +Closes # + +## Checks + +- [ ] Lint passes for every area touched +- [ ] Build passes +- [ ] Tests pass +- [ ] No secrets, keys, or `.env` values are committed + +
+Contract changes only — expand if this PR touches contracts/ + +Run from `contracts/`: + +```sh +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all +``` + +- [ ] All three pass +- [ ] Reviewed against the relevant sections of + [`contracts/SECURITY.md`](../blob/main/contracts/SECURITY.md#pre-deployment-checklist) + +Confirm the sections that apply to this change: + +- [ ] **Authorization** — every new or modified state-changing entrypoint calls + `require_auth()` on the correct principal, and no entrypoint treats a + caller-supplied address argument as proof of identity. Negative tests + exist **without** `mock_all_auths`. +- [ ] **Arithmetic** — no unchecked arithmetic on any path handling amounts, + shares, or percentages; overflow returns a typed error. +- [ ] **Storage and TTL** — correct durability chosen, and persistent entries + that must outlive the default are extended. +- [ ] **Pause** — new mutating entrypoints respect the pause guard. +- [ ] **Admin and upgrades** — privileged entrypoints are admin-gated and emit + an event. +- [ ] **Events** — every new state change emits an observable event, and the + event catalogue is updated. +- [ ] **Size** — WASM size impact considered for new dependencies or large code + additions. + +If this PR knowingly leaves one of these open, say which and why: + +
diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index c4e53b555..3b7854c78 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -19,10 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Read pinned toolchain + id: toolchain + run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: - toolchain: stable + toolchain: ${{ steps.toolchain.outputs.channel }} components: rustfmt - name: Check formatting run: cargo fmt --all -- --check @@ -32,10 +35,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Read pinned toolchain + id: toolchain + run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: - toolchain: stable + toolchain: ${{ steps.toolchain.outputs.channel }} components: clippy - name: Cache cargo registry uses: actions/cache@v4 @@ -55,10 +61,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Read pinned toolchain + id: toolchain + run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: - toolchain: stable + toolchain: ${{ steps.toolchain.outputs.channel }} - name: Cache cargo registry uses: actions/cache@v4 with: @@ -78,10 +87,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Read pinned toolchain + id: toolchain + run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: - toolchain: stable + toolchain: ${{ steps.toolchain.outputs.channel }} - name: Cache cargo registry uses: actions/cache@v4 with: @@ -101,10 +113,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Read pinned toolchain + id: toolchain + run: echo "channel=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: - toolchain: stable + toolchain: ${{ steps.toolchain.outputs.channel }} targets: wasm32-unknown-unknown - name: Cache cargo registry uses: actions/cache@v4 diff --git a/contracts/.gitignore b/contracts/.gitignore index a7eaa152b..89a6be20b 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -1,3 +1,9 @@ target .env -test_snapshots/ \ No newline at end of file +test_snapshots/ + +# Deployment records hold live contract ids per network and are produced by +# scripts/deploy.sh. They are environment state, not source. The committed +# deployments/testnet.example.json documents the shape. +deployments/* +!deployments/*.example.json diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 000000000..1a034fa23 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,119 @@ +# AssetsUp Soroban Contracts + +Stellar/Soroban smart contracts backing the AssetsUp asset-management platform. +The workspace holds five crates covering the asset registry, multisig approval +of high-value transfers, escrow and KYC, and on-chain maintenance history. + +## Crate map + +| Crate | Directory | Deployable | Responsibility | +|---|---|---|---| +| `assetsup` | [`assetsup/`](assetsup/) | yes | Primary asset registry: registration, ownership transfer, tokenization, dividends, voting, leasing, insurance, detokenization. | +| `contrib` | [`contrib/`](contrib/) | yes | Secondary registry carrying the capabilities `assetsup` does not have: escrow, KYC, staking, price oracle, and an emergency pause. | +| `multisig-wallet` | [`multisig-wallet/`](multisig-wallet/) | yes | General-purpose *m-of-n* wallet: transaction submission, confirmation, execution, owner/threshold governance, emergency freeze. | +| `multisig-transfer` | [`multisig_transfer/`](multisig_transfer/) | yes | Approval workflow for asset transfers, gated on per-category approval rules. Calls into a registry contract to move ownership. | +| `asset-maintenance` | [`asset-maintenance/`](asset-maintenance/) | yes | On-chain maintenance history, schedules, warranties, provider registry, and alerts. | + +All five build to WASM and are deployable. `assetsup`, `contrib`, +`multisig-wallet`, and `multisig-transfer` additionally declare `crate-type = +["lib", "cdylib"]` so they can be imported by integration tests; only +`asset-maintenance` is `cdylib`-only. + +Every crate has its own README with an entrypoint table, storage layout, +emitted events, and error list. Start there. + +## How `assetsup` and `contrib` relate + +This is the most common source of confusion in the workspace, because the two +crates share several module names (`audit`, `detokenization`, `insurance`, +`lease`, `tokenization`, and transfer restrictions). + +They are **two independent contracts, deployed separately, with separate +storage**. Neither reads the other's state. The overlap is duplicated code, not +a shared library: + +- **`assetsup` is the authoritative asset registry.** It is the larger crate + (~8,900 lines), it is the one the backend is expected to treat as the source + of truth for asset identity and ownership, and it is the only one with + dividends, voting, and detokenization proposals wired to a token supply. +- **`contrib` is a second registry with a different capability set.** It carries + escrow, KYC, staking, and an oracle — none of which exist in `assetsup` — plus + its own `pause` module, which `assetsup` only partially has. + +Where a module name appears in both, the implementations have diverged and are +not interchangeable. `assetsup::insurance` models policies and claims with a +full claim state machine; `contrib::insurance` is a smaller policy/claim store. +The same holds for `lease` and `audit`. + +Resolving this duplication — deciding which crate owns each concern — is +tracked in [SC-46]. Until that lands, treat the two as separate contracts and +consult the per-crate README for the behaviour of the specific one you are +calling. + +## Toolchain + +The Rust toolchain is **pinned** in [`rust-toolchain.toml`](rust-toolchain.toml). +`rustup` picks it up automatically for any command run inside `contracts/`, and +every contracts job in [`.github/workflows/CI.yaml`](../.github/workflows/CI.yaml) +reads the same `channel` value, so local and CI builds always use the same +compiler. + +Current pin: **Rust 1.96.0**, with the `rustfmt` and `clippy` components and the +`wasm32-unknown-unknown` target. + +Floating `stable` is deliberately avoided. A new stable release can introduce +clippy lints that fail `-D warnings` on a pull request that changed nothing, +turning an unrelated Rust release into a broken build for every open PR. + +### Upgrading the toolchain + +Bump the toolchain **in its own dedicated pull request** so that any lint churn +is isolated from feature work and reviewable on its own: + +1. Edit `channel` in `contracts/rust-toolchain.toml`. +2. Run `cargo fmt --all`, then `cargo clippy --all-targets --all-features -- -D warnings` + and fix any lints the new release introduced. +3. Confirm `cargo test --all` still passes and that the pinned `soroban-sdk` + version still compiles — the SDK sets its own MSRV and can lag new releases. +4. Open the PR with only the toolchain bump and its lint fixes. + +CI needs no change: it reads the channel from the file. + +## Local development + +```sh +cd contracts + +cargo build --all +cargo test --all +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +``` + +CI enforces all four. Run them before opening a pull request. + +Build a contract for deployment: + +```sh +cargo build --package assetsup --target wasm32-unknown-unknown --release +``` + +## Deployment + +Scripted, reproducible testnet deployment lives in +[`scripts/deploy.sh`](scripts/deploy.sh). See +[`scripts/README.md`](scripts/README.md) for prerequisites and usage. + +## Security + +The trust model, per-contract attack surface, and the pre-deployment checklist +are documented in [`SECURITY.md`](SECURITY.md). Contract changes are reviewed +against that checklist. + +## Naming conventions + +- Crate (package) names use **hyphens**: `multisig-wallet`, `asset-maintenance`. +- Directory names should match the package name. One exception remains today — + the directory `multisig_transfer/` holds the package `multisig-transfer`, + which is why `cargo test -p multisig_transfer` fails while + `-p multisig-transfer` works. Tracked in [SC-33]. diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md new file mode 100644 index 000000000..92d840406 --- /dev/null +++ b/contracts/SECURITY.md @@ -0,0 +1,171 @@ +# Contract security + +Threat model, trust assumptions, and the pre-deployment checklist for the five +crates in `contracts/`. Contract changes are reviewed against the +[checklist](#pre-deployment-checklist) below. + +**These contracts have not been externally audited.** They govern asset +ownership, multisig approvals, escrow, KYC, and dividends. Treat everything here +as pre-production until an audit is complete and the open items in +[Known accepted risks](#known-accepted-risks) are closed. + +## Trust model + +### Who is trusted + +| Principal | Held by | Can do | Blast radius if compromised | +|---|---|---|---| +| **Contract admin** | One address per contract, set at `initialize` | Add/remove registrars, pause and unpause, change admin, approve KYC, manage oracle sources, register providers | **Total.** Can authorize itself as a registrar, register or retire arbitrary assets, and hand the admin role to an attacker. Admin transfer is single-step, so a compromised admin can lock out the legitimate operator irreversibly. | +| **Authorized registrar** | Addresses on the `assetsup`/`contrib` allowlist | Register assets, update asset metadata | Can mint fraudulent asset records and pollute the registry. Cannot directly steal an existing asset in `contrib`. | +| **Asset owner** | The `owner` field of an asset | Transfer, retire, tokenize, lease, insure their own asset | Limited to that owner's assets. | +| **Multisig signer** | Members of `owners` in `multisig-wallet` | Submit, confirm, and propose; *m* of them can execute anything | *m* compromised signers is equivalent to full wallet control. Fewer than *m* can grief by consuming ids but cannot execute. | +| **Approver** | Addresses satisfying a `multisig-transfer` `ApprovalRule` | Approve or reject transfer requests | Enough colluding approvers can move any asset in the category they govern. | +| **Oracle source** | Allowlisted addresses in `contrib::oracle` | Write asset valuations | Can distort valuations, which feed dividend and share calculations. | +| **Backend signer** | The service account the API signs with | Whatever role it has been granted on-chain | It is a hot key in a server process. Grant it the narrowest role that works — registrar, never admin. | + +### Who is not trusted + +Everyone else. Any address can call any entrypoint; Soroban does **not** +authenticate callers implicitly. An entrypoint is protected only if it calls +`require_auth()` on the correct address. + +### Trust boundaries + +- **Backend → contracts.** The backend is an ordinary client. Contracts must not + assume the backend validated anything. +- **`multisig-transfer` → registry.** `multisig-transfer` calls into the + registry contract stored at `AssetRegistry` to move ownership. That registry + address is set at `initialize` with no authorization and is never re-verified. +- **`assetsup` ↔ `contrib`.** No trust relationship — they are independent + deployments with separate storage that never call each other. + +## Assets at risk, by contract + +| Contract | What an attacker gains | Highest-risk entrypoints | +|---|---|---| +| `assetsup` | Ownership of any registered asset; fractional share balances; undistributed dividends | `transfer_asset_ownership`, `register_asset`, `retire_asset`, `mint_tokens`, `distribute_dividends` | +| `contrib` | Escrowed value; KYC approval status; staked balances; valuation feed | `create_escrow`, `confirm_release`, `approve_kyc`, `update_valuation`, `unstake_tokens` | +| `multisig-wallet` | Anything the wallet controls | `execute_transaction`, `execute_proposal`, `emergency_unfreeze` | +| `multisig-transfer` | Ownership of assets whose category rule it governs | `execute_transfer`, `configure_approval_rule`, `initialize` | +| `asset-maintenance` | Falsified audit evidence; fraudulent warranty claims | `add_maintenance_record`, `file_warranty_claim`, `add_warranty_information` | + +## Known accepted risks + +Each is tracked; none is closed. Do not deploy to a network holding real value +until the ones marked **blocking** are fixed. + +| # | Risk | Status | +|---|---|---| +| 1 | **`assetsup` does not authenticate `caller`.** `register_asset`, `update_asset_metadata`, `transfer_asset_ownership`, and `retire_asset` compare a caller-supplied `caller` argument against an allowlist/owner/admin but never call `caller.require_auth()`. Any account can name a privileged address and pass the check. `transfer_asset_ownership` is a direct asset-theft path. | **Blocking** — [SC-42] | +| 2 | **Unguarded entrypoints in `asset-maintenance`.** `init`, `update_maintenance_schedule`, `complete_scheduled_maintenance`, `add_warranty_information`, `update_warranty_information`, `file_warranty_claim`, and `create_maintenance_alert` perform no authorization at all. Audit evidence is forgeable. | **Blocking** — [SC-42] | +| 3 | **`initialize` is front-runnable** in `contrib` and `multisig-transfer` — neither authorizes the caller, so whoever calls first becomes admin. Deploy and initialize in the same transaction, or accept the race. | **Blocking** — [SC-42] | +| 4 | **Admin transfer is single-step.** One typo permanently bricks administration, with no on-chain undo. | Open — [SC-48] | +| 5 | **Arithmetic can trap rather than error.** `overflow-checks = true` turns overflow into a panic. Value paths should return typed errors. | Open — [SC-43] | +| 6 | **No deliberate TTL policy.** A persistent entry whose TTL lapses is archived; an archived registry entry or pending approval is a correctness bug. | Open — [SC-44] | +| 7 | **Pause coverage is unverified.** `contrib` has a pause module; whether every mutating entrypoint honours it — and whether the other crates have one at all — is unconfirmed. | Open — [SC-47] | +| 8 | **No upgrade story.** No contract exposes an upgrade entrypoint and no storage-version key exists, so a storage layout change means redeploying and losing data. | Open — [SC-49] | +| 9 | **No dependency scanning.** Nothing checks for advisories in transitive dependencies. | Open — [SC-38] | +| 10 | **Error codes collide across contracts.** The same integer means different things per contract, so a backend cannot map a code without knowing which contract produced it. | Open — [SC-45] | +| 11 | **`multisig_transfer` has no tests**, and `multisig-wallet` and `asset-maintenance` have four each. | Open — [SC-32], [SC-40], [SC-41] | +| 12 | **Contracts are unaudited.** No external review has been performed. | Open | + +## Pre-deployment checklist + +Work through this before deploying to any network that holds real value. Each +item names the issue that established the requirement. + +### Authorization — [SC-42] +- [ ] Every state-changing entrypoint calls `require_auth()` on the correct principal. +- [ ] No entrypoint trusts a caller-supplied address argument as proof of identity. +- [ ] `require_auth_for_args` is used where authorization must bind to amounts or recipients. +- [ ] Each protected entrypoint has a negative test **without** `mock_all_auths`. +- [ ] `initialize` cannot be front-run, or deployment and initialization are atomic. + +### Arithmetic — [SC-43] +- [ ] No unchecked `+`/`-`/`*` on any path handling amounts, shares, or percentages. +- [ ] Overflow returns a typed error rather than trapping. +- [ ] Rounding direction is documented, and who absorbs the remainder is explicit. +- [ ] Boundary tests exist at `0`, `1`, and type max. + +### Storage and TTL — [SC-44] +- [ ] Every storage write uses the right durability (instance / persistent / temporary). +- [ ] Long-lived data is never in `temporary`. +- [ ] Persistent entries that must outlive the default are extended on read and write. +- [ ] Instance TTL is bumped in every entrypoint touching instance storage. +- [ ] TTL constants are defined in one place. +- [ ] A test advances the ledger and proves critical entries survive. + +### Emergency controls — [SC-47] +- [ ] Every mutating entrypoint respects the pause guard. +- [ ] Read-only entrypoints still work while paused. +- [ ] Whether withdrawal and escrow-release are exempt is a documented decision. +- [ ] A test fails if a new mutating entrypoint is added without a pause check. + +### Admin and upgrades — [SC-48], [SC-49] +- [ ] Admin transfer is two-step; an address that never accepts leaves the original admin in place. +- [ ] Each contract's upgrade posture is documented (upgradeable or immutable). +- [ ] Upgrade entrypoints are admin-gated and emit an event. +- [ ] A storage-version key exists and migration is idempotent. +- [ ] An upgrade test proves state survives a version bump. + +### Dependencies — [SC-38] +- [ ] `cargo audit` passes with no unignored advisories. +- [ ] `cargo deny` passes for licenses, advisories, and duplicate versions. +- [ ] Every ignored advisory has a written rationale and an expiry date. + +### Build and size — [SC-34], [SC-37], [SC-52] +- [ ] Every deployable contract builds for `wasm32-unknown-unknown`. +- [ ] Release profile has size optimizations enabled and `overflow-checks = true` retained. +- [ ] Each contract's WASM size is within budget and the delta was reviewed. + +### Tests and observability — [SC-32], [SC-36], [SC-39] +- [ ] `cargo test --all` passes. +- [ ] `cargo clippy --all-targets --all-features -- -D warnings` is clean. +- [ ] `cargo fmt --all -- --check` is clean. +- [ ] No crate is at zero coverage; per-crate floors pass. +- [ ] Every state-changing entrypoint emits an event. +- [ ] The event catalogue matches what the backend consumes. + +### Deployment — [SC-55] +- [ ] Deployment is scripted and reproducible from a clean checkout. +- [ ] Contract ids are recorded in `deployments/.json`. +- [ ] No key material is committed or logged. +- [ ] Each deployed contract answered a read call. +- [ ] `multisig-wallet` was initialized with the real signer set and a threshold > 1. + +## Reporting a vulnerability + +**Do not open a public issue for a security vulnerability.** + +Report privately through +[GitHub Security Advisories](https://github.com/DistinctCodes/AssetsUp/security/advisories/new), +which creates a channel visible only to maintainers. If that is unavailable, +contact a maintainer listed on the organization profile directly. + +Please include: + +- The affected contract and entrypoint. +- What an attacker gains, and what they need to start. +- A reproduction — ideally a failing test against this workspace. +- Any suggested fix. + +What to expect: + +| Stage | Target | +|---|---| +| Acknowledgement | 3 working days | +| Initial assessment and severity | 10 working days | +| Fix or documented mitigation | Depends on severity; critical issues are prioritized above all other work | + +Please give maintainers a reasonable window to ship a fix before disclosing +publicly. Reporters are credited in the advisory unless they ask not to be. + +### Scope + +**In scope:** everything under `contracts/` — all five crates, the release +profile, CI workflows that produce deployable artifacts, and the deployment +scripts. + +**Out of scope:** the unaudited status itself and the items already listed in +[Known accepted risks](#known-accepted-risks) — those are tracked, not news. +Backend and frontend issues belong in their own reports. diff --git a/contracts/asset-maintenance/README.md b/contracts/asset-maintenance/README.md new file mode 100644 index 000000000..c9a492f0e --- /dev/null +++ b/contracts/asset-maintenance/README.md @@ -0,0 +1,160 @@ +# `asset-maintenance` + +Records asset maintenance history, schedules, warranties, service providers, +and alerts on-chain. The maintenance history is intended as **audit evidence**: +records are appended, never rewritten. + +Contract type: `AssetMaintenanceContract`. Deployable (`crate-type = ["cdylib"]`). + +Assets are referenced by `u64` id. This contract stores an `AssetRegistry` +address at init but does not currently call into it to validate that an asset +exists. + +## Invariants + +- Maintenance history is append-only: `add_maintenance_record` pushes onto + `MaintenanceHistory(asset_id)` and no entrypoint removes or rewrites an + existing record. +- `AssetStats(asset_id)` is a derived rollup (total cost, downtime, service + count, health score) maintained alongside the history. +- A health score is expressed on a 1–100 scale. + +## Storage layout + +All entries use **instance** storage. + +| Key | Type | Meaning | +|---|---|---| +| `Admin` | `Address` | Contract administrator. | +| `AssetRegistry` | `Address` | Address of the asset registry contract. | +| `Provider(Address)` | `ProviderProfile` | Registered maintenance provider. | +| `MaintenanceHistory(u64)` | `Vec` | Append-only service history for an asset. | +| `MaintenanceSchedule(u64)` | `ScheduledMaintenance` | Upcoming scheduled service. | +| `Warranty(u64)` | `WarrantyInfo` | Warranty terms and status. | +| `Alerts(u64)` | `Vec` | Alerts raised against an asset. | +| `AssetStats(u64)` | `AssetStats` | Derived cost/downtime/health rollup. | + +## Entrypoints + +`Auth` names the address whose `require_auth()` is called. **"— (none)" marks an +entrypoint that performs no authorization at all**; these are unguarded and are +tracked in [SC-42]. + +### Setup and providers + +| Entrypoint | Args | Returns | Auth | +|---|---|---|---| +| `init` | `admin, registry` | `()` | — (none) | +| `register_provider` | `provider: ProviderProfile` | `()` | stored `admin` | +| `deactivate_provider` | `provider_address` | `()` | stored `admin` | +| `get_provider_details` | `provider_address` | `Option` | read-only | + +### Maintenance records + +| Entrypoint | Args | Returns | Auth | +|---|---|---|---| +| `add_maintenance_record` | `record: MaintenanceRecord` | `()` | `record.provider` | +| `get_maintenance_history` | `asset_id` | `Vec` | read-only | +| `schedule_maintenance` | `owner, schedule` | `()` | `owner` | +| `update_maintenance_schedule` | `owner, schedule` | `()` | — (none) | +| `complete_scheduled_maintenance` | `asset_id, record` | `()` | — (none) | +| `get_upcoming_maintenance` | `asset_id` | `Option` | read-only | +| `get_overdue_maintenance` | `asset_id` | `bool` | read-only | + +`update_maintenance_schedule` takes an `owner` argument but never calls +`owner.require_auth()`, so the argument is currently decorative. + +### Warranties + +| Entrypoint | Args | Returns | Auth | +|---|---|---|---| +| `add_warranty_information` | `warranty: WarrantyInfo` | `()` | — (none) | +| `update_warranty_information` | `warranty: WarrantyInfo` | `()` | — (none) | +| `get_warranty` | `asset_id` | `Option` | read-only | +| `file_warranty_claim` | `asset_id, claim_amount` | `()` | — (none) | + +### Alerts + +| Entrypoint | Args | Returns | Auth | +|---|---|---|---| +| `create_maintenance_alert` | `alert: MaintenanceAlert` | `()` | — (none) | +| `acknowledge_maintenance_alert` | `asset_id, alert_index, by` | `()` | `by` | +| `get_alerts` | `asset_id` | `Vec` | read-only | + +### Analytics (all read-only) + +`calculate_total_maintenance_cost`, `calculate_asset_downtime`, +`get_asset_health_score`, `get_asset_stats`, `is_maintenance_cost_excessive`. + +## Events + +| Topics | Emitted by | +|---|---| +| `("MaintRec", asset_id)` | `add_maintenance_record` | +| `("MaintSch", asset_id)` | `schedule_maintenance` | +| `("MaintCmp", asset_id)` | `complete_scheduled_maintenance` | +| `("WarrAdd", asset_id)` | `add_warranty_information` | +| `("WarrClm", asset_id)` | `file_warranty_claim` | +| `("AlertCr", asset_id)` | `create_maintenance_alert` | + +These topics use `CamelCase`, unlike the `snake_case` used elsewhere in the +workspace — see [SC-36]. `init`, `register_provider`, `deactivate_provider`, +`update_maintenance_schedule`, `update_warranty_information`, and +`acknowledge_maintenance_alert` emit no event. + +## Errors + +This crate has **no error enum**. Failures are raised with `panic!` on a +`&str` message rather than a typed `contracterror`, so callers cannot +distinguish failure modes by code. Unifying this with the rest of the workspace +is tracked in [SC-45]. + +## Types + +`MaintenanceType` (Preventive, Corrective, Emergency, Inspection, Upgrade, +Calibration), `AlertType`, `AlertSeverity`, `WarrantyStatus`, `PriorityLevel`, +`MaintenanceRecord`, `ScheduledMaintenance`, `WarrantyInfo`, `ProviderProfile`, +`MaintenanceAlert`, `AssetStats`. + +## Worked example + +```rust +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +let env = Env::default(); +env.mock_all_auths(); + +let contract_id = env.register(AssetMaintenanceContract, ()); +let client = AssetMaintenanceContractClient::new(&env, &contract_id); + +let admin = Address::generate(&env); +let registry = Address::generate(&env); +let provider = Address::generate(&env); + +client.init(&admin, ®istry); + +// The provider records a completed service against asset 1. +let record = MaintenanceRecord { + record_id: 1, + asset_id: 1, + maintenance_type: MaintenanceType::Preventive, + provider: provider.clone(), + technician_id: String::from_str(&env, "tech-01"), + service_date: 1_000, + duration_hours: 3, + // ... remaining fields +}; +client.add_maintenance_record(&record); + +let history = client.get_maintenance_history(&1); +assert_eq!(history.len(), 1); +``` + +## Tests + +```sh +cargo test -p asset-maintenance +``` + +Coverage is currently thin (4 tests for ~730 lines); expanding it is tracked in +[SC-41]. diff --git a/contracts/asset-maintenance/src/lib.rs b/contracts/asset-maintenance/src/lib.rs index f8b11a27f..314cdb28b 100644 --- a/contracts/asset-maintenance/src/lib.rs +++ b/contracts/asset-maintenance/src/lib.rs @@ -1,4 +1,29 @@ #![no_std] +//! # asset-maintenance +//! +//! Records asset maintenance history, schedules, warranties, service +//! providers, and alerts on-chain. +//! +//! The maintenance history is intended as **audit evidence**: records are +//! appended, never rewritten. Assets are referenced by `u64` id. +//! +//! ## Invariants +//! +//! - Maintenance history is append-only — no entrypoint removes or rewrites an +//! existing [`MaintenanceRecord`]. +//! - [`AssetStats`] is a derived rollup (total cost, downtime, service count, +//! health score) maintained alongside the history. +//! - Health scores are expressed on a 1–100 scale. +//! +//! ## Error handling +//! +//! Unlike the other crates in this workspace, failures here are raised with +//! `panic!` on a `&str` rather than a typed `contracterror`, so callers cannot +//! distinguish failure modes by code. +//! +//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/asset-maintenance/README.md) +//! for the full entrypoint, storage, and event tables. + use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec}; mod test; diff --git a/contracts/assetsup/README.md b/contracts/assetsup/README.md new file mode 100644 index 000000000..87216ded8 --- /dev/null +++ b/contracts/assetsup/README.md @@ -0,0 +1,219 @@ +# `assetsup` + +The primary asset registry. Assets are registered by authorized registrars, +owned by an address, and can be transferred, retired, tokenized into fractional +shares, leased, insured, voted on, and finally detokenized. + +Contract type: `AssetUpContract`. Deployable (`crate-type = ["lib", "cdylib"]`). + +This is the largest crate in the workspace (~8,900 lines across 33 files). See +[`../README.md`](../README.md#how-assetsup-and-contrib-relate) for how it +relates to `contrib`, which duplicates several module names. Splitting this +crate is under discussion in [SC-46]. + +## Invariants + +- An asset has exactly one owner at any time. +- An asset id is unique; re-registering an existing id fails with + `AssetAlreadyExists`. +- A retired asset cannot be transferred or updated. +- For a tokenized asset, the sum of all holder balances equals the total token + supply. +- Locked tokens cannot be transferred until unlocked. +- Every state change is appended to the audit log for that asset. + +## Two asset id spaces + +The crate uses **two different asset identifier types**, which is a common +source of confusion: + +- The **registry** (`register_asset`, `get_asset`, `transfer_asset_ownership`, + leases, insurance) keys assets by **`BytesN<32>`**. +- **Tokenization** (`tokenize_asset`, balances, dividends, voting, + detokenization, transfer restrictions) keys assets by **`u64`**. + +These namespaces are not linked by the contract. Callers are responsible for +maintaining the mapping between them. + +## Module layout + +| Module | Responsibility | +|---|---| +| `lib.rs` | Contract entrypoints; delegates to the modules below. | +| `asset.rs` | `Asset`, `AssetInfo`, registry `DataKey`. | +| `types.rs` | Shared types re-exported from the crate root. | +| `error.rs` | `Error` enum (codes 1–46) and `handle_error`. | +| `audit.rs` | Append-only audit entries per asset. | +| `tokenization.rs` | Fractional share issuance, balances, locks, valuation. | +| `dividends.rs` | Dividend distribution and claims. | +| `voting.rs` | Weighted voting by token balance. | +| `detokenization.rs` | Detokenization proposals and execution. | +| `transfer_restrictions.rs` | Whitelists and transfer rules. | +| `lease.rs` | Asset leasing lifecycle. | +| `insurance.rs` | Insurance policies and the claim state machine. | +| `branch.rs` | Branch/organization records. | + +## Storage layout + +Registry state uses **persistent** storage; contract-level flags are also +persistent (not `instance`, which is unusual — see [SC-44]). + +| Key | Type | Meaning | +|---|---|---| +| `Admin` | `Address` | Contract administrator. | +| `Paused` | `bool` | Global pause flag. | +| `TotalAssetCount` | `u64` | Number of registered assets. | +| `ContractMetadata` | `ContractMetadata` | Name/version metadata. | +| `AuthorizedRegistrar(Address)` | `bool` | Registrar allowlist. | +| `ScheduledTransfer(BytesN<32>)` | — | Scheduled transfer record. | +| `PendingApproval(BytesN<32>)` | — | Pending approval record. | + +Module-specific keys (assets, token balances, leases, policies) live in each +module's own `DataKey`. + +## Entrypoints + +`Auth` names the address whose `require_auth()` is called. + +### Lifecycle and administration + +| Entrypoint | Args | Returns | Auth | +|---|---|---|---| +| `initialize` | `admin` | `Result<()>` | `admin` | +| `update_admin` | `new_admin` | `Result<()>` | current admin | +| `add_authorized_registrar` | `registrar` | `Result<()>` | current admin | +| `remove_authorized_registrar` | `registrar` | `Result<()>` | current admin | +| `pause_contract` | — | `Result<()>` | current admin | +| `unpause_contract` | — | `Result<()>` | current admin | + +Admin transfer is single-step: `update_admin` hands over immediately, so a typo +permanently bricks administration. A two-step transfer is tracked in [SC-48]. + +### Asset registry + +| Entrypoint | Args | Returns | Auth | +|---|---|---|---| +| `register_asset` | `asset, caller` | `Result<()>` | ⚠️ registrar allowlist check only — **no `require_auth`** | +| `update_asset_metadata` | `asset_id, ..., caller` | `Result<()>` | ⚠️ owner check only — **no `require_auth`** | +| `transfer_asset_ownership` | `asset_id, new_owner, caller` | `Result<()>` | ⚠️ owner check only — **no `require_auth`** | +| `retire_asset` | `asset_id, caller` | `Result<()>` | ⚠️ owner/admin check only — **no `require_auth`** | + +> **⚠️ Known gap.** These four entrypoints compare the supplied `caller` +> argument against an allowlist, the asset owner, or the admin, but never call +> `caller.require_auth()`. Because `caller` is attacker-supplied, the check can +> be satisfied by simply naming a privileged address. Fixing this is tracked in +> [SC-42]; it is the highest-severity item in the workspace. + +Reads: `get_asset`, `get_asset_info`, `batch_get_asset_info`, +`get_assets_by_owner`, `check_asset_exists`, `get_total_asset_count`, +`get_admin`, `is_paused`, `is_authorized_registrar`, `get_contract_metadata`, +`get_asset_audit_logs`. + +### Tokenization + +| Entrypoint | Auth | +|---|---| +| `tokenize_asset` | owner | +| `mint_tokens`, `burn_tokens` | issuer | +| `transfer_tokens` | `from` | +| `lock_tokens` | owner | +| `unlock_tokens` | — | +| `update_valuation` | — | + +Reads: `get_token_balance`, `get_token_holders`, `is_tokens_locked`, +`get_ownership_percentage`, `get_tokenized_asset`. + +### Dividends, voting, detokenization + +| Entrypoint | Auth | +|---|---| +| `distribute_dividends` | — | +| `claim_dividends` | `holder` | +| `enable_revenue_sharing`, `disable_revenue_sharing` | — | +| `cast_vote` | `voter` | +| `propose_detokenization` | `proposer` | +| `execute_detokenization` | — | + +Reads: `get_unclaimed_dividends`, `get_vote_tally`, `has_voted`, +`proposal_passed`, `get_detokenization_proposal`, `is_detokenization_active`. + +### Transfer restrictions + +`set_transfer_restriction`, `add_to_whitelist`, `remove_from_whitelist`, +`is_whitelisted`, `get_whitelist`. None currently call `require_auth`. + +### Leasing and insurance + +| Entrypoint | Auth | +|---|---| +| `create_lease` | lessor | +| `return_leased_asset`, `cancel_lease` | `caller` | +| `create_insurance_policy` | insurer | +| `cancel_insurance_policy`, `suspend_insurance_policy`, `renew_insurance_policy` | `caller`/insurer | +| `expire_insurance_policy` | — | + +## Events + +| Topic | Emitted by | +|---|---| +| `("asset_reg",)` | `register_asset` | +| `("asset_upd",)` | `update_asset_metadata` | +| `("asset_tx",)` | `transfer_asset_ownership` | +| `("asset_ret",)` | `retire_asset` | +| `("admin_chg",)` | `update_admin` | +| `("c_pause",)` | `pause_contract` | +| `("c_unpause",)` | `unpause_contract` | +| `("lease_new",)`, `("lease_ret",)`, `("lease_can",)`, `("lease_exp",)` | lease lifecycle | + +Tokenization, dividends, voting, and detokenization emit events too; see the +respective modules. Registrar allowlist changes emit **no** event. See [SC-36] +for the workspace-wide event catalogue. + +## Errors + +`Error`, defined in [`src/error.rs`](src/error.rs), codes 1–46, grouped by +concern (registry 1–9, tokenization 10–20, voting 21–25, dividends 26–27, +detokenization 28–29, valuation 30, holders 31, math 32–33, contract state +34–35, validation 36–39, leasing 40–46). + +Note that most entrypoints return `Result<_, Error>`, but `handle_error` panics +with the error instead of returning it in some paths, so callers see a trap +rather than a typed error. Cross-contract code allocation is tracked in [SC-45]. + +## Worked example + +```rust +use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String}; + +let env = Env::default(); +env.mock_all_auths(); + +let contract_id = env.register(AssetUpContract, ()); +let client = AssetUpContractClient::new(&env, &contract_id); + +let admin = Address::generate(&env); +let registrar = Address::generate(&env); +let owner = Address::generate(&env); + +client.initialize(&admin); +client.add_authorized_registrar(®istrar); + +let asset = Asset { + id: BytesN::from_array(&env, &[1u8; 32]), + name: String::from_str(&env, "Forklift #3"), + owner: owner.clone(), + // ... remaining fields +}; +client.register_asset(&asset, ®istrar); + +assert_eq!(client.get_asset(&asset.id).owner, owner); +assert_eq!(client.get_total_asset_count(), 1); +``` + +## Tests + +```sh +cargo test -p assetsup +``` + +199 tests live under [`src/tests/`](src/tests/), organized by module. diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index 7c220273f..a5a289e14 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -1,5 +1,34 @@ #![no_std] #![allow(clippy::too_many_arguments)] +//! # assetsup +//! +//! The primary AssetsUp asset registry. +//! +//! Assets are registered by authorized registrars, owned by an `Address`, and +//! can be transferred, retired, tokenized into fractional shares, leased, +//! insured, voted on, and detokenized. +//! +//! ## Invariants +//! +//! - An asset has exactly one owner at any time. +//! - An asset id is unique; re-registering fails with `Error::AssetAlreadyExists`. +//! - A retired asset cannot be transferred or updated. +//! - For a tokenized asset, holder balances sum to the total token supply. +//! +//! ## Two asset id spaces +//! +//! The registry keys assets by `BytesN<32>`, while tokenization, dividends, +//! voting, and detokenization key them by `u64`. The contract does not link the +//! two namespaces — callers maintain the mapping. +//! +//! ## Relationship to `contrib` +//! +//! `assetsup` and `contrib` are independent contracts with separate storage +//! that share several module names. See `contracts/README.md` for which crate +//! owns which concern. +//! +//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/assetsup/README.md) +//! for the full entrypoint, storage, event, and error tables. use crate::error::{handle_error, Error}; use soroban_sdk::{ diff --git a/contracts/assetsup/src/types.rs b/contracts/assetsup/src/types.rs index a1c633b85..b1136649c 100644 --- a/contracts/assetsup/src/types.rs +++ b/contracts/assetsup/src/types.rs @@ -85,7 +85,7 @@ pub enum TokenDataKey { TokenizedAsset(u64), /// Stores OwnershipRecord for (asset_id, holder_address) TokenHolder(u64, Address), - /// Stores Vec
of all token holders for an asset + /// Stores `Vec
` of all token holders for an asset TokenHoldersList(u64), /// Stores lock timestamp for (asset_id, holder_address) TokenLockedUntil(u64, Address), @@ -95,7 +95,7 @@ pub enum TokenDataKey { VoteTally(u64, u64), /// Stores TransferRestriction for asset_id TransferRestriction(u64), - /// Stores Vec
whitelist for asset_id + /// Stores `Vec
` whitelist for asset_id Whitelist(u64), /// Stores unclaimed dividend for (asset_id, holder_address) UnclaimedDividend(u64, Address), diff --git a/contracts/contrib/README.md b/contracts/contrib/README.md new file mode 100644 index 000000000..12f118697 --- /dev/null +++ b/contracts/contrib/README.md @@ -0,0 +1,186 @@ +# `contrib` + +A second asset registry carrying the capabilities `assetsup` does not have: +**escrow**, **KYC**, **staking**, a **price oracle**, and a first-class +**emergency pause**. + +Contract type: `ContribContract`. Deployable (`crate-type = ["lib", "cdylib"]`). + +## Relationship to `assetsup` + +`contrib` and `assetsup` are **two independent contracts with separate storage**. +Neither reads the other's state, and neither calls the other. Several module +names appear in both (`audit`, `detokenization`, `insurance`, `lease`, +`tokenization`, transfer restrictions) but the implementations have diverged +and are **not** interchangeable. + +What each crate uniquely owns today: + +| Concern | `assetsup` | `contrib` | +|---|---|---| +| Asset registry | ✅ authoritative | ✅ separate copy | +| Escrow | — | ✅ only here | +| KYC | — | ✅ only here | +| Staking | — | ✅ only here | +| Price oracle | — | ✅ only here | +| Emergency pause | partial | ✅ dedicated `pause` module | +| Dividends, voting | ✅ only here | — | +| Insurance | ✅ full claim state machine | smaller policy/claim store | +| Leasing | ✅ richer lifecycle | check-in/cancel only | + +Deciding which crate owns each duplicated concern is tracked in [SC-46]. Until +that lands, treat them as separate deployments and read this file for +`contrib`'s actual behaviour. + +## Invariants + +- An asset has exactly one owner at any time. +- A retired asset cannot be transferred. +- While paused, every mutating registry entrypoint rejects; reads still work. +- An escrow's funds are either released to the beneficiary or returned to the + depositor — never both. +- A KYC record is approved only by the admin and carries an expiry. + +## Module layout + +| Module | Responsibility | +|---|---| +| `lib.rs` | Registry entrypoints and re-exported module facades. | +| `types.rs` | `AssetStatus` and shared types. | +| `error.rs` | `Error` enum and `handle_error`. | +| `pause.rs` | `pause`, `unpause`, `is_paused`, `require_not_paused`. | +| `audit.rs` | Append-only audit log per asset. | +| `escrow.rs` | Escrow creation, release, cancellation. | +| `kyc.rs` | KYC submission, approval, revocation, tiers. | +| `staking.rs` | Token staking, unstaking, reward accrual. | +| `oracle.rs` | Oracle allowlist and asset valuation feed. | +| `insurance.rs` | Policies and claims. | +| `lease.rs` | Lease creation, check-in, cancellation. | +| `tokenization.rs` | Fractional shares. | +| `detokenization.rs` | Detokenization proposals. | +| `restrictions.rs` | Whitelists and transfer validation. | + +## Storage layout + +| Key | Type | Meaning | +|---|---|---| +| `Asset(BytesN<32>)` | `Asset` | Registered asset. | +| `OwnerAssets(Address)` | `Vec>` | Assets held by an owner. | +| `TotalCount` | `u64` | Registered asset count. | +| `Admin` | `Address` | Contract administrator. | +| `Paused` | `bool` | Emergency pause flag. | +| `AuthorizedRegistrar(Address)` | `bool` | Registrar allowlist. | +| `AuditLogCount` | `u64` | Audit entry counter. | +| `AuditLogs(BytesN<32>)` | `Vec` | Audit trail per asset. | + +Escrow, KYC, staking, and oracle modules define their own keys. + +## Entrypoints + +### Registry + +| Entrypoint | Args | Returns | Auth | +|---|---|---|---| +| `initialize` | `admin` | `()` | — (none, front-runnable) | +| `register_asset` | `registrar, asset` | `()` | `registrar` | +| `transfer_asset` | `asset_id, new_owner, caller` | `()` | `caller` | +| `retire_asset` | `asset_id, caller` | `()` | `caller` | +| `add_authorized_registrar` | `caller, registrar` | `()` | `caller` | +| `remove_authorized_registrar` | `caller, registrar` | `()` | `caller` | +| `add_registrar` / `remove_registrar` | `caller, registrar` | `()` | aliases of the above | + +Unlike `assetsup`, these entrypoints **do** call `require_auth()` on the acting +address. `contrib` is the correct reference implementation for authorization in +this workspace. + +Reads: `get_admin`, `get_asset`, `get_asset_info`, `get_assets_by_owner`, +`get_total_count`, `get_total_asset_count`, `is_authorized_registrar`, +`get_audit_logs`, `is_paused`. + +### Pause + +| Entrypoint | Args | Auth | +|---|---|---| +| `pause_contract` | `caller` | `caller` (must be admin) | +| `unpause_contract` | `caller` | `caller` (must be admin) | +| `is_paused` | — | read-only | + +`pause::require_not_paused` is the guard mutating entrypoints call. Verifying +that **every** mutating entrypoint calls it is tracked in [SC-47]. + +### Escrow + +| Entrypoint | Args | Auth | +|---|---|---| +| `create_escrow` | `depositor, beneficiary, amount, ...` | `depositor` | +| `confirm_release` | `escrow_id` | depositor/arbiter check | +| `cancel_escrow` | `escrow_id` | depositor/arbiter check | +| `get_escrow` | `escrow_id` | read-only | + +### KYC + +| Entrypoint | Args | Auth | +|---|---|---| +| `init` | `admin` | — (none) | +| `submit_kyc` | `address` | `address` | +| `approve_kyc` | `address, tier, expires_at` | admin | +| `revoke_kyc` | `address` | admin | +| `is_kyc_approved` / `get_kyc_record` | `address` | read-only | + +### Staking + +`init`, `stake_tokens`, `unstake_tokens`, `get_staking_power`, +`accrue_staking_rewards`. + +### Oracle + +`init`, `add_oracle`, `remove_oracle`, `update_valuation` (restricted to +allowlisted oracle sources), `get_latest_valuation`, `get_valuation_history`. + +### Insurance and leasing + +`create_policy`, `get_policy`, `cancel_policy`, `is_policy_active`, +`submit_claim`, `update_claim_status`, `get_claim`, `get_claims_for_policy`, +`create_lease`, `check_in_lease`, `cancel_lease`, `get_active_leases`. + +## Events + +`contrib` mixes two emission styles — `symbol_short!` with abbreviated +`snake_case`, and `Symbol::new` with full words: + +| Topics | Emitted by | +|---|---| +| `("asset_reg", asset_id)` | `register_asset` | +| `("asset_tra", asset_id)` | `transfer_asset` | +| `("asset_ret", asset_id)` | `retire_asset` | +| `("escrow_created", escrow_id)` | `create_escrow` | +| `("escrow_completed", escrow_id)` | `confirm_release` | +| `("escrow_cancelled", escrow_id)` | `cancel_escrow` | +| `("kyc_submitted", address)` | `submit_kyc` | +| `("kyc_approved", address)` | `approve_kyc` | +| `("kyc_revoked", address)` | `revoke_kyc` | +| `("oracle_added", oracle)` / `("oracle_removed", oracle)` | oracle allowlist | +| `("valuation_updated", asset_id)` | `update_valuation` | +| `("staked", asset_id, staker)` / `("unstaked", asset_id, staker)` | staking | +| `("rewards_accrued", asset_id)` | `accrue_staking_rewards` | +| `("pol_cre", policy_id)`, `("pol_can", policy_id)` | insurance policies | +| `("clm_sub", claim_id)`, `("clm_upd", claim_id)` | insurance claims | +| `("lease_cr", lease_id)`, `("lease_in", lease_id)`, `("lease_can", lease_id)` | leasing | + +Unifying this is tracked in [SC-36]. + +## Errors + +`Error`, defined in [`src/error.rs`](src/error.rs). Codes 1–21 and 28–32; note +the gap at 22–27 and that the numbering **overlaps `assetsup` with different +meanings** — code 5 is `Unauthorized` here but `BranchAlreadyExists` in +`assetsup`. Tracked in [SC-45]. + +## Tests + +```sh +cargo test -p contrib +``` + +35 tests across [`src/tests/`](src/tests/) plus the module-level `*_test.rs` +files. diff --git a/contracts/contrib/src/lib.rs b/contracts/contrib/src/lib.rs index 539797f3d..61b45592d 100644 --- a/contracts/contrib/src/lib.rs +++ b/contracts/contrib/src/lib.rs @@ -1,5 +1,33 @@ #![no_std] #![allow(clippy::too_many_arguments)] +//! # contrib +//! +//! A second AssetsUp asset registry carrying the capabilities `assetsup` does +//! not have: escrow, KYC, staking, a price oracle, and a first-class emergency +//! pause. +//! +//! ## Relationship to `assetsup` +//! +//! `contrib` and `assetsup` are **independent contracts with separate +//! storage**. Neither reads or calls the other. Several module names appear in +//! both (`audit`, `detokenization`, `insurance`, `lease`, `tokenization`) but +//! the implementations have diverged and are not interchangeable. See +//! `contracts/README.md` for the ownership split. +//! +//! ## Invariants +//! +//! - An asset has exactly one owner at any time. +//! - A retired asset cannot be transferred. +//! - While paused, mutating registry entrypoints reject; reads still work. +//! - Escrowed funds are either released to the beneficiary or returned to the +//! depositor — never both. +//! +//! Unlike `assetsup`, every acting address here is authenticated with +//! `require_auth()`; this crate is the reference for authorization in the +//! workspace. +//! +//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/contrib/README.md) +//! for the full entrypoint, storage, event, and error tables. mod audit; mod pause; diff --git a/contracts/deployments/testnet.example.json b/contracts/deployments/testnet.example.json new file mode 100644 index 000000000..856c2236c --- /dev/null +++ b/contracts/deployments/testnet.example.json @@ -0,0 +1,12 @@ +{ + "network": "testnet", + "admin": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "deployedAt": "2026-01-01T00:00:00Z", + "contracts": { + "assetsup": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "contrib": "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "multisig-wallet": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "asset-maintenance": "CDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", + "multisig-transfer": "CEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE" + } +} diff --git a/contracts/multisig-wallet/README.md b/contracts/multisig-wallet/README.md new file mode 100644 index 000000000..bfa2f5558 --- /dev/null +++ b/contracts/multisig-wallet/README.md @@ -0,0 +1,151 @@ +# `multisig-wallet` + +A general-purpose *m-of-n* multisignature wallet. Owners submit transactions, +confirm them, and once the confirmation threshold is met anyone may execute +them. Owner membership and the threshold itself are changed through the same +confirmation flow, so no single owner can unilaterally alter the wallet. + +Contract type: `MultisigWallet`. Deployable (`crate-type = ["lib", "cdylib"]`). + +## Invariants + +- The threshold is always `>= 1` and `<= owners.len()`. +- A wallet always has at least 2 owners. +- A transaction executes only once — `executed` is checked before execution. +- A frozen wallet rejects every mutating operation except `emergency_unfreeze`. +- Confirmations are recorded per `(tx_id, address)`, so one owner cannot confirm + the same transaction twice. + +## Storage layout + +All state lives in **instance** storage except transactions, proposals, and +confirmations, which are keyed individually. + +| Key | Type | Meaning | +|---|---|---| +| `Owners` | `Vec
` | Current owner set. | +| `OwnerProfile(Address)` | `OwnerProfile` | Per-owner metadata: type, voting weight, activity counters. | +| `Threshold` | `u32` | Confirmations required to execute. | +| `NextTxId` | `u64` | Monotonic transaction id counter. | +| `Transaction(u64)` | `Transaction` | A submitted transaction. | +| `Confirmation(u64, Address)` | `bool` | Whether an owner confirmed a transaction. | +| `DailyLimit` | `u128` | Per-day spend cap; `0` means unlimited. | +| `DailySpent(u64)` | `u128` | Amount spent on a given day bucket. | +| `Frozen` | `bool` | Emergency freeze flag. | +| `NextProposalId` | `u64` | Monotonic proposal id counter. | +| `Proposal(u64)` | `OwnershipProposal` | An owner/threshold change proposal. | +| `ProposalConfirmation(u64, Address)` | `bool` | Whether an owner confirmed a proposal. | +| `Admin` | `Address` | Address that initialized the wallet. | + +## Entrypoints + +`Auth` names the address whose `require_auth()` is called. "—" means the +entrypoint performs no authorization of its own. + +### Lifecycle + +| Entrypoint | Args | Returns | Auth | Errors | +|---|---|---|---|---| +| `initialize` | `admin, owners, threshold` | `Result<()>` | `admin` | `AlreadyInitialized`, `InsufficientOwners`, `InvalidThreshold` | + +### Transactions + +| Entrypoint | Args | Returns | Auth | Errors | +|---|---|---|---|---| +| `submit_transaction` | `initiator, to, amount, token, fn_name, args, expires_at` | `Result` | `initiator` | `NotInitialized`, `NotAnOwner`, `WalletFrozen` | +| `confirm_transaction` | `confirmer, tx_id` | `Result<()>` | `confirmer` | `NotAnOwner`, `TransactionNotFound`, `TransactionAlreadyExecuted`, `TransactionExpired`, `AlreadyConfirmed`, `WalletFrozen` | +| `revoke_confirmation` | `revoker, tx_id` | `Result<()>` | `revoker` | `NotAnOwner`, `TransactionNotFound`, `TransactionAlreadyExecuted` | +| `execute_transaction` | `tx_id` | `Result<()>` | — (permissionless once the threshold is met) | `TransactionNotFound`, `TransactionAlreadyExecuted`, `TransactionExpired`, `DailyLimitExceeded`, `WalletFrozen` | +| `cancel_transaction` | `caller, tx_id` | `Result<()>` | `caller` | `NotAnOwner`, `TransactionNotFound`, `TransactionAlreadyExecuted` | + +`execute_transaction` is intentionally callable by anyone: the authorization +decision was already made by the confirming owners, and requiring one of them to +also submit the execution transaction adds no security while adding liveness +risk. + +### Ownership governance + +| Entrypoint | Args | Returns | Auth | Errors | +|---|---|---|---|---| +| `propose_add_owner` | `proposer, new_owner` | `Result` | `proposer` | `NotAnOwner`, `OwnerAlreadyExists` | +| `propose_remove_owner` | `proposer, owner` | `Result` | `proposer` | `NotAnOwner`, `OwnerNotFound`, `InsufficientOwners` | +| `propose_change_threshold` | `proposer, new_threshold` | `Result` | `proposer` | `NotAnOwner`, `InvalidThreshold`, `ThresholdTooHigh` | +| `confirm_proposal` | `confirmer, proposal_id` | `Result<()>` | `confirmer` | `NotAnOwner`, `ProposalNotFound`, `AlreadyConfirmed` | +| `execute_proposal` | `proposal_id` | `Result<()>` | — (permissionless once the threshold is met) | `ProposalNotFound`, `InvalidProposal`, `InvalidThreshold` | + +### Emergency and limits + +| Entrypoint | Args | Returns | Auth | Errors | +|---|---|---|---|---| +| `emergency_freeze` | `caller` | `Result<()>` | `caller` | `NotAnOwner` | +| `emergency_unfreeze` | `caller` | `Result<()>` | `caller` | `Unauthorized` | +| `set_daily_limit` | `caller, limit` | `Result<()>` | `caller` | `Unauthorized` | + +### Reads + +`get_owners`, `get_threshold`, `get_transaction`, `is_frozen`, +`get_required_confirmations`, `get_owner_profile`, `get_proposal`. None require +auth and none mutate state. + +## Events + +| Topics | Payload | Emitted by | +|---|---|---| +| `("tx_sub", tx_id)` | `(initiator, tx_type, timestamp)` | `submit_transaction` | +| `("tx_conf", tx_id)` | `(confirmer, confirmations_count, timestamp)` | `confirm_transaction` | +| `("tx_rev", tx_id)` | `(revoker, timestamp)` | `revoke_confirmation` | +| `("tx_exec", tx_id)` | `(initiator, result, timestamp)` | `execute_transaction` | +| `("tx_can", tx_id)` | `(caller, timestamp)` | `cancel_transaction` | +| `("own_add",)` | `(new_owner, proposer, timestamp)` | `execute_proposal` (add-owner) | +| `("own_rem",)` | `(removed_owner, proposer, timestamp)` | `execute_proposal` (remove-owner) | +| `("thr_chg",)` | `(old_threshold, new_threshold, timestamp)` | `execute_proposal` (threshold change) | +| `("frozen",)` | `(caller, timestamp)` | `emergency_freeze` | +| `("unfrozen",)` | `(caller, timestamp)` | `emergency_unfreeze` | +| `("lim_rch",)` | `(limit, attempted_total, timestamp)` | daily-limit check, before returning `DailyLimitExceeded` | + +Topics are `symbol_short!` values. Note the gaps: `initialize`, the three +`propose_*` entrypoints, `confirm_proposal`, and `set_daily_limit` currently +emit **no** event, so those state changes are not observable off-chain. See +[SC-36] for the workspace-wide event convention and the plan to close these. + +## Errors + +Defined in [`src/errors.rs`](src/errors.rs), codes 1–19. See +`contracts/SECURITY.md` and [SC-45] for the cross-contract code allocation. + +## Worked example + +```rust +use soroban_sdk::{testutils::Address as _, vec, Address, Env}; + +let env = Env::default(); +env.mock_all_auths(); + +let contract_id = env.register(MultisigWallet, ()); +let client = MultisigWalletClient::new(&env, &contract_id); + +let admin = Address::generate(&env); +let alice = Address::generate(&env); +let bob = Address::generate(&env); +let carol = Address::generate(&env); + +// 2-of-3 wallet. +client.initialize(&admin, &vec![&env, alice.clone(), bob.clone(), carol.clone()], &2); + +// Alice proposes raising the threshold to 3. +let proposal_id = client.propose_change_threshold(&alice, &3); + +// Alice and Bob confirm; the proposal now has 2 of 2 required confirmations. +client.confirm_proposal(&alice, &proposal_id); +client.confirm_proposal(&bob, &proposal_id); + +// Anyone may execute once the threshold is reached. +client.execute_proposal(&proposal_id); +assert_eq!(client.get_threshold(), 3); +``` + +## Tests + +```sh +cargo test -p multisig-wallet +``` diff --git a/contracts/multisig-wallet/src/lib.rs b/contracts/multisig-wallet/src/lib.rs index 3533ed84c..8aaf78f0b 100644 --- a/contracts/multisig-wallet/src/lib.rs +++ b/contracts/multisig-wallet/src/lib.rs @@ -1,4 +1,31 @@ #![no_std] +//! # multisig-wallet +//! +//! A general-purpose *m-of-n* multisignature wallet. +//! +//! Owners submit transactions, confirm them, and once the confirmation +//! threshold is met anyone may execute them. Owner membership and the threshold +//! itself are changed through the same confirmation flow, so no single owner can +//! unilaterally alter the wallet. +//! +//! ## Invariants +//! +//! - The threshold is always `>= 1` and `<= owners.len()`. +//! - A wallet always has at least two owners. +//! - A transaction executes at most once. +//! - A frozen wallet rejects every mutating operation except +//! [`MultisigWallet::emergency_unfreeze`]. +//! - One owner cannot confirm the same transaction twice. +//! +//! ## Not to be confused with `multisig-transfer` +//! +//! This crate is generic — its subject is an arbitrary transaction. +//! `multisig-transfer` is domain-specific: its subject is always an asset +//! transfer, its thresholds are per asset category, and it delegates execution +//! to a registry contract. Neither calls the other. +//! +//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/multisig-wallet/README.md) +//! for the full entrypoint, storage, event, and error tables. use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Val, Vec}; diff --git a/contracts/multisig_transfer/README.md b/contracts/multisig_transfer/README.md new file mode 100644 index 000000000..a5a450217 --- /dev/null +++ b/contracts/multisig_transfer/README.md @@ -0,0 +1,126 @@ +# `multisig-transfer` + +An approval workflow for asset ownership transfers. A transfer is requested, +collects approvals from authorized approvers until a per-category threshold is +met, and is then executed — at which point this contract calls into a separate +**asset registry contract** to actually move ownership. + +Contract type: `MultiSigTransferContract`. Deployable +(`crate-type = ["lib", "cdylib"]`). + +> **Naming.** The directory is `multisig_transfer/` (underscore) but the package +> is `multisig-transfer` (hyphen). Use `cargo test -p multisig-transfer`; +> `-p multisig_transfer` will not resolve. Tracked in [SC-33]. + +## Relationship to `multisig-wallet` + +These are different contracts solving different problems and neither calls the +other: + +- **`multisig-wallet`** is a generic *m-of-n* wallet. Its subject is an + arbitrary transaction. +- **`multisig-transfer`** is domain-specific. Its subject is always an asset + transfer, thresholds are configured **per asset category**, and execution + delegates to a registry contract. + +## Invariants + +- An asset has at most one pending transfer request at a time + (`AssetPendingRequest`); a second request fails with `PendingRequestExists`. +- An approver cannot approve the same request twice (`AlreadyApproved`). +- A requester cannot approve their own request (`CannotApproveOwnRequest`). +- Execution requires `approvals >= rule.required_approvals` for the asset's + category (`NotEnoughApprovals`). +- Retired assets cannot be transferred (`AssetRetired`). + +## Storage layout + +All entries use **persistent** storage. + +| Key | Type | Meaning | +|---|---|---| +| `Admin` | `Address` | Contract administrator. | +| `AssetRegistry` | `Address` | Registry contract ownership changes are delegated to. | +| `NextRequestId` | `u64` | Monotonic request id counter, starts at 1. | +| `Requests` | `Map` | All transfer requests by id. | +| `Rules` | `Map, ApprovalRule>` | Approval rule per asset category. | +| `PendingApprovals` | `Map>` | Approvers who have signed off per request. | +| `ApprovalFlags` | `Map<(u64, Address), bool>` | Double-approval guard. | +| `ApprovalSignatures` | `Map<(u64, Address), BytesN<64>>` | Optional signature material. | +| `AssetPendingRequest` | `Map, u64>` | The open request for an asset, if any. | +| `AssetHistory` | `Map, Vec>` | All request ids ever raised for an asset. | + +Storage is centralized in [`src/storage.rs`](src/storage.rs) rather than +scattered across modules — this is the pattern the rest of the workspace should +follow. + +## Entrypoints + +| Entrypoint | Args | Returns | Auth | Errors | +|---|---|---|---|---| +| `initialize` | `admin, asset_registry` | `()` | — (none) | — | +| `configure_approval_rule` | `caller, rule` | `Result<()>` | admin check via `require_admin` | `NotInitialized`, `Unauthorized` | +| `create_transfer_request` | `caller, asset_id, asset_category, to_owner, ...` | `Result` | caller ownership check | `AssetNotFound`, `AssetRetired`, `InvalidOwner`, `InvalidNewOwner`, `PendingRequestExists`, `RuleNotFound` | +| `approve_transfer_request` | `caller, request_id, ...` | `Result<()>` | approver membership check | `RequestNotFound`, `RequestNotPending`, `ApproverNotAuthorized`, `CannotApproveOwnRequest`, `AlreadyApproved`, `ApprovalDeadlinePassed` | +| `reject_transfer_request` | `caller, request_id, reason_hash` | `Result<()>` | approver membership check | `RequestNotFound`, `RequestNotPending`, `ApproverNotAuthorized` | +| `execute_transfer` | `caller, request_id` | `Result<()>` | `caller` | `RequestNotFound`, `RequestNotPending`, `NotEnoughApprovals`, `ExecuteTooEarly`, `RequestExpired`, `RegistryCallFailed` | +| `cancel_transfer_request` | `caller, request_id` | `Result<()>` | requester or admin | `RequestNotFound`, `RequestNotPending`, `Unauthorized` | + +`initialize` performs **no** authorization and can be front-run on a freshly +deployed contract — see [SC-42]. + +### Reads + +`get_request`, `get_asset_history`, `get_pending_transfers_approver`, +`get_required_approvers_category`. None require auth and none mutate state. + +## Events + +Event emission is centralized in [`src/events.rs`](src/events.rs) — the only +crate in the workspace that does this. + +| Topic | Payload | +|---|---| +| `("TransferRequested",)` | `(request_id, asset_id, from_owner, to_owner, timestamp)` | +| `("TransferApproved",)` | `(request_id, approver, approval_count, timestamp)` | +| `("TransferRejected",)` | `(request_id, rejector, reason_hash, timestamp)` | +| `("TransferExecuted",)` | `(request_id, asset_id, new_owner, timestamp)` | +| `("TransferCancelled",)` | `(request_id, cancelled_by, timestamp)` | +| `("ApprovalRuleUpdated",)` | `(category, required_approvals, timestamp)` | +| `("ApproverAdded",)` | `(approver, added_by, timestamp)` | +| `("ApproverRemoved",)` | `(approver, removed_by, timestamp)` | + +Topics here are string literals in `PascalCase`, while the rest of the workspace +uses `symbol_short!` in `snake_case`. `ApproverAdded` and `ApproverRemoved` are +`#[allow(dead_code)]` — declared but never emitted. See [SC-36]. + +## Errors + +`MultiSigError`, defined in [`src/errors.rs`](src/errors.rs), codes 1–18. + +Note the collision risk with sibling contracts: code `1` is `NotInitialized` +here but `AlreadyInitialized` in `assetsup`, `contrib`, and `multisig-wallet`. +Resolving this is tracked in [SC-45]. + +## Module layout + +| Module | Responsibility | +|---|---| +| `lib.rs` | Contract entrypoints. | +| `storage.rs` | `DataKey` and typed storage accessors. | +| `types.rs` | `TransferRequest`, `ApprovalRule`, `RequestStatus`. | +| `approvals.rs` | Approval bookkeeping and double-approval guards. | +| `registry.rs` | Cross-contract calls into the asset registry. | +| `rules.rs` | Per-category approval rule lookup. | +| `events.rs` | All event emission. | +| `errors.rs` | `MultiSigError`. | +| `utils.rs` | `require_admin`, `now`. | + +## Tests + +```sh +cargo test -p multisig-transfer +``` + +This crate currently has **no tests at all**. Writing the suite is tracked in +[SC-32]. diff --git a/contracts/multisig_transfer/src/lib.rs b/contracts/multisig_transfer/src/lib.rs index 5cb3fc176..e7b5c3dd3 100644 --- a/contracts/multisig_transfer/src/lib.rs +++ b/contracts/multisig_transfer/src/lib.rs @@ -1,4 +1,34 @@ #![no_std] +//! # multisig-transfer +//! +//! An approval workflow for asset ownership transfers. +//! +//! A transfer is requested, collects approvals from authorized approvers until +//! a per-category threshold is met, and is then executed — at which point this +//! contract calls into a separate asset **registry contract** to actually move +//! ownership. +//! +//! ## Naming +//! +//! The directory is `multisig_transfer/` (underscore) but the package is +//! `multisig-transfer` (hyphen), so use `cargo test -p multisig-transfer`. +//! +//! ## Invariants +//! +//! - An asset has at most one pending transfer request at a time. +//! - An approver cannot approve the same request twice. +//! - A requester cannot approve their own request. +//! - Execution requires `approvals >= rule.required_approvals` for the asset's +//! category. +//! - Retired assets cannot be transferred. +//! +//! ## Not to be confused with `multisig-wallet` +//! +//! `multisig-wallet` is a generic *m-of-n* wallet over arbitrary transactions. +//! This crate is domain-specific to asset transfers. Neither calls the other. +//! +//! See [`README.md`](https://github.com/DistinctCodes/AssetsUp/blob/main/contracts/multisig_transfer/README.md) +//! for the full entrypoint, storage, event, and error tables. use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; diff --git a/contracts/rust-toolchain.toml b/contracts/rust-toolchain.toml new file mode 100644 index 000000000..f37d7cd81 --- /dev/null +++ b/contracts/rust-toolchain.toml @@ -0,0 +1,14 @@ +# Pinned toolchain for the contracts workspace. +# +# Every contracts job in .github/workflows/CI.yaml reads the `channel` value +# below, so local builds and CI always use the same compiler. Floating +# `stable` is deliberately avoided: a new stable release can introduce clippy +# lints that fail `-D warnings` on a pull request that changed nothing. +# +# Upgrading: bump `channel` in its own dedicated pull request so lint churn is +# isolated from feature work. See contracts/README.md#toolchain. +[toolchain] +channel = "1.96.0" +components = ["rustfmt", "clippy"] +targets = ["wasm32-unknown-unknown"] +profile = "minimal" diff --git a/contracts/scripts/README.md b/contracts/scripts/README.md new file mode 100644 index 000000000..4fa251a21 --- /dev/null +++ b/contracts/scripts/README.md @@ -0,0 +1,91 @@ +# Deployment scripts + +## `deploy.sh` + +Builds, optimizes, deploys, initializes, and verifies the full AssetsUp contract +suite in one command, then records the resulting contract ids for the backend to +consume. + +```sh +cd contracts +./scripts/deploy.sh --network testnet --source alice +``` + +| Flag | Meaning | +|---|---| +| `--network` | Stellar network: `testnet`, `futurenet`, `local`, `mainnet`. | +| `--source` | **Name** of a Stellar CLI identity to sign and pay with. | +| `--skip-build` | Reuse existing `.wasm` artifacts instead of rebuilding. | + +### Prerequisites + +1. **Rust toolchain** — pinned by [`../rust-toolchain.toml`](../rust-toolchain.toml). + `rustup` installs it automatically on first use inside `contracts/`, including + the `wasm32-unknown-unknown` target. + +2. **Stellar CLI** ≥ 22 on `PATH`: + + ```sh + cargo install --locked stellar-cli + stellar --version + ``` + +3. **A funded identity.** Generate and fund one on testnet: + + ```sh + stellar keys generate --network testnet alice + stellar keys address alice + ``` + +4. **bash 4+.** macOS ships bash 3.2; install a newer one with `brew install bash` + and invoke the script with it. The script checks this and fails early. + +### What it does + +1. **Build** — each deployable contract for `wasm32-unknown-unknown --release`. +2. **Optimize** — `stellar contract optimize`, reporting before/after sizes. +3. **Deploy** — in dependency order, so `assetsup` exists before the contracts + that take its address. +4. **Initialize** — wires cross-contract addresses: + - `assetsup.initialize(admin)` + - `contrib.initialize(admin)` + - `asset-maintenance.init(admin, registry = assetsup)` + - `multisig-transfer.initialize(admin, asset_registry = assetsup)` +5. **Verify** — calls a read entrypoint on each deployed contract and fails the + run if any does not respond. +6. **Record** — writes `deployments/.json`. + +### `multisig-wallet` is deliberately not initialized + +A wallet needs at least two owners and a real threshold; initializing it with +the deploying identity as sole owner would be both invalid +(`InsufficientOwners`) and a security mistake. The script deploys it and prints +the exact `initialize` command to run with your real signer set. + +### Output + +`deployments/.json` is **git-ignored** — it is environment state, not +source. [`../deployments/testnet.example.json`](../deployments/testnet.example.json) +is committed and documents the shape: + +```json +{ + "network": "testnet", + "admin": "G...", + "deployedAt": "2026-01-01T00:00:00Z", + "contracts": { + "assetsup": "C...", + "contrib": "C...", + "multisig-wallet": "C...", + "asset-maintenance": "C...", + "multisig-transfer": "C..." + } +} +``` + +### Key material + +The script never reads, writes, or logs a secret key. `--source` takes an +identity **name** that the Stellar CLI resolves from its own keystore; passing +something that looks like a secret key is rejected outright, because it would +otherwise land in shell history and CI logs. diff --git a/contracts/scripts/deploy.sh b/contracts/scripts/deploy.sh new file mode 100755 index 000000000..795137c13 --- /dev/null +++ b/contracts/scripts/deploy.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# +# Reproducible deployment of the AssetsUp contract suite. +# +# Handles build -> optimize -> deploy -> initialize -> verify for every +# deployable contract, and writes the resulting contract ids to +# deployments/.json for the backend to consume. +# +# Usage: +# ./scripts/deploy.sh --network testnet --source alice +# ./scripts/deploy.sh --network testnet --source alice --skip-build +# +# No key material is read or written by this script. Identities are managed by +# the Stellar CLI keystore and referenced by name only. + +set -euo pipefail + +# Associative arrays require bash 4+. macOS ships bash 3.2 as /bin/bash, so +# reach for a newer one (brew install bash) rather than failing obscurely later. +if (( BASH_VERSINFO[0] < 4 )); then + echo "error: bash 4+ required, found ${BASH_VERSION}." >&2 + echo " On macOS: brew install bash, then re-run with that bash." >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONTRACTS_DIR="$(dirname "$SCRIPT_DIR")" +cd "$CONTRACTS_DIR" + +NETWORK="" +SOURCE="" +SKIP_BUILD=0 +WASM_DIR="target/wasm32-unknown-unknown/release" + +# Deployable contracts, in dependency order. multisig-transfer is initialized +# with the assetsup registry address, so assetsup must be deployed first. +CONTRACTS=( + "assetsup" + "contrib" + "multisig-wallet" + "asset-maintenance" + "multisig-transfer" +) + +usage() { + cat <<'EOF' +Usage: ./scripts/deploy.sh --network --source [--skip-build] + + --network Stellar network to deploy to (testnet, futurenet, local, mainnet). + --source Name of a Stellar CLI identity to sign and pay with. Must be + funded. Never pass a secret key here. + --skip-build Reuse existing .wasm artifacts instead of rebuilding. + +Prerequisites: + - Rust toolchain pinned by contracts/rust-toolchain.toml (rustup installs it + automatically) with the wasm32-unknown-unknown target. + - stellar CLI >= 22 on PATH. + - A funded identity: stellar keys generate --network testnet +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --network) NETWORK="${2:-}"; shift 2 ;; + --source) SOURCE="${2:-}"; shift 2 ;; + --skip-build) SKIP_BUILD=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown argument '$1'" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$NETWORK" || -z "$SOURCE" ]]; then + echo "error: --network and --source are both required" >&2 + usage + exit 2 +fi + +if [[ "$SOURCE" == S* && ${#SOURCE} -eq 56 ]]; then + echo "error: --source looks like a secret key. Pass an identity NAME instead;" >&2 + echo " secrets must never appear in shell history or CI logs." >&2 + exit 2 +fi + +command -v stellar >/dev/null 2>&1 || { + echo "error: 'stellar' CLI not found on PATH. See https://developers.stellar.org/docs/tools/cli" >&2 + exit 1 +} + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } + +# Package name -> crate directory. These disagree for multisig-transfer. +crate_dir() { + case "$1" in + multisig-transfer) echo "multisig_transfer" ;; + *) echo "$1" ;; + esac +} + +# ---------------------------------------------------------------- build + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + log "Building ${#CONTRACTS[@]} contracts for wasm32-unknown-unknown (release)" + for c in "${CONTRACTS[@]}"; do + log " building $c" + cargo build --package "$c" --target wasm32-unknown-unknown --release + done +else + log "Skipping build (--skip-build)" +fi + +log "Optimizing WASM artifacts" +for c in "${CONTRACTS[@]}"; do + wasm="$WASM_DIR/${c//-/_}.wasm" + if [[ ! -f "$wasm" ]]; then + echo "error: expected artifact not found: $wasm" >&2 + exit 1 + fi + before=$(wc -c < "$wasm" | tr -d ' ') + stellar contract optimize --wasm "$wasm" >/dev/null + optimized="${wasm%.wasm}.optimized.wasm" + after=$(wc -c < "$optimized" | tr -d ' ') + printf ' %-20s %8s -> %8s bytes\n' "$c" "$before" "$after" +done + +# ---------------------------------------------------------------- deploy + +OUT_DIR="deployments" +OUT_FILE="$OUT_DIR/$NETWORK.json" +mkdir -p "$OUT_DIR" + +declare -A DEPLOYED + +log "Deploying to '$NETWORK' as identity '$SOURCE'" +for c in "${CONTRACTS[@]}"; do + wasm="$WASM_DIR/${c//-/_}.optimized.wasm" + id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source-account "$SOURCE" \ + --network "$NETWORK") + DEPLOYED["$c"]="$id" + printf ' %-20s %s\n' "$c" "$id" +done + +# ---------------------------------------------------------------- initialize + +ADMIN=$(stellar keys address "$SOURCE") +log "Initializing contracts (admin: $ADMIN)" + +invoke() { + local contract_id="$1"; shift + stellar contract invoke \ + --id "$contract_id" \ + --source-account "$SOURCE" \ + --network "$NETWORK" \ + -- "$@" +} + +log " assetsup.initialize" +invoke "${DEPLOYED[assetsup]}" initialize --admin "$ADMIN" >/dev/null + +log " contrib.initialize" +invoke "${DEPLOYED[contrib]}" initialize --admin "$ADMIN" >/dev/null + +log " asset-maintenance.init (registry: assetsup)" +invoke "${DEPLOYED[asset-maintenance]}" init \ + --admin "$ADMIN" --registry "${DEPLOYED[assetsup]}" >/dev/null + +log " multisig-transfer.initialize (registry: assetsup)" +invoke "${DEPLOYED[multisig-transfer]}" initialize \ + --admin "$ADMIN" --asset_registry "${DEPLOYED[assetsup]}" >/dev/null + +# multisig-wallet needs an owner set and a threshold. A single-owner wallet is +# invalid (InsufficientOwners), so it is left uninitialized here and must be +# initialized deliberately with the real signer set. +warn "multisig-wallet deployed but NOT initialized: it needs >= 2 owners and a" +warn "threshold. Initialize it explicitly with your real signer set:" +warn " stellar contract invoke --id ${DEPLOYED[multisig-wallet]} \\" +warn " --source-account $SOURCE --network $NETWORK \\" +warn " -- initialize --admin $ADMIN --owners '[...]' --threshold 2" + +# ---------------------------------------------------------------- verify + +log "Verifying deployments with a read call against each contract" + +verify() { + local name="$1" id="$2"; shift 2 + if invoke "$id" "$@" >/dev/null 2>&1; then + printf ' %-20s \033[1;32mOK\033[0m\n' "$name" + else + printf ' %-20s \033[1;31mFAILED\033[0m\n' "$name" + return 1 + fi +} + +failed=0 +verify "assetsup" "${DEPLOYED[assetsup]}" get_admin || failed=1 +verify "contrib" "${DEPLOYED[contrib]}" get_admin || failed=1 +verify "asset-maintenance" "${DEPLOYED[asset-maintenance]}" get_asset_stats --asset_id 0 || failed=1 +verify "multisig-transfer" "${DEPLOYED[multisig-transfer]}" get_asset_history --asset_id 0000000000000000000000000000000000000000000000000000000000000000 || failed=1 + +if [[ "$failed" -ne 0 ]]; then + echo "error: at least one contract failed verification" >&2 + exit 1 +fi + +# ---------------------------------------------------------------- record + +log "Writing $OUT_FILE" +{ + printf '{\n' + printf ' "network": "%s",\n' "$NETWORK" + printf ' "admin": "%s",\n' "$ADMIN" + printf ' "deployedAt": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf ' "contracts": {\n' + last_index=$(( ${#CONTRACTS[@]} - 1 )) + for i in "${!CONTRACTS[@]}"; do + c="${CONTRACTS[$i]}" + sep="," + [[ "$i" -eq "$last_index" ]] && sep="" + printf ' "%s": "%s"%s\n' "$c" "${DEPLOYED[$c]}" "$sep" + done + printf ' }\n' + printf '}\n' +} > "$OUT_FILE" + +log "Done. Contract ids are in $OUT_FILE (git-ignored)." From ff2659ab6b1143ec5074263d928bf198b48df4f2 Mon Sep 17 00:00:00 2001 From: northersubair <294447266+northersubair@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:00:56 +0100 Subject: [PATCH 2/2] Correct contrib capability and auth claims in the new docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying the documentation against the compiled crates turned up three inaccuracies in the tables added earlier in this branch. contrib is mostly dead code. Its lib.rs declares only audit, pause, types, insurance and lease; escrow, KYC, staking, oracle, tokenization, detokenization, restrictions and error.rs have no mod declaration and are not part of the crate — about 1,670 lines. The deployed ContribContract therefore has none of those capabilities and no typed errors at all, so describing contrib as "the crate that owns escrow and KYC" was wrong. The crate README now leads with this, and the workspace README, entrypoint tables, event table and SECURITY.md are corrected to match. Recorded as an accepted risk since anyone reading the source would reasonably assume otherwise. asset-maintenance: update_maintenance_schedule and complete_scheduled_maintenance were listed as having no authorization. They do authenticate, indirectly, by delegating to schedule_maintenance and add_maintenance_record. The genuinely unguarded entrypoints are init, add_warranty_information, update_warranty_information, file_warranty_claim and create_maintenance_alert; SECURITY.md is narrowed to those. multisig-wallet: emergency_unfreeze and set_daily_limit require an owner and return NotAnOwner, not Unauthorized. Refs #1218 Refs #1220 --- contracts/README.md | 16 ++- contracts/SECURITY.md | 8 +- contracts/asset-maintenance/README.md | 11 +- contracts/contrib/README.md | 149 +++++++++++++------------- contracts/multisig-wallet/README.md | 4 +- 5 files changed, 99 insertions(+), 89 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index 1a034fa23..5682d4315 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -9,7 +9,7 @@ of high-value transfers, escrow and KYC, and on-chain maintenance history. | Crate | Directory | Deployable | Responsibility | |---|---|---|---| | `assetsup` | [`assetsup/`](assetsup/) | yes | Primary asset registry: registration, ownership transfer, tokenization, dividends, voting, leasing, insurance, detokenization. | -| `contrib` | [`contrib/`](contrib/) | yes | Secondary registry carrying the capabilities `assetsup` does not have: escrow, KYC, staking, price oracle, and an emergency pause. | +| `contrib` | [`contrib/`](contrib/) | yes | Secondary registry: audit log, emergency pause, insurance, leasing. Most files in this crate are **not compiled** — see below. | | `multisig-wallet` | [`multisig-wallet/`](multisig-wallet/) | yes | General-purpose *m-of-n* wallet: transaction submission, confirmation, execution, owner/threshold governance, emergency freeze. | | `multisig-transfer` | [`multisig_transfer/`](multisig_transfer/) | yes | Approval workflow for asset transfers, gated on per-category approval rules. Calls into a registry contract to move ownership. | | `asset-maintenance` | [`asset-maintenance/`](asset-maintenance/) | yes | On-chain maintenance history, schedules, warranties, provider registry, and alerts. | @@ -36,15 +36,23 @@ a shared library: (~8,900 lines), it is the one the backend is expected to treat as the source of truth for asset identity and ownership, and it is the only one with dividends, voting, and detokenization proposals wired to a token supply. -- **`contrib` is a second registry with a different capability set.** It carries - escrow, KYC, staking, and an oracle — none of which exist in `assetsup` — plus - its own `pause` module, which `assetsup` only partially has. +- **`contrib` is a second, smaller registry** with an audit log, an emergency + pause, insurance, and leasing. Where a module name appears in both, the implementations have diverged and are not interchangeable. `assetsup::insurance` models policies and claims with a full claim state machine; `contrib::insurance` is a smaller policy/claim store. The same holds for `lease` and `audit`. +> **`contrib` is mostly dead code.** `contrib/src/lib.rs` declares only +> `audit`, `pause`, `types`, `insurance`, and `lease`. The other files in +> `contrib/src/` — escrow, KYC, staking, oracle, tokenization, detokenization, +> transfer restrictions, and its `error.rs` — have no `mod` declaration and are +> **not compiled into the crate**, about 1,670 lines in total. So the deployed +> `ContribContract` has no escrow, no KYC, no staking, no oracle, and no typed +> errors, despite the source files being present. See +> [`contrib/README.md`](contrib/README.md) before relying on any of them. + Resolving this duplication — deciding which crate owns each concern — is tracked in [SC-46]. Until that lands, treat the two as separate contracts and consult the per-crate README for the behaviour of the specific one you are diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index 92d840406..1b5f20756 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -20,7 +20,6 @@ as pre-production until an audit is complete and the open items in | **Asset owner** | The `owner` field of an asset | Transfer, retire, tokenize, lease, insure their own asset | Limited to that owner's assets. | | **Multisig signer** | Members of `owners` in `multisig-wallet` | Submit, confirm, and propose; *m* of them can execute anything | *m* compromised signers is equivalent to full wallet control. Fewer than *m* can grief by consuming ids but cannot execute. | | **Approver** | Addresses satisfying a `multisig-transfer` `ApprovalRule` | Approve or reject transfer requests | Enough colluding approvers can move any asset in the category they govern. | -| **Oracle source** | Allowlisted addresses in `contrib::oracle` | Write asset valuations | Can distort valuations, which feed dividend and share calculations. | | **Backend signer** | The service account the API signs with | Whatever role it has been granted on-chain | It is a hot key in a server process. Grant it the narrowest role that works — registrar, never admin. | ### Who is not trusted @@ -44,7 +43,7 @@ authenticate callers implicitly. An entrypoint is protected only if it calls | Contract | What an attacker gains | Highest-risk entrypoints | |---|---|---| | `assetsup` | Ownership of any registered asset; fractional share balances; undistributed dividends | `transfer_asset_ownership`, `register_asset`, `retire_asset`, `mint_tokens`, `distribute_dividends` | -| `contrib` | Escrowed value; KYC approval status; staked balances; valuation feed | `create_escrow`, `confirm_release`, `approve_kyc`, `update_valuation`, `unstake_tokens` | +| `contrib` | Ownership of any asset in its own registry; insurance policy and claim state | `register_asset`, `transfer_asset`, `retire_asset`, `update_claim_status` | | `multisig-wallet` | Anything the wallet controls | `execute_transaction`, `execute_proposal`, `emergency_unfreeze` | | `multisig-transfer` | Ownership of assets whose category rule it governs | `execute_transfer`, `configure_approval_rule`, `initialize` | | `asset-maintenance` | Falsified audit evidence; fraudulent warranty claims | `add_maintenance_record`, `file_warranty_claim`, `add_warranty_information` | @@ -57,7 +56,7 @@ until the ones marked **blocking** are fixed. | # | Risk | Status | |---|---|---| | 1 | **`assetsup` does not authenticate `caller`.** `register_asset`, `update_asset_metadata`, `transfer_asset_ownership`, and `retire_asset` compare a caller-supplied `caller` argument against an allowlist/owner/admin but never call `caller.require_auth()`. Any account can name a privileged address and pass the check. `transfer_asset_ownership` is a direct asset-theft path. | **Blocking** — [SC-42] | -| 2 | **Unguarded entrypoints in `asset-maintenance`.** `init`, `update_maintenance_schedule`, `complete_scheduled_maintenance`, `add_warranty_information`, `update_warranty_information`, `file_warranty_claim`, and `create_maintenance_alert` perform no authorization at all. Audit evidence is forgeable. | **Blocking** — [SC-42] | +| 2 | **Unguarded entrypoints in `asset-maintenance`.** `init`, `add_warranty_information`, `update_warranty_information`, `file_warranty_claim`, and `create_maintenance_alert` perform no authorization at all. Warranty terms and claims are forgeable by anyone. | **Blocking** — [SC-42] | | 3 | **`initialize` is front-runnable** in `contrib` and `multisig-transfer` — neither authorizes the caller, so whoever calls first becomes admin. Deploy and initialize in the same transaction, or accept the race. | **Blocking** — [SC-42] | | 4 | **Admin transfer is single-step.** One typo permanently bricks administration, with no on-chain undo. | Open — [SC-48] | | 5 | **Arithmetic can trap rather than error.** `overflow-checks = true` turns overflow into a panic. Value paths should return typed errors. | Open — [SC-43] | @@ -67,7 +66,8 @@ until the ones marked **blocking** are fixed. | 9 | **No dependency scanning.** Nothing checks for advisories in transitive dependencies. | Open — [SC-38] | | 10 | **Error codes collide across contracts.** The same integer means different things per contract, so a backend cannot map a code without knowing which contract produced it. | Open — [SC-45] | | 11 | **`multisig_transfer` has no tests**, and `multisig-wallet` and `asset-maintenance` have four each. | Open — [SC-32], [SC-40], [SC-41] | -| 12 | **Contracts are unaudited.** No external review has been performed. | Open | +| 12 | **Roughly 1,670 lines of `contrib` are not compiled.** Escrow, KYC, staking, oracle, tokenization, detokenization, transfer restrictions, and its `error.rs` have no `mod` declaration, so the deployed contract does not have those capabilities and has no typed errors at all. Anyone reading the source would reasonably assume otherwise. | Open — [SC-46] | +| 13 | **Contracts are unaudited.** No external review has been performed. | Open | ## Pre-deployment checklist diff --git a/contracts/asset-maintenance/README.md b/contracts/asset-maintenance/README.md index c9a492f0e..68500916d 100644 --- a/contracts/asset-maintenance/README.md +++ b/contracts/asset-maintenance/README.md @@ -56,13 +56,16 @@ tracked in [SC-42]. | `add_maintenance_record` | `record: MaintenanceRecord` | `()` | `record.provider` | | `get_maintenance_history` | `asset_id` | `Vec` | read-only | | `schedule_maintenance` | `owner, schedule` | `()` | `owner` | -| `update_maintenance_schedule` | `owner, schedule` | `()` | — (none) | -| `complete_scheduled_maintenance` | `asset_id, record` | `()` | — (none) | +| `update_maintenance_schedule` | `owner, schedule` | `()` | `owner` (via `schedule_maintenance`) | +| `complete_scheduled_maintenance` | `asset_id, record` | `()` | `record.provider` (via `add_maintenance_record`) | | `get_upcoming_maintenance` | `asset_id` | `Option` | read-only | | `get_overdue_maintenance` | `asset_id` | `bool` | read-only | -`update_maintenance_schedule` takes an `owner` argument but never calls -`owner.require_auth()`, so the argument is currently decorative. +`update_maintenance_schedule` and `complete_scheduled_maintenance` authenticate +indirectly: they delegate to `schedule_maintenance` and +`add_maintenance_record` respectively, which call `require_auth()`. The +protection is real but easy to miss, and easy to break by refactoring the +delegation away. ### Warranties diff --git a/contracts/contrib/README.md b/contracts/contrib/README.md index 12f118697..b86c34665 100644 --- a/contracts/contrib/README.md +++ b/contracts/contrib/README.md @@ -1,64 +1,92 @@ # `contrib` -A second asset registry carrying the capabilities `assetsup` does not have: -**escrow**, **KYC**, **staking**, a **price oracle**, and a first-class -**emergency pause**. +A second asset registry with an audit log, an emergency pause, insurance +policies and claims, and leasing. Contract type: `ContribContract`. Deployable (`crate-type = ["lib", "cdylib"]`). +## ⚠️ Most of this directory is not compiled + +`contrib/src/lib.rs` declares only five modules: + +```rust +mod audit; +mod pause; +mod types; +mod insurance; +mod lease; +``` + +Every other `.rs` file in `contrib/src/` has **no `mod` declaration and is +therefore not part of the crate** — roughly 1,670 lines that never compile and +never ship: + +| File | Lines | File | Lines | +|---|---:|---|---:| +| `tokenization.rs` | 392 | `kyc.rs` | 107 | +| `restrictions.rs` | 177 | `test.rs` | 102 | +| `detokenization.rs` | 162 | `escrow.rs` | 101 | +| `staking.rs` | 138 | `oracle.rs` | 95 | +| `oracle_test.rs` | 133 | `error.rs` | 42 | +| `kyc_test.rs` | 115 | | | +| `staking_test.rs` | 108 | | | + +Consequences worth being explicit about: + +- **`ContribContract` does not expose escrow, KYC, staking, oracle, + tokenization, detokenization, or transfer restrictions.** Reading those files + will tell you nothing about the deployed contract. +- **`contrib` has no typed errors.** `error.rs` defines an `Error` enum that + nothing references, so failures surface as `panic!` on a string. +- `tokenization.rs` does not even parse as valid contract code; it has never + compiled. +- The orphaned `*_test.rs` files never run. All 35 passing tests come from + `src/tests/`. + +Whether to wire these modules in or delete them is part of the `assetsup` / +`contrib` consolidation tracked in [SC-46]. + ## Relationship to `assetsup` `contrib` and `assetsup` are **two independent contracts with separate storage**. Neither reads the other's state, and neither calls the other. Several module -names appear in both (`audit`, `detokenization`, `insurance`, `lease`, -`tokenization`, transfer restrictions) but the implementations have diverged -and are **not** interchangeable. +names appear in both (`audit`, `insurance`, `lease`) but the implementations +have diverged and are **not** interchangeable. -What each crate uniquely owns today: +What each crate actually ships today: | Concern | `assetsup` | `contrib` | |---|---|---| | Asset registry | ✅ authoritative | ✅ separate copy | -| Escrow | — | ✅ only here | -| KYC | — | ✅ only here | -| Staking | — | ✅ only here | -| Price oracle | — | ✅ only here | | Emergency pause | partial | ✅ dedicated `pause` module | -| Dividends, voting | ✅ only here | — | -| Insurance | ✅ full claim state machine | smaller policy/claim store | -| Leasing | ✅ richer lifecycle | check-in/cancel only | +| Audit log | ✅ | ✅ | +| Insurance | ✅ full claim state machine | ✅ smaller policy/claim store | +| Leasing | ✅ richer lifecycle | ✅ check-in/cancel only | +| Tokenization, dividends, voting, detokenization | ✅ only here | ❌ present as dead files only | +| Escrow, KYC, staking, price oracle | — | ❌ present as dead files only | -Deciding which crate owns each duplicated concern is tracked in [SC-46]. Until -that lands, treat them as separate deployments and read this file for -`contrib`'s actual behaviour. +The often-repeated idea that `contrib` is where escrow and KYC live is not true +of the compiled contract. ## Invariants - An asset has exactly one owner at any time. - A retired asset cannot be transferred. - While paused, every mutating registry entrypoint rejects; reads still work. -- An escrow's funds are either released to the beneficiary or returned to the - depositor — never both. -- A KYC record is approved only by the admin and carries an expiry. ## Module layout +Compiled modules only — see the section above for the files that are not part +of the crate. + | Module | Responsibility | |---|---| | `lib.rs` | Registry entrypoints and re-exported module facades. | | `types.rs` | `AssetStatus` and shared types. | -| `error.rs` | `Error` enum and `handle_error`. | | `pause.rs` | `pause`, `unpause`, `is_paused`, `require_not_paused`. | | `audit.rs` | Append-only audit log per asset. | -| `escrow.rs` | Escrow creation, release, cancellation. | -| `kyc.rs` | KYC submission, approval, revocation, tiers. | -| `staking.rs` | Token staking, unstaking, reward accrual. | -| `oracle.rs` | Oracle allowlist and asset valuation feed. | | `insurance.rs` | Policies and claims. | | `lease.rs` | Lease creation, check-in, cancellation. | -| `tokenization.rs` | Fractional shares. | -| `detokenization.rs` | Detokenization proposals. | -| `restrictions.rs` | Whitelists and transfer validation. | ## Storage layout @@ -73,8 +101,6 @@ that lands, treat them as separate deployments and read this file for | `AuditLogCount` | `u64` | Audit entry counter. | | `AuditLogs(BytesN<32>)` | `Vec` | Audit trail per asset. | -Escrow, KYC, staking, and oracle modules define their own keys. - ## Entrypoints ### Registry @@ -108,41 +134,18 @@ Reads: `get_admin`, `get_asset`, `get_asset_info`, `get_assets_by_owner`, `pause::require_not_paused` is the guard mutating entrypoints call. Verifying that **every** mutating entrypoint calls it is tracked in [SC-47]. -### Escrow - -| Entrypoint | Args | Auth | -|---|---|---| -| `create_escrow` | `depositor, beneficiary, amount, ...` | `depositor` | -| `confirm_release` | `escrow_id` | depositor/arbiter check | -| `cancel_escrow` | `escrow_id` | depositor/arbiter check | -| `get_escrow` | `escrow_id` | read-only | - -### KYC - -| Entrypoint | Args | Auth | -|---|---|---| -| `init` | `admin` | — (none) | -| `submit_kyc` | `address` | `address` | -| `approve_kyc` | `address, tier, expires_at` | admin | -| `revoke_kyc` | `address` | admin | -| `is_kyc_approved` / `get_kyc_record` | `address` | read-only | - -### Staking - -`init`, `stake_tokens`, `unstake_tokens`, `get_staking_power`, -`accrue_staking_rewards`. - -### Oracle - -`init`, `add_oracle`, `remove_oracle`, `update_valuation` (restricted to -allowlisted oracle sources), `get_latest_valuation`, `get_valuation_history`. - ### Insurance and leasing `create_policy`, `get_policy`, `cancel_policy`, `is_policy_active`, `submit_claim`, `update_claim_status`, `get_claim`, `get_claims_for_policy`, `create_lease`, `check_in_lease`, `cancel_lease`, `get_active_leases`. +### Not present + +There are no escrow, KYC, staking, oracle, tokenization, detokenization, or +transfer-restriction entrypoints. Source files for them exist in `contrib/src/` +but are not compiled into the crate — see the warning at the top of this file. + ## Events `contrib` mixes two emission styles — `symbol_short!` with abbreviated @@ -153,27 +156,23 @@ allowlisted oracle sources), `get_latest_valuation`, `get_valuation_history`. | `("asset_reg", asset_id)` | `register_asset` | | `("asset_tra", asset_id)` | `transfer_asset` | | `("asset_ret", asset_id)` | `retire_asset` | -| `("escrow_created", escrow_id)` | `create_escrow` | -| `("escrow_completed", escrow_id)` | `confirm_release` | -| `("escrow_cancelled", escrow_id)` | `cancel_escrow` | -| `("kyc_submitted", address)` | `submit_kyc` | -| `("kyc_approved", address)` | `approve_kyc` | -| `("kyc_revoked", address)` | `revoke_kyc` | -| `("oracle_added", oracle)` / `("oracle_removed", oracle)` | oracle allowlist | -| `("valuation_updated", asset_id)` | `update_valuation` | -| `("staked", asset_id, staker)` / `("unstaked", asset_id, staker)` | staking | -| `("rewards_accrued", asset_id)` | `accrue_staking_rewards` | | `("pol_cre", policy_id)`, `("pol_can", policy_id)` | insurance policies | | `("clm_sub", claim_id)`, `("clm_upd", claim_id)` | insurance claims | | `("lease_cr", lease_id)`, `("lease_in", lease_id)`, `("lease_can", lease_id)` | leasing | +| `("pause",)`, `("unpause",)` | `pause_contract`, `unpause_contract` | -Unifying this is tracked in [SC-36]. +`initialize` and the registrar allowlist changes emit **no** event. Unifying +the convention and closing those gaps is tracked in [SC-36]. ## Errors -`Error`, defined in [`src/error.rs`](src/error.rs). Codes 1–21 and 28–32; note -the gap at 22–27 and that the numbering **overlaps `assetsup` with different -meanings** — code 5 is `Unauthorized` here but `BranchAlreadyExists` in +**`contrib` has no typed errors in compiled code.** `src/error.rs` defines an +`Error` enum with codes 1–21 and 28–32, but the file is not declared as a +module and nothing references it, so every failure surfaces as a `panic!` on a +string rather than a `contracterror` a caller can match on. + +Were it wired in, its numbering would **overlap `assetsup` with different +meanings** — code 5 is `Unauthorized` there but `BranchAlreadyExists` in `assetsup`. Tracked in [SC-45]. ## Tests @@ -182,5 +181,5 @@ meanings** — code 5 is `Unauthorized` here but `BranchAlreadyExists` in cargo test -p contrib ``` -35 tests across [`src/tests/`](src/tests/) plus the module-level `*_test.rs` -files. +All 35 tests live in [`src/tests/`](src/tests/). The `*_test.rs` files at the +top of `src/` are not compiled and never run. diff --git a/contracts/multisig-wallet/README.md b/contracts/multisig-wallet/README.md index bfa2f5558..3f6e2b1be 100644 --- a/contracts/multisig-wallet/README.md +++ b/contracts/multisig-wallet/README.md @@ -78,8 +78,8 @@ risk. | Entrypoint | Args | Returns | Auth | Errors | |---|---|---|---|---| | `emergency_freeze` | `caller` | `Result<()>` | `caller` | `NotAnOwner` | -| `emergency_unfreeze` | `caller` | `Result<()>` | `caller` | `Unauthorized` | -| `set_daily_limit` | `caller, limit` | `Result<()>` | `caller` | `Unauthorized` | +| `emergency_unfreeze` | `caller` | `Result<()>` | `caller` (must be an owner) | `NotAnOwner` | +| `set_daily_limit` | `caller, limit` | `Result<()>` | `caller` (must be an owner) | `NotAnOwner` | ### Reads