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..5682d4315 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,127 @@ +# 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: 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. | + +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, 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 +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..1b5f20756 --- /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. | +| **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` | 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` | + +## 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`, `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] | +| 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 | **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 + +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..68500916d --- /dev/null +++ b/contracts/asset-maintenance/README.md @@ -0,0 +1,163 @@ +# `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` | `()` | `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` 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 + +| 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..b86c34665 --- /dev/null +++ b/contracts/contrib/README.md @@ -0,0 +1,185 @@ +# `contrib` + +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`, `insurance`, `lease`) but the implementations +have diverged and are **not** interchangeable. + +What each crate actually ships today: + +| Concern | `assetsup` | `contrib` | +|---|---|---| +| Asset registry | ✅ authoritative | ✅ separate copy | +| Emergency pause | partial | ✅ dedicated `pause` module | +| 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 | + +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. + +## 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. | +| `pause.rs` | `pause`, `unpause`, `is_paused`, `require_not_paused`. | +| `audit.rs` | Append-only audit log per asset. | +| `insurance.rs` | Policies and claims. | +| `lease.rs` | Lease creation, check-in, cancellation. | + +## 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. | + +## 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]. + +### 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 +`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` | +| `("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` | + +`initialize` and the registrar allowlist changes emit **no** event. Unifying +the convention and closing those gaps is tracked in [SC-36]. + +## Errors + +**`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 + +```sh +cargo test -p contrib +``` + +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/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..3f6e2b1be --- /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` (must be an owner) | `NotAnOwner` | +| `set_daily_limit` | `caller, limit` | `Result<()>` | `caller` (must be an owner) | `NotAnOwner` | + +### 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)."