diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 3b7854c78..14a57ce92 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -132,8 +132,33 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-wasm- ${{ runner.os }}-cargo- - - name: Build contrib contract to WASM - run: cargo build --package contrib --target wasm32-unknown-unknown --release --verbose + # Every crate in the workspace is a deployable contract: each declares a + # `cdylib` crate-type and a `#[contract]` entrypoint. Building the whole + # workspace for wasm32 therefore fails the job if any contract cannot be + # deployed, which native compilation alone does not guarantee. + - name: Build all contracts to WASM + run: cargo build --workspace --target wasm32-unknown-unknown --release --verbose + + - name: Report WASM artifact sizes + run: | + echo "### WASM artifact sizes" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Contract | Size (bytes) |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---:|" >> "$GITHUB_STEP_SUMMARY" + for wasm in target/wasm32-unknown-unknown/release/*.wasm; do + name=$(basename "$wasm" .wasm) + size=$(wc -c < "$wasm" | tr -d ' ') + printf '| %s | %s |\n' "$name" "$size" >> "$GITHUB_STEP_SUMMARY" + done + cat "$GITHUB_STEP_SUMMARY" + + - name: Upload WASM artifacts + uses: actions/upload-artifact@v4 + with: + name: contract-wasm + path: contracts/target/wasm32-unknown-unknown/release/*.wasm + if-no-files-found: error + retention-days: 14 # ───────────────────────────────────────────── # BACKEND — NestJS diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index d697505f6..03925589c 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -176,12 +176,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" @@ -209,6 +203,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes-lit" version = "0.0.5" @@ -237,6 +237,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "chrono" version = "0.4.42" @@ -313,14 +324,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" dependencies = [ - "quote", - "syn 2.0.106", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -484,6 +501,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -633,6 +665,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -654,6 +695,22 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -801,6 +858,17 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1033,6 +1101,17 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -1125,11 +1204,12 @@ version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.11.4", + "schemars 0.8.22", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -1196,9 +1276,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "soroban-builtin-sdk-macros" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" +checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85" dependencies = [ "itertools", "proc-macro2", @@ -1208,9 +1288,9 @@ dependencies = [ [[package]] name = "soroban-env-common" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" +checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3" dependencies = [ "arbitrary", "crate-git-revision", @@ -1227,9 +1307,9 @@ dependencies = [ [[package]] name = "soroban-env-guest" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" +checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6" dependencies = [ "soroban-env-common", "static_assertions", @@ -1237,9 +1317,9 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e8b03a4191d485eab03f066336112b2a50541a7553179553dc838b986b94dd" +checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725" dependencies = [ "ark-bls12-381", "ark-ec", @@ -1267,15 +1347,15 @@ dependencies = [ "soroban-env-common", "soroban-wasmi", "static_assertions", - "stellar-strkey", + "stellar-strkey 0.0.13", "wasmparser", ] [[package]] name = "soroban-env-macros" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00eff744764ade3bc480e4909e3a581a240091f3d262acdce80b41f7069b2bd9" +checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c" dependencies = [ "itertools", "proc-macro2", @@ -1288,9 +1368,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "22.0.10" +version = "23.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f420c93b0e44c6d08207adf86b6c608a95b272440f55737f20dcb3812e1980f" +checksum = "fea7299402f5f445089fde192cc68587baf0cc6432be300bce99d997fd85b4cb" dependencies = [ "serde", "serde_json", @@ -1302,12 +1382,13 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "22.0.10" +version = "23.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d702069aa16d36dee1d6b3c75e4bfae8e2692f2cbc16496652757d18d8533f7e" +checksum = "dd1e11f9fd537f9df29ec1a66c1b78d666094df56e196565d292ebf9a72732b4" dependencies = [ "arbitrary", "bytes-lit", + "crate-git-revision", "ctor", "derive_arbitrary", "ed25519-dalek", @@ -1319,21 +1400,22 @@ dependencies = [ "soroban-env-host", "soroban-ledger-snapshot", "soroban-sdk-macros", - "stellar-strkey", + "stellar-strkey 0.0.16", + "visibility", ] [[package]] name = "soroban-sdk-macros" -version = "22.0.10" +version = "23.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965b977c52d16dd8dfd9074702583e5a5778f230cda0fa78b2ea743e9149ac8d" +checksum = "caa7114e2f031b6fbd30376e844f3c55f6daf56f6f9d33ce309f846ffced316d" dependencies = [ - "crate-git-revision", "darling 0.20.11", + "heck", "itertools", + "macro-string", "proc-macro2", "quote", - "rustc_version", "sha2", "soroban-env-common", "soroban-spec", @@ -1344,11 +1426,11 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "22.0.10" +version = "23.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ead5de5373f3278707c06142a12bd6bcf4cbe211a89a74904ae238b3aaabc5" +checksum = "1303589e67e7c76d0571b8d797b75f9fa33a1ceb1b4361a981570a3ebf00ac19" dependencies = [ - "base64 0.13.1", + "base64", "stellar-xdr", "thiserror", "wasmparser", @@ -1356,9 +1438,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "22.0.10" +version = "23.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9dd041c94ce48aa9f7f115e748ee9fa5b9f263df4a70178f060dd0876e15d11" +checksum = "956188f28b750b80a2bb4cd5698038746edae1be51ec19a29c7efc6dcd922e5f" dependencies = [ "prettyplease", "proc-macro2", @@ -1399,6 +1481,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1407,29 +1495,42 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stellar-strkey" -version = "0.0.9" +version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" +checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" dependencies = [ "crate-git-revision", "data-encoding", - "thiserror", +] + +[[package]] +name = "stellar-strkey" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" +dependencies = [ + "crate-git-revision", + "data-encoding", + "heapless", ] [[package]] name = "stellar-xdr" -version = "22.1.0" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" +checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" dependencies = [ "arbitrary", - "base64 0.13.1", + "base64", + "cfg_eval", "crate-git-revision", "escape-bytes", + "ethnum", "hex", "serde", "serde_with", - "stellar-strkey", + "sha2", + "stellar-strkey 0.0.13", ] [[package]] @@ -1535,6 +1636,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 62be99f9c..e309bd3aa 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,15 +1,20 @@ [workspace] resolver = "2" + +# Naming convention: crate (package) names use hyphens, matching Rust ecosystem +# norms, and every directory name matches its package name. That keeps +# `cargo -p ` working for all five crates. Member paths are +# listed bare and alphabetically — no `./` prefixes, no mixed separators. members = [ - "./asset-maintenance", + "asset-maintenance", "assetsup", "contrib", + "multisig-transfer", "multisig-wallet", - "multisig_transfer", ] [workspace.dependencies] -soroban-sdk = "22" +soroban-sdk = "23" [profile.release] opt-level = "z" diff --git a/contracts/EVENTS.md b/contracts/EVENTS.md new file mode 100644 index 000000000..920bb5216 --- /dev/null +++ b/contracts/EVENTS.md @@ -0,0 +1,224 @@ +# Contract event catalogue + +Every event emitted by the AssetsUp contracts, for consumers of the backend +Soroban bridge. + +## Convention + +All events are **typed** `#[contractevent]` structs, introduced with the +`soroban-sdk` 23 upgrade. Each crate declares them in its own `events.rs`, which +is the only place events are published from. + +The rules: + +1. **Name.** The struct is named `` — `AssetRegistered`, + `TransferExecuted`, `LeaseCancelled`. The SDK derives **topic 0** from the + struct name in `lower_snake_case`, so `AssetRegistered` publishes under the + topic `asset_registered`. +2. **Topics.** The identifier a consumer would filter on — an asset id, a + request id, an owner address — is marked `#[topic]` and becomes topic 1. + Events that are not about a single entity have no extra topic. +3. **Data.** Every remaining field goes into the data map, keyed by field name. +4. **Timestamp.** Events carry a `timestamp` field where the emitting entrypoint + has one meaningful to record. + +Because `#[contractevent]` types are emitted into the **contract spec**, clients +can generate bindings for them instead of hand-decoding positional tuples. This +is the main practical gain over the previous approach, where topics were an +inconsistent mix of `symbol_short!`, `Symbol::new`, and bare string tuples, and +payloads were untyped tuples whose shape had to be read out of the source. + +### Reading events in tests + +`env.events().all()` returns the events of the **most recent contract +invocation**, not every event since the test began. Two consequences worth +knowing before writing an event test: + +- Comparing a count before and after a call does not work. Both readings see one + invocation's worth of events, so the difference is meaningless. +- **Any** invocation resets the view, including a read-only entrypoint. Calling + `client.get_threshold()` after the call under test will leave + `events().all()` empty. Read the events first, assert, then make other calls. + +Assert against the event you just triggered: + +```rust +let events = env.events().all(); +assert_eq!(events.len(), 1); + +let (_contract, topics, _data) = events.last().unwrap(); +let name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); +assert_eq!(name, Symbol::new(&env, "asset_registered")); +``` + +--- + +## `assetsup` + +### Registry + +| Event (topic 0) | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `contract_initialized` | `admin` | `timestamp` | `initialize` | +| `asset_registered` | `asset_id` | `owner`, `timestamp` | `register_asset` | +| `asset_updated` | `asset_id` | `caller`, `timestamp` | `update_asset_metadata` | +| `asset_transferred` | `asset_id` | `old_owner`, `new_owner`, `timestamp` | `transfer_asset_ownership` | +| `asset_retired` | `asset_id` | `caller`, `timestamp` | `retire_asset` | +| `admin_proposed` | `proposed_admin` | `current_admin`, `timestamp` | `propose_admin` | +| `admin_proposal_cancelled` | `proposed_admin` | `current_admin`, `timestamp` | `cancel_admin_proposal` | +| `admin_changed` | `new_admin` | `old_admin`, `timestamp` | `accept_admin` | +| `registrar_added` | `registrar` | `timestamp` | `add_authorized_registrar` | +| `registrar_removed` | `registrar` | `timestamp` | `remove_authorized_registrar` | +| `contract_paused` | `admin` | `timestamp` | `pause_contract` | +| `contract_unpaused` | `admin` | `timestamp` | `unpause_contract` | + +### Tokenization + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `asset_tokenized` | `asset_id` | `total_supply`, `symbol`, `decimals`, `tokenizer` | `tokenize_asset` | +| `tokens_minted` | `asset_id` | `amount`, `total_supply` | `mint_tokens` | +| `tokens_burned` | `asset_id` | `amount`, `total_supply` | `burn_tokens` | +| `tokens_transferred` | `asset_id` | `from`, `to`, `amount` | `transfer_tokens` | +| `tokens_locked` | `asset_id` | `holder`, `until_timestamp` | `lock_tokens` | +| `tokens_unlocked` | `asset_id` | `holder`, `timestamp` | `unlock_tokens` | +| `valuation_updated` | `asset_id` | `new_valuation` | `update_valuation` | +| `asset_detokenized` | `asset_id` | `proposal_id`, `total_supply` | `execute_detokenization` | + +### Dividends and voting + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `dividend_distributed` | `asset_id` | `total_amount`, `holder_count` | `distribute_dividends` | +| `dividend_claimed` | `asset_id` | `holder`, `amount` | `claim_dividends` | +| `vote_cast` | `asset_id` | `proposal_id`, `voter`, `voting_power` | `cast_vote` | + +### Transfer restrictions + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `restriction_set` | `asset_id` | `require_accredited` | `set_transfer_restriction` | +| `whitelist_added` | `asset_id` | `address` | `add_to_whitelist` | +| `whitelist_removed` | `asset_id` | `address` | `remove_from_whitelist` | + +### Leasing + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `lease_created` | `lease_id` | `asset_id`, `lessor`, `lessee`, `timestamp` | `create_lease` | +| `lease_returned` | `lease_id` | `caller`, `timestamp` | `return_leased_asset` | +| `lease_cancelled` | `lease_id` | `caller`, `timestamp` | `cancel_lease` | +| `lease_expired` | `lease_id` | `timestamp` | `expire_lease` | + +--- + +## `contrib` + +> Only the modules `contrib/src/lib.rs` declares are compiled: `audit`, +> `pause`, `types`, `insurance`, `lease`. The escrow, KYC, staking, oracle, +> tokenization, detokenization, and restrictions files exist in the crate +> directory but are **not part of the crate**, so they emit nothing. See the +> crate README. + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `contract_initialized` | `admin` | `timestamp` | `initialize` | +| `asset_registered` | `asset_id` | `owner`, `timestamp` | `register_asset` | +| `asset_transferred` | `asset_id` | `old_owner`, `new_owner`, `timestamp` | `transfer_asset` | +| `asset_retired` | `asset_id` | `caller`, `timestamp` | `retire_asset` | +| `registrar_added` | `registrar` | `caller`, `timestamp` | `add_authorized_registrar` | +| `registrar_removed` | `registrar` | `caller`, `timestamp` | `remove_authorized_registrar` | +| `contract_paused` | `caller` | `timestamp` | `pause_contract` | +| `contract_unpaused` | `caller` | `timestamp` | `unpause_contract` | +| `policy_created` | `policy_id` | `asset_id`, `insurer`, `holder`, `timestamp` | `create_policy` | +| `policy_cancelled` | `policy_id` | `caller`, `timestamp` | `cancel_policy` | +| `claim_submitted` | `claim_id` | `policy_id`, `claimant`, `amount`, `timestamp` | `submit_claim` | +| `claim_status_updated` | `claim_id` | `status`, `timestamp` | `update_claim_status` | +| `lease_created` | `lease_id` | `asset_id`, `lessor`, `lessee`, `timestamp` | `create_lease` | +| `lease_checked_in` | `lease_id` | `timestamp` | `check_in_lease` | +| `lease_cancelled` | `lease_id` | `caller`, `timestamp` | `cancel_lease` | + +--- + +## `multisig-wallet` + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `wallet_initialized` | `admin` | `owners`, `threshold`, `timestamp` | `initialize` | +| `transaction_submitted` | `tx_id` | `initiator`, `tx_type`, `timestamp` | `submit_transaction` | +| `transaction_confirmed` | `tx_id` | `confirmer`, `confirmations_count`, `timestamp` | `confirm_transaction` | +| `confirmation_revoked` | `tx_id` | `revoker`, `timestamp` | `revoke_confirmation` | +| `transaction_executed` | `tx_id` | `initiator`, `timestamp` | `execute_transaction` | +| `transaction_cancelled` | `tx_id` | `caller`, `timestamp` | `cancel_transaction` | +| `proposal_submitted` | `proposal_id` | `proposer`, `proposal_type`, `timestamp` | `propose_add_owner`, `propose_remove_owner`, `propose_change_threshold` | +| `proposal_confirmed` | `proposal_id` | `confirmer`, `confirmations_count`, `timestamp` | `confirm_proposal` | +| `owner_added` | `owner` | `proposer`, `timestamp` | `execute_proposal` (AddOwner) | +| `owner_removed` | `owner` | `proposer`, `timestamp` | `execute_proposal` (RemoveOwner) | +| `threshold_changed` | — | `old_threshold`, `new_threshold`, `timestamp` | `execute_proposal` (ChangeThreshold) | +| `wallet_frozen` | `caller` | `timestamp` | `emergency_freeze` | +| `wallet_unfrozen` | `caller` | `timestamp` | `emergency_unfreeze` | +| `daily_limit_changed` | `caller` | `limit`, `timestamp` | `set_daily_limit` | +| `daily_limit_reached` | — | `limit`, `attempted_total`, `timestamp` | daily-limit check, before `DailyLimitExceeded` | + +`daily_limit_reached` is emitted on a **rejected** operation, not a state change. +It is the one deliberate exception to "events mark state changes"; it exists so +operators can see limit pressure. + +--- + +## `multisig-transfer` + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `contract_initialized` | `admin` | `asset_registry`, `timestamp` | `initialize` | +| `transfer_requested` | `request_id` | `asset_id`, `from_owner`, `to_owner`, `timestamp` | `create_transfer_request` | +| `transfer_approved` | `request_id` | `approver`, `approval_count`, `timestamp` | `approve_transfer_request` | +| `transfer_rejected` | `request_id` | `rejector`, `reason_hash`, `timestamp` | `reject_transfer_request` | +| `transfer_executed` | `request_id` | `asset_id`, `new_owner`, `timestamp` | `execute_transfer` | +| `transfer_cancelled` | `request_id` | `cancelled_by`, `timestamp` | `cancel_transfer_request` | +| `approval_rule_updated` | `category` | `required_approvals`, `timestamp` | `configure_approval_rule` | +| `approver_added` | `approver` | `added_by`, `timestamp` | *defined, not yet emitted* | +| `approver_removed` | `approver` | `removed_by`, `timestamp` | *defined, not yet emitted* | + +`approver_added` and `approver_removed` are declared because the approver set is +part of the contract's model, but no entrypoint currently mutates it. They are +marked `#[allow(dead_code)]`. + +--- + +## `asset-maintenance` + +| Event | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `contract_initialized` | `admin` | `registry`, `timestamp` | `init` | +| `provider_registered` | `provider` | `timestamp` | `register_provider` | +| `provider_deactivated` | `provider` | `timestamp` | `deactivate_provider` | +| `maintenance_recorded` | `asset_id` | `record_id`, `provider`, `timestamp` | `add_maintenance_record` | +| `maintenance_scheduled` | `asset_id` | `next_service_due`, `timestamp` | `schedule_maintenance` | +| `maintenance_schedule_updated` | `asset_id` | `next_service_due`, `timestamp` | `update_maintenance_schedule` | +| `maintenance_completed` | `asset_id` | `record_id`, `provider`, `timestamp` | `complete_scheduled_maintenance` | +| `warranty_added` | `asset_id` | `end_date`, `timestamp` | `add_warranty_information` | +| `warranty_updated` | `asset_id` | `end_date`, `timestamp` | `update_warranty_information` | +| `warranty_claim_filed` | `asset_id` | `claim_amount`, `timestamp` | `file_warranty_claim` | +| `alert_created` | `asset_id` | `alert_type`, `severity`, `timestamp` | `create_maintenance_alert` | +| `alert_acknowledged` | `asset_id` | `alert_index`, `acknowledged_by`, `timestamp` | `acknowledge_maintenance_alert` | + +Previously `add_warranty_information` and `schedule_maintenance` were shared by +both the create and update entrypoints, so an update was indistinguishable from +a create off-chain. Persistence is now factored into private helpers so each +entrypoint emits its own event. + +--- + +## Coverage + +Every state-changing entrypoint in every compiled module emits an event. The +following were **silent before** this catalogue existed and now emit: + +| Contract | Entrypoint | +|---|---| +| `assetsup` | `initialize`, `add_authorized_registrar`, `remove_authorized_registrar` | +| `contrib` | `initialize`, `add_authorized_registrar`, `remove_authorized_registrar` | +| `multisig-wallet` | `initialize`, `propose_*`, `confirm_proposal`, `set_daily_limit` | +| `multisig-transfer` | `initialize` | +| `asset-maintenance` | `init`, `register_provider`, `deactivate_provider`, `update_maintenance_schedule`, `update_warranty_information`, `acknowledge_maintenance_alert` | diff --git a/contracts/README.md b/contracts/README.md index 5682d4315..2ec63eed9 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -11,7 +11,7 @@ of high-value transfers, escrow and KYC, and on-chain maintenance history. | `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. | +| `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`, @@ -121,7 +121,9 @@ 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]. +- Directory names match the package name for every crate, so + `cargo -p ` works throughout. The last exception, + `multisig_transfer/` holding the package `multisig-transfer`, was renamed in + [SC-33]. +- Workspace member paths are listed bare and alphabetically — no `./` prefixes, + no mixed separators. diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index 1b5f20756..c0b958306 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -65,7 +65,7 @@ until the ones marked **blocking** are fixed. | 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] | +| 11 | **Test coverage is uneven.** `multisig-transfer` has 10 tests for ~750 lines and no coverage of the request lifecycle itself; the auth boundaries are covered but the approval flow is not. | Partly addressed — [SC-32] | | 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 | diff --git a/contracts/asset-maintenance/src/events.rs b/contracts/asset-maintenance/src/events.rs new file mode 100644 index 000000000..c3b69a28d --- /dev/null +++ b/contracts/asset-maintenance/src/events.rs @@ -0,0 +1,246 @@ +//! Typed contract events for `asset-maintenance`. +//! +//! See `contracts/EVENTS.md` for the workspace-wide convention. Struct names +//! are ``; the SDK derives topic 0 from the name in +//! `lower_snake_case`. `asset_id` is always a `#[topic]` so consumers can index +//! per asset. + +use soroban_sdk::{contractevent, Address, Env}; + +use crate::{AlertSeverity, AlertType, MaintenanceAlert, MaintenanceRecord, ScheduledMaintenance}; + +/// The contract was initialized with an admin and an asset registry. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractInitialized { + #[topic] + pub admin: Address, + pub registry: Address, + pub timestamp: u64, +} + +/// A maintenance provider was registered. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderRegistered { + #[topic] + pub provider: Address, + pub timestamp: u64, +} + +/// A maintenance provider was deactivated and can no longer file records. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderDeactivated { + #[topic] + pub provider: Address, + pub timestamp: u64, +} + +/// A maintenance record was appended to an asset's history. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceRecorded { + #[topic] + pub asset_id: u64, + pub record_id: u64, + pub provider: Address, + pub timestamp: u64, +} + +/// Maintenance was scheduled for an asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceScheduled { + #[topic] + pub asset_id: u64, + pub next_service_due: u64, + pub timestamp: u64, +} + +/// An existing maintenance schedule was changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceScheduleUpdated { + #[topic] + pub asset_id: u64, + pub next_service_due: u64, + pub timestamp: u64, +} + +/// A scheduled maintenance was completed and recorded. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceCompleted { + #[topic] + pub asset_id: u64, + pub record_id: u64, + pub provider: Address, + pub timestamp: u64, +} + +/// Warranty information was attached to an asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WarrantyAdded { + #[topic] + pub asset_id: u64, + pub end_date: u64, + pub timestamp: u64, +} + +/// Warranty information for an asset was changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WarrantyUpdated { + #[topic] + pub asset_id: u64, + pub end_date: u64, + pub timestamp: u64, +} + +/// A claim was filed against an asset's warranty. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WarrantyClaimFiled { + #[topic] + pub asset_id: u64, + pub claim_amount: i128, + pub timestamp: u64, +} + +/// A maintenance alert was raised against an asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AlertCreated { + #[topic] + pub asset_id: u64, + pub alert_type: AlertType, + pub severity: AlertSeverity, + pub timestamp: u64, +} + +/// A maintenance alert was acknowledged. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AlertAcknowledged { + #[topic] + pub asset_id: u64, + pub alert_index: u32, + pub acknowledged_by: Address, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Emission helpers +// --------------------------------------------------------------------------- + +pub fn contract_initialized(env: &Env, admin: &Address, registry: &Address) { + ContractInitialized { + admin: admin.clone(), + registry: registry.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn provider_registered(env: &Env, provider: &Address) { + ProviderRegistered { + provider: provider.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn provider_deactivated(env: &Env, provider: &Address) { + ProviderDeactivated { + provider: provider.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn maintenance_recorded(env: &Env, record: &MaintenanceRecord) { + MaintenanceRecorded { + asset_id: record.asset_id, + record_id: record.record_id, + provider: record.provider.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn maintenance_scheduled(env: &Env, schedule: &ScheduledMaintenance) { + MaintenanceScheduled { + asset_id: schedule.asset_id, + next_service_due: schedule.next_service_due, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn maintenance_schedule_updated(env: &Env, schedule: &ScheduledMaintenance) { + MaintenanceScheduleUpdated { + asset_id: schedule.asset_id, + next_service_due: schedule.next_service_due, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn maintenance_completed(env: &Env, asset_id: u64, record: &MaintenanceRecord) { + MaintenanceCompleted { + asset_id, + record_id: record.record_id, + provider: record.provider.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn warranty_added(env: &Env, asset_id: u64, end_date: u64) { + WarrantyAdded { + asset_id, + end_date, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn warranty_updated(env: &Env, asset_id: u64, end_date: u64) { + WarrantyUpdated { + asset_id, + end_date, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn warranty_claim_filed(env: &Env, asset_id: u64, claim_amount: i128) { + WarrantyClaimFiled { + asset_id, + claim_amount, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn alert_created(env: &Env, alert: &MaintenanceAlert) { + AlertCreated { + asset_id: alert.asset_id, + alert_type: alert.alert_type.clone(), + severity: alert.severity.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn alert_acknowledged(env: &Env, asset_id: u64, alert_index: u32, by: &Address) { + AlertAcknowledged { + asset_id, + alert_index, + acknowledged_by: by.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/asset-maintenance/src/lib.rs b/contracts/asset-maintenance/src/lib.rs index 6987d6f36..bf559c644 100644 --- a/contracts/asset-maintenance/src/lib.rs +++ b/contracts/asset-maintenance/src/lib.rs @@ -24,8 +24,9 @@ //! 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}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +pub mod events; mod test; mod tests_coverage; @@ -216,6 +217,8 @@ impl AssetMaintenanceContract { env.storage() .persistent() .set(&DataKey::AssetRegistry, ®istry); + + events::contract_initialized(&env, &admin, ®istry); } pub fn register_provider(env: Env, provider: ProviderProfile) { @@ -225,6 +228,8 @@ impl AssetMaintenanceContract { env.storage() .persistent() .set(&DataKey::Provider(provider.address.clone()), &provider); + + events::provider_registered(&env, &provider.address); } pub fn deactivate_provider(env: Env, provider_address: Address) { @@ -239,7 +244,9 @@ impl AssetMaintenanceContract { provider.is_active = false; env.storage() .persistent() - .set(&DataKey::Provider(provider_address), &provider); + .set(&DataKey::Provider(provider_address.clone()), &provider); + + events::provider_deactivated(&env, &provider_address); } } @@ -317,10 +324,7 @@ impl AssetMaintenanceContract { .set(&DataKey::AssetStats(record.asset_id), &stats); // 6. Emit Event - env.events().publish( - (symbol_short!("MaintRec"), record.asset_id), - (record.record_id, record.provider, env.ledger().timestamp()), - ); + events::maintenance_recorded(&env, &record); } pub fn get_maintenance_history(env: Env, asset_id: u64) -> Vec { @@ -332,23 +336,13 @@ impl AssetMaintenanceContract { pub fn schedule_maintenance(env: Env, owner: Address, schedule: ScheduledMaintenance) { owner.require_auth(); - // Asset owner check (placeholder - assuming caller is owner or admin) - // In a real system, we'd check if owner is truly the owner via Registry - if schedule.frequency_days == 0 { - panic!("frequency must be positive"); - } - - env.storage() - .persistent() - .set(&DataKey::MaintenanceSchedule(schedule.asset_id), &schedule); - - env.events().publish( - (symbol_short!("MaintSch"), schedule.asset_id), - (schedule.next_service_due, env.ledger().timestamp()), - ); + Self::store_schedule(&env, &schedule); + events::maintenance_scheduled(&env, &schedule); } pub fn update_maintenance_schedule(env: Env, owner: Address, schedule: ScheduledMaintenance) { + owner.require_auth(); + // Update existing schedule if !env .storage() @@ -357,7 +351,23 @@ impl AssetMaintenanceContract { { panic!("no schedule exists for asset"); } - Self::schedule_maintenance(env, owner, schedule); + + Self::store_schedule(&env, &schedule); + events::maintenance_schedule_updated(&env, &schedule); + } + + /// Validates and persists a schedule without emitting an event, so that + /// the create and update entrypoints each emit their own. + fn store_schedule(env: &Env, schedule: &ScheduledMaintenance) { + // Asset owner check (placeholder - assuming caller is owner or admin) + // In a real system, we'd check if owner is truly the owner via Registry + if schedule.frequency_days == 0 { + panic!("frequency must be positive"); + } + + env.storage() + .persistent() + .set(&DataKey::MaintenanceSchedule(schedule.asset_id), schedule); } pub fn get_upcoming_maintenance(env: Env, asset_id: u64) -> Option { @@ -386,26 +396,14 @@ impl AssetMaintenanceContract { } } - env.events().publish( - (symbol_short!("MaintCmp"), asset_id), - (record.record_id, record.provider, env.ledger().timestamp()), - ); + events::maintenance_completed(&env, asset_id, &record); } pub fn add_warranty_information(env: Env, warranty: WarrantyInfo) { Self::require_admin(&env); - if warranty.end_date <= warranty.start_date { - panic!("warranty dates invalid"); - } - env.storage() - .persistent() - .set(&DataKey::Warranty(warranty.asset_id), &warranty); - - env.events().publish( - (symbol_short!("WarrAdd"), warranty.asset_id), - (warranty.end_date, env.ledger().timestamp()), - ); + Self::store_warranty(&env, &warranty); + events::warranty_added(&env, warranty.asset_id, warranty.end_date); } pub fn update_warranty_information(env: Env, warranty: WarrantyInfo) { @@ -418,7 +416,20 @@ impl AssetMaintenanceContract { { panic!("no warranty exists for asset"); } - Self::add_warranty_information(env, warranty); + + Self::store_warranty(&env, &warranty); + events::warranty_updated(&env, warranty.asset_id, warranty.end_date); + } + + /// Validates and persists warranty terms without emitting an event, so + /// that the add and update entrypoints each emit their own. + fn store_warranty(env: &Env, warranty: &WarrantyInfo) { + if warranty.end_date <= warranty.start_date { + panic!("warranty dates invalid"); + } + env.storage() + .persistent() + .set(&DataKey::Warranty(warranty.asset_id), warranty); } pub fn get_provider_details(env: Env, provider_address: Address) -> Option { @@ -455,10 +466,7 @@ impl AssetMaintenanceContract { .persistent() .set(&DataKey::Warranty(asset_id), &warranty); - env.events().publish( - (symbol_short!("WarrClm"), asset_id), - (claim_amount, env.ledger().timestamp()), - ); + events::warranty_claim_filed(&env, asset_id, claim_amount); } pub fn create_maintenance_alert(env: Env, alert: MaintenanceAlert) { @@ -474,10 +482,7 @@ impl AssetMaintenanceContract { .persistent() .set(&DataKey::Alerts(alert.asset_id), &alerts); - env.events().publish( - (symbol_short!("AlertCr"), alert.asset_id), - (alert.alert_type, alert.severity, env.ledger().timestamp()), - ); + events::alert_created(&env, &alert); } pub fn acknowledge_maintenance_alert(env: Env, asset_id: u64, alert_index: u32, by: Address) { @@ -490,11 +495,13 @@ impl AssetMaintenanceContract { if let Some(mut alert) = alerts.get(alert_index) { alert.acknowledged = true; - alert.acknowledged_by = by; + alert.acknowledged_by = by.clone(); alerts.set(alert_index, alert); env.storage() .persistent() .set(&DataKey::Alerts(asset_id), &alerts); + + events::alert_acknowledged(&env, asset_id, alert_index, &by); } } diff --git a/contracts/assetsup/src/detokenization.rs b/contracts/assetsup/src/detokenization.rs index d297ce5f5..cb2e0de82 100644 --- a/contracts/assetsup/src/detokenization.rs +++ b/contracts/assetsup/src/detokenization.rs @@ -134,10 +134,7 @@ pub fn execute_detokenization(env: &Env, asset_id: u64, proposal_id: u64) -> Res store.set(&proposal_key, &executed_proposal); // Emit event: (asset_id, proposal_id, total_supply_removed) - env.events().publish( - ("detokenization", "asset_detokenized"), - (asset_id, proposal_id, total_supply), - ); + crate::events::asset_detokenized(env, asset_id, proposal_id, total_supply); Ok(()) } diff --git a/contracts/assetsup/src/dividends.rs b/contracts/assetsup/src/dividends.rs index e24a31e1c..2b4f57ee1 100644 --- a/contracts/assetsup/src/dividends.rs +++ b/contracts/assetsup/src/dividends.rs @@ -46,10 +46,7 @@ pub fn distribute_dividends(env: &Env, asset_id: u64, total_amount: i128) -> Res } // Emit event: (asset_id, total_amount, holder_count) - env.events().publish( - ("dividend", "distributed"), - (asset_id, total_amount, holders.len()), - ); + crate::events::dividend_distributed(env, asset_id, total_amount, holders.len()); Ok(()) } @@ -78,8 +75,7 @@ pub fn claim_dividends(env: &Env, asset_id: u64, holder: Address) -> Result`; the SDK derives topic 0 from the name in +//! `lower_snake_case`. The primary entity identifier is marked `#[topic]` so +//! downstream systems can index on it. +//! +//! Note that the registry keys assets by `BytesN<32>` while tokenization, +//! dividends, and voting key them by `u64`; the event types reflect whichever +//! id space the emitting entrypoint uses. + +use soroban_sdk::{contractevent, Address, BytesN, Env, String}; + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/// The contract was initialized with an admin. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractInitialized { + #[topic] + pub admin: Address, + pub timestamp: u64, +} + +/// An asset was registered by an authorized registrar. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetRegistered { + #[topic] + pub asset_id: BytesN<32>, + pub owner: Address, + pub timestamp: u64, +} + +/// An asset's metadata was updated. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetUpdated { + #[topic] + pub asset_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + +/// Ownership of an asset moved to a new owner. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetTransferred { + #[topic] + pub asset_id: BytesN<32>, + pub old_owner: Address, + pub new_owner: Address, + pub timestamp: u64, +} + +/// An asset was retired and can no longer be transferred or updated. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetRetired { + #[topic] + pub asset_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + +/// An admin transfer was nominated. The role does **not** move until the +/// proposed address accepts. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminProposed { + #[topic] + pub proposed_admin: Address, + pub current_admin: Address, + pub timestamp: u64, +} + +/// A pending admin nomination was withdrawn by the current admin. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminProposalCancelled { + #[topic] + pub proposed_admin: Address, + pub current_admin: Address, + pub timestamp: u64, +} + +/// The contract admin was changed, after the incoming address accepted. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminChanged { + #[topic] + pub new_admin: Address, + pub old_admin: Address, + pub timestamp: u64, +} + +/// An address was granted registrar rights. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegistrarAdded { + #[topic] + pub registrar: Address, + pub timestamp: u64, +} + +/// An address had its registrar rights revoked. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegistrarRemoved { + #[topic] + pub registrar: Address, + pub timestamp: u64, +} + +/// The contract was paused; mutating entrypoints now reject. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractPaused { + #[topic] + pub admin: Address, + pub timestamp: u64, +} + +/// The contract was unpaused and resumed normal operation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractUnpaused { + #[topic] + pub admin: Address, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Tokenization +// --------------------------------------------------------------------------- + +/// An asset was tokenized into fractional shares. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetTokenized { + #[topic] + pub asset_id: u64, + pub total_supply: i128, + pub symbol: String, + pub decimals: u32, + pub tokenizer: Address, +} + +/// New tokens were minted for a tokenized asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokensMinted { + #[topic] + pub asset_id: u64, + pub amount: i128, + pub total_supply: i128, +} + +/// Tokens were burned, reducing the supply of a tokenized asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokensBurned { + #[topic] + pub asset_id: u64, + pub amount: i128, + pub total_supply: i128, +} + +/// Tokens moved between two holders. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokensTransferred { + #[topic] + pub asset_id: u64, + pub from: Address, + pub to: Address, + pub amount: i128, +} + +/// A holder's tokens were locked until a given timestamp. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokensLocked { + #[topic] + pub asset_id: u64, + pub holder: Address, + pub until_timestamp: u64, +} + +/// A holder's tokens were unlocked. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokensUnlocked { + #[topic] + pub asset_id: u64, + pub holder: Address, + pub timestamp: u64, +} + +/// A tokenized asset's valuation was updated. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValuationUpdated { + #[topic] + pub asset_id: u64, + pub new_valuation: i128, +} + +/// A tokenized asset was fully detokenized. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetDetokenized { + #[topic] + pub asset_id: u64, + pub proposal_id: u64, + pub total_supply: i128, +} + +// --------------------------------------------------------------------------- +// Dividends and voting +// --------------------------------------------------------------------------- + +/// A dividend was distributed across an asset's token holders. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DividendDistributed { + #[topic] + pub asset_id: u64, + pub total_amount: i128, + pub holder_count: u32, +} + +/// A holder claimed their accrued dividends. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DividendClaimed { + #[topic] + pub asset_id: u64, + pub holder: Address, + pub amount: i128, +} + +/// A token holder cast a weighted vote on a proposal. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoteCast { + #[topic] + pub asset_id: u64, + pub proposal_id: u64, + pub voter: Address, + pub voting_power: i128, +} + +// --------------------------------------------------------------------------- +// Transfer restrictions +// --------------------------------------------------------------------------- + +/// A transfer restriction was set on an asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestrictionSet { + #[topic] + pub asset_id: u64, + pub require_accredited: bool, +} + +/// An address was added to an asset's transfer whitelist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WhitelistAdded { + #[topic] + pub asset_id: u64, + pub address: Address, +} + +/// An address was removed from an asset's transfer whitelist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WhitelistRemoved { + #[topic] + pub asset_id: u64, + pub address: Address, +} + +// --------------------------------------------------------------------------- +// Leasing +// --------------------------------------------------------------------------- + +/// A lease was created over an asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseCreated { + #[topic] + pub lease_id: BytesN<32>, + pub asset_id: BytesN<32>, + pub lessor: Address, + pub lessee: Address, + pub timestamp: u64, +} + +/// A leased asset was returned. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseReturned { + #[topic] + pub lease_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + +/// A lease was cancelled before its end date. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseCancelled { + #[topic] + pub lease_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + +/// A lease reached its end date and expired. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseExpired { + #[topic] + pub lease_id: BytesN<32>, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Emission helpers +// --------------------------------------------------------------------------- + +pub fn contract_initialized(env: &Env, admin: &Address) { + ContractInitialized { + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_registered(env: &Env, asset_id: &BytesN<32>, owner: &Address) { + AssetRegistered { + asset_id: asset_id.clone(), + owner: owner.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_updated(env: &Env, asset_id: &BytesN<32>, caller: &Address) { + AssetUpdated { + asset_id: asset_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_transferred( + env: &Env, + asset_id: &BytesN<32>, + old_owner: &Address, + new_owner: &Address, +) { + AssetTransferred { + asset_id: asset_id.clone(), + old_owner: old_owner.clone(), + new_owner: new_owner.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_retired(env: &Env, asset_id: &BytesN<32>, caller: &Address) { + AssetRetired { + asset_id: asset_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn admin_proposed(env: &Env, current_admin: &Address, proposed_admin: &Address) { + AdminProposed { + proposed_admin: proposed_admin.clone(), + current_admin: current_admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn admin_proposal_cancelled(env: &Env, current_admin: &Address, proposed_admin: &Address) { + AdminProposalCancelled { + proposed_admin: proposed_admin.clone(), + current_admin: current_admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn admin_changed(env: &Env, old_admin: &Address, new_admin: &Address) { + AdminChanged { + new_admin: new_admin.clone(), + old_admin: old_admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn registrar_added(env: &Env, registrar: &Address) { + RegistrarAdded { + registrar: registrar.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn registrar_removed(env: &Env, registrar: &Address) { + RegistrarRemoved { + registrar: registrar.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn contract_paused(env: &Env, admin: &Address) { + ContractPaused { + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn contract_unpaused(env: &Env, admin: &Address) { + ContractUnpaused { + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_tokenized( + env: &Env, + asset_id: u64, + total_supply: i128, + symbol: String, + decimals: u32, + tokenizer: Address, +) { + AssetTokenized { + asset_id, + total_supply, + symbol, + decimals, + tokenizer, + } + .publish(env); +} + +pub fn tokens_minted(env: &Env, asset_id: u64, amount: i128, total_supply: i128) { + TokensMinted { + asset_id, + amount, + total_supply, + } + .publish(env); +} + +pub fn tokens_burned(env: &Env, asset_id: u64, amount: i128, total_supply: i128) { + TokensBurned { + asset_id, + amount, + total_supply, + } + .publish(env); +} + +pub fn tokens_transferred(env: &Env, asset_id: u64, from: &Address, to: &Address, amount: i128) { + TokensTransferred { + asset_id, + from: from.clone(), + to: to.clone(), + amount, + } + .publish(env); +} + +pub fn tokens_locked(env: &Env, asset_id: u64, holder: &Address, until_timestamp: u64) { + TokensLocked { + asset_id, + holder: holder.clone(), + until_timestamp, + } + .publish(env); +} + +pub fn tokens_unlocked(env: &Env, asset_id: u64, holder: &Address) { + TokensUnlocked { + asset_id, + holder: holder.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn valuation_updated(env: &Env, asset_id: u64, new_valuation: i128) { + ValuationUpdated { + asset_id, + new_valuation, + } + .publish(env); +} + +pub fn asset_detokenized(env: &Env, asset_id: u64, proposal_id: u64, total_supply: i128) { + AssetDetokenized { + asset_id, + proposal_id, + total_supply, + } + .publish(env); +} + +pub fn dividend_distributed(env: &Env, asset_id: u64, total_amount: i128, holder_count: u32) { + DividendDistributed { + asset_id, + total_amount, + holder_count, + } + .publish(env); +} + +pub fn dividend_claimed(env: &Env, asset_id: u64, holder: &Address, amount: i128) { + DividendClaimed { + asset_id, + holder: holder.clone(), + amount, + } + .publish(env); +} + +pub fn vote_cast(env: &Env, asset_id: u64, proposal_id: u64, voter: &Address, voting_power: i128) { + VoteCast { + asset_id, + proposal_id, + voter: voter.clone(), + voting_power, + } + .publish(env); +} + +pub fn restriction_set(env: &Env, asset_id: u64, require_accredited: bool) { + RestrictionSet { + asset_id, + require_accredited, + } + .publish(env); +} + +pub fn whitelist_added(env: &Env, asset_id: u64, address: &Address) { + WhitelistAdded { + asset_id, + address: address.clone(), + } + .publish(env); +} + +pub fn whitelist_removed(env: &Env, asset_id: u64, address: &Address) { + WhitelistRemoved { + asset_id, + address: address.clone(), + } + .publish(env); +} + +pub fn lease_created( + env: &Env, + lease_id: &BytesN<32>, + asset_id: &BytesN<32>, + lessor: &Address, + lessee: &Address, +) { + LeaseCreated { + lease_id: lease_id.clone(), + asset_id: asset_id.clone(), + lessor: lessor.clone(), + lessee: lessee.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn lease_returned(env: &Env, lease_id: &BytesN<32>, caller: &Address) { + LeaseReturned { + lease_id: lease_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn lease_cancelled(env: &Env, lease_id: &BytesN<32>, caller: &Address) { + LeaseCancelled { + lease_id: lease_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn lease_expired(env: &Env, lease_id: &BytesN<32>) { + LeaseExpired { + lease_id: lease_id.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/assetsup/src/lease.rs b/contracts/assetsup/src/lease.rs index 559284f19..64f80fd98 100644 --- a/contracts/assetsup/src/lease.rs +++ b/contracts/assetsup/src/lease.rs @@ -129,10 +129,7 @@ pub fn create_lease( set_asset_active_lease(env, &asset_id, &lease_id); append_lessee_lease(env, &lessee, &lease_id); - env.events().publish( - (soroban_sdk::symbol_short!("lease_new"),), - (lease_id, asset_id, lessor, lessee, env.ledger().timestamp()), - ); + crate::events::lease_created(env, &lease_id, &asset_id, &lessor, &lessee); Ok(()) } @@ -152,10 +149,7 @@ pub fn return_leased_asset(env: &Env, lease_id: BytesN<32>, caller: Address) -> save_lease(env, &lease); clear_asset_active_lease(env, &lease.asset_id); - env.events().publish( - (soroban_sdk::symbol_short!("lease_ret"),), - (lease_id, caller, env.ledger().timestamp()), - ); + crate::events::lease_returned(env, &lease_id, &caller); Ok(()) } @@ -179,10 +173,7 @@ pub fn cancel_lease(env: &Env, lease_id: BytesN<32>, caller: Address) -> Result< save_lease(env, &lease); clear_asset_active_lease(env, &lease.asset_id); - env.events().publish( - (soroban_sdk::symbol_short!("lease_can"),), - (lease_id, caller, env.ledger().timestamp()), - ); + crate::events::lease_cancelled(env, &lease_id, &caller); Ok(()) } @@ -202,10 +193,7 @@ pub fn expire_lease(env: &Env, lease_id: BytesN<32>) -> Result<(), Error> { save_lease(env, &lease); clear_asset_active_lease(env, &lease.asset_id); - env.events().publish( - (soroban_sdk::symbol_short!("lease_exp"),), - (lease_id, env.ledger().timestamp()), - ); + crate::events::lease_expired(env, &lease_id); Ok(()) } diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index c59504164..3a8d8e38e 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -31,9 +31,7 @@ //! for the full entrypoint, storage, event, and error tables. use crate::error::{handle_error, Error}; -use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec, -}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, String, Vec}; pub(crate) mod asset; pub(crate) mod audit; @@ -41,6 +39,7 @@ pub(crate) mod branch; pub(crate) mod detokenization; pub(crate) mod dividends; pub(crate) mod error; +pub mod events; pub(crate) mod insurance; pub(crate) mod lease; pub(crate) mod math; @@ -116,7 +115,9 @@ impl AssetUpContract { ttl::extend_persistent(&env, &DataKey::Paused); ttl::extend_persistent(&env, &DataKey::TotalAssetCount); ttl::extend_persistent(&env, &DataKey::ContractMetadata); - ttl::extend_persistent(&env, &DataKey::AuthorizedRegistrar(admin)); + ttl::extend_persistent(&env, &DataKey::AuthorizedRegistrar(admin.clone())); + + events::contract_initialized(&env, &admin); Ok(()) } @@ -230,10 +231,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_reg"),), - (asset.owner, asset.id, env.ledger().timestamp()), - ); + events::asset_registered(&env, &asset.id, &asset.owner); Ok(()) } @@ -348,10 +346,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_upd"),), - (asset_id, caller, env.ledger().timestamp()), - ); + events::asset_updated(&env, &asset_id, &caller); Ok(()) } @@ -431,10 +426,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_tx"),), - (asset_id, old_owner, new_owner, env.ledger().timestamp()), - ); + events::asset_transferred(&env, &asset_id, &old_owner, &new_owner); Ok(()) } @@ -478,10 +470,7 @@ impl AssetUpContract { ); // Emit event - env.events().publish( - (symbol_short!("asset_ret"),), - (asset_id, caller, env.ledger().timestamp()), - ); + events::asset_retired(&env, &asset_id, &caller); Ok(()) } @@ -578,10 +567,7 @@ impl AssetUpContract { .persistent() .set(&DataKey::PendingAdmin, &new_admin); - env.events().publish( - (symbol_short!("adm_prop"),), - (current_admin, new_admin, env.ledger().timestamp()), - ); + events::admin_proposed(&env, ¤t_admin, &new_admin); Ok(()) } @@ -612,10 +598,7 @@ impl AssetUpContract { .persistent() .set(&DataKey::AuthorizedRegistrar(pending.clone()), &true); - env.events().publish( - (symbol_short!("admin_chg"),), - (old_admin, pending, env.ledger().timestamp()), - ); + events::admin_changed(&env, &old_admin, &pending); Ok(()) } @@ -633,10 +616,7 @@ impl AssetUpContract { env.storage().persistent().remove(&DataKey::PendingAdmin); - env.events().publish( - (symbol_short!("adm_cncl"),), - (current_admin, pending, env.ledger().timestamp()), - ); + events::admin_proposal_cancelled(&env, ¤t_admin, &pending); Ok(()) } @@ -654,7 +634,9 @@ impl AssetUpContract { env.storage() .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &true); + .set(&DataKey::AuthorizedRegistrar(registrar.clone()), &true); + + events::registrar_added(&env, ®istrar); Ok(()) } @@ -671,7 +653,9 @@ impl AssetUpContract { env.storage() .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &false); + .set(&DataKey::AuthorizedRegistrar(registrar.clone()), &false); + + events::registrar_removed(&env, ®istrar); Ok(()) } @@ -682,10 +666,7 @@ impl AssetUpContract { env.storage().persistent().set(&DataKey::Paused, &true); // Emit event - env.events().publish( - (symbol_short!("c_pause"),), - (admin, env.ledger().timestamp()), - ); + events::contract_paused(&env, &admin); Ok(()) } @@ -697,10 +678,7 @@ impl AssetUpContract { env.storage().persistent().set(&DataKey::Paused, &false); // Emit event - env.events().publish( - (symbol_short!("c_unpause"),), - (admin, env.ledger().timestamp()), - ); + events::contract_unpaused(&env, &admin); Ok(()) } diff --git a/contracts/assetsup/src/tests/auth.rs b/contracts/assetsup/src/tests/auth.rs index 032a63fe3..e3bc9d555 100644 --- a/contracts/assetsup/src/tests/auth.rs +++ b/contracts/assetsup/src/tests/auth.rs @@ -196,15 +196,47 @@ fn retire_asset_rejects_an_unauthenticated_caller() { // --------------------------------------------------------------------------- #[test] -fn update_admin_requires_the_current_admins_authorization() { +fn admin_transfer_requires_the_current_admins_authorization() { + // Admin transfer is two-step ([SC-48]): the current admin nominates, and + // the incoming address accepts. Both halves must be authenticated, or the + // two-step flow would be no safer than the single-step one it replaced. let env = create_env(); - let (client, _admin) = setup_unmocked(&env); + let (client, admin) = setup_unmocked(&env); let usurper = Address::generate(&env); - let res = client.try_update_admin(&usurper); + let res = client.try_propose_admin(&usurper); assert!( res.is_err(), - "admin transfer must not succeed without the current admin's auth" + "nominating a new admin must require the current admin's auth" + ); + assert_eq!( + client.get_pending_admin(), + None, + "the rejected proposal must not have been recorded" + ); + assert_eq!(client.get_admin(), admin, "the admin must be unchanged"); +} + +#[test] +fn accepting_an_admin_transfer_requires_the_incoming_addresss_authorization() { + let env = create_env(); + let (client, admin) = setup_unmocked(&env); + let successor = Address::generate(&env); + + env.mock_all_auths(); + client.propose_admin(&successor); + + env.set_auths(&[]); + let res = client.try_accept_admin(); + + assert!( + res.is_err(), + "acceptance must be signed by the incoming address" + ); + assert_eq!( + client.get_admin(), + admin, + "an unaccepted proposal must leave the original admin in place" ); } diff --git a/contracts/assetsup/src/tests/events.rs b/contracts/assetsup/src/tests/events.rs new file mode 100644 index 000000000..93459b427 --- /dev/null +++ b/contracts/assetsup/src/tests/events.rs @@ -0,0 +1,189 @@ +//! Event emission tests. +//! +//! `env.events().all()` reports the events of the **most recent contract +//! invocation**, not every event since the test began, so each assertion here +//! looks at exactly what the entrypoint under test published. +//! +//! See `contracts/EVENTS.md` for the catalogue these assertions pin down. + +use soroban_sdk::testutils::{Address as _, Events as _}; +use soroban_sdk::{Address, BytesN, Env, Symbol, TryIntoVal, Val, Vec}; + +use super::helpers::{create_env, create_test_asset, initialize_contract}; +use crate::{AssetUpContract, AssetUpContractClient}; + +fn asset_id(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +/// Returns the topics and data of the single event published by the most +/// recent invocation, failing if the entrypoint did not emit exactly one. +fn only_event(env: &Env) -> (Vec, Val) { + let events = env.events().all(); + assert_eq!( + events.len(), + 1, + "expected exactly one event from the last invocation" + ); + let (_contract, topics, data) = events.last().unwrap(); + (topics, data) +} + +fn assert_event_name(env: &Env, topics: &Vec, expected: &str) { + let name: Symbol = topics + .get(0) + .expect("event must have a name topic") + .try_into_val(env) + .expect("topic 0 must decode as a Symbol"); + assert_eq!( + name, + Symbol::new(env, expected), + "topic 0 should name the event" + ); +} + +#[test] +fn initialize_emits_contract_initialized() { + let env = create_env(); + let admin = Address::generate(&env); + let contract_id = env.register(AssetUpContract, ()); + let client = AssetUpContractClient::new(&env, &contract_id); + env.mock_all_auths(); + + client.initialize(&admin); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "contract_initialized"); + let emitted: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, admin); +} + +#[test] +fn register_asset_emits_asset_registered_keyed_by_asset_id() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let owner = Address::generate(&env); + let id = asset_id(&env, 1); + + client.register_asset(&create_test_asset(&env, &owner, id.clone()), &admin); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "asset_registered"); + let emitted: BytesN<32> = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, id, "asset id must be indexable as topic 1"); +} + +#[test] +fn transfer_asset_ownership_emits_asset_transferred() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let id = asset_id(&env, 2); + client.register_asset(&create_test_asset(&env, &owner, id.clone()), &admin); + + client.transfer_asset_ownership(&id, &new_owner, &owner); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "asset_transferred"); + let emitted: BytesN<32> = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, id); +} + +#[test] +fn retire_asset_emits_asset_retired() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let owner = Address::generate(&env); + let id = asset_id(&env, 3); + client.register_asset(&create_test_asset(&env, &owner, id.clone()), &admin); + + client.retire_asset(&id, &owner); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "asset_retired"); + let emitted: BytesN<32> = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, id); +} + +#[test] +fn the_two_step_admin_transfer_emits_a_distinct_event_at_each_step() { + // Admin transfer is two-step ([SC-48]). Each step is separately + // observable, so a consumer can tell a nomination from a completed + // handover — which the old single-step admin_changed could not express. + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let new_admin = Address::generate(&env); + + client.propose_admin(&new_admin); + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "admin_proposed"); + let emitted: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!( + emitted, new_admin, + "the nominee is the indexable topic while the transfer is pending" + ); + + client.accept_admin(); + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "admin_changed"); + let emitted: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, new_admin); +} + +#[test] +fn cancelling_an_admin_proposal_is_observable() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let candidate = Address::generate(&env); + + client.propose_admin(&candidate); + client.cancel_admin_proposal(); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "admin_proposal_cancelled"); + let emitted: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, candidate); +} + +#[test] +fn registrar_changes_are_observable() { + // These two entrypoints were silent before the event catalogue: an + // off-chain consumer could not tell that the registrar allowlist changed. + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let registrar = Address::generate(&env); + + client.add_authorized_registrar(®istrar); + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "registrar_added"); + let emitted: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, registrar); + + client.remove_authorized_registrar(®istrar); + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "registrar_removed"); + let emitted: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, registrar); +} + +#[test] +fn pause_and_unpause_emit_distinct_events() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + + client.pause_contract(); + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "contract_paused"); + + client.unpause_contract(); + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "contract_unpaused"); +} diff --git a/contracts/assetsup/src/tests/mod.rs b/contracts/assetsup/src/tests/mod.rs index 0947dd652..355cb54df 100644 --- a/contracts/assetsup/src/tests/mod.rs +++ b/contracts/assetsup/src/tests/mod.rs @@ -7,6 +7,7 @@ mod admin_transfer; mod asset; mod audit_trail; mod auth; +mod events; mod initialization; mod pause; diff --git a/contracts/assetsup/src/tokenization.rs b/contracts/assetsup/src/tokenization.rs index beb71973d..abbb8c417 100644 --- a/contracts/assetsup/src/tokenization.rs +++ b/contracts/assetsup/src/tokenization.rs @@ -95,10 +95,7 @@ pub fn tokenize_asset( ); // Emit event: (asset_id, supply, symbol, decimals, tokenizer) - env.events().publish( - ("token", "asset_tokenized"), - (asset_id, total_supply, symbol, decimals, tokenizer), - ); + crate::events::asset_tokenized(env, asset_id, total_supply, symbol, decimals, tokenizer); Ok(tokenized_asset) } @@ -157,10 +154,7 @@ pub fn mint_tokens( ); // Emit event: (asset_id, amount, new_supply) - env.events().publish( - ("token", "tokens_minted"), - (asset_id, amount, tokenized_asset.total_supply), - ); + crate::events::tokens_minted(env, asset_id, amount, tokenized_asset.total_supply); Ok(tokenized_asset) } @@ -223,10 +217,7 @@ pub fn burn_tokens( ); // Emit event: (asset_id, amount, new_supply) - env.events().publish( - ("token", "tokens_burned"), - (asset_id, amount, tokenized_asset.total_supply), - ); + crate::events::tokens_burned(env, asset_id, amount, tokenized_asset.total_supply); Ok(tokenized_asset) } @@ -325,10 +316,7 @@ pub fn transfer_tokens( ); // Emit event: (asset_id, from, to, amount) - env.events().publish( - ("token", "tokens_transferred"), - (asset_id, from.clone(), to.clone(), amount), - ); + crate::events::tokens_transferred(env, asset_id, &from, &to, amount); Ok(()) } @@ -377,10 +365,7 @@ pub fn lock_tokens( store.set(&lock_key, &until_timestamp); // Emit event: (asset_id, holder, until_timestamp) - env.events().publish( - ("token", "tokens_locked"), - (asset_id, holder, until_timestamp), - ); + crate::events::tokens_locked(env, asset_id, &holder, until_timestamp); Ok(()) } @@ -401,8 +386,7 @@ pub fn unlock_tokens(env: &Env, asset_id: u64, holder: Address) -> Result<(), Er } // Emit event: (asset_id, holder) - env.events() - .publish(("token", "tokens_unlocked"), (asset_id, holder)); + crate::events::tokens_unlocked(env, asset_id, &holder); Ok(()) } @@ -473,8 +457,7 @@ pub fn update_valuation(env: &Env, asset_id: u64, new_valuation: i128) -> Result store.set(&key, &tokenized_asset); // Emit event: (asset_id, new_valuation) - env.events() - .publish(("token", "valuation_updated"), (asset_id, new_valuation)); + crate::events::valuation_updated(env, asset_id, new_valuation); Ok(()) } diff --git a/contracts/assetsup/src/transfer_restrictions.rs b/contracts/assetsup/src/transfer_restrictions.rs index 16a89d5ee..5dcda1fa4 100644 --- a/contracts/assetsup/src/transfer_restrictions.rs +++ b/contracts/assetsup/src/transfer_restrictions.rs @@ -15,10 +15,7 @@ pub fn set_transfer_restriction( store.set(&key, &restriction); // Emit event: (asset_id, require_accredited) - env.events().publish( - ("transfer", "restriction_set"), - (asset_id, restriction.require_accredited), - ); + crate::events::restriction_set(env, asset_id, restriction.require_accredited); Ok(()) } @@ -39,8 +36,7 @@ pub fn add_to_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<() store.set(&key, &whitelist); // Emit event: (asset_id, address) - env.events() - .publish(("transfer", "whitelist_added"), (asset_id, address)); + crate::events::whitelist_added(env, asset_id, &address); Ok(()) } @@ -58,8 +54,7 @@ pub fn remove_from_whitelist(env: &Env, asset_id: u64, address: Address) -> Resu store.set(&key, &whitelist); // Emit event: (asset_id, address) - env.events() - .publish(("transfer", "whitelist_removed"), (asset_id, address)); + crate::events::whitelist_removed(env, asset_id, &address); } Ok(()) diff --git a/contracts/assetsup/src/voting.rs b/contracts/assetsup/src/voting.rs index c76d622be..aa89ca101 100644 --- a/contracts/assetsup/src/voting.rs +++ b/contracts/assetsup/src/voting.rs @@ -36,10 +36,7 @@ pub fn cast_vote(env: &Env, asset_id: u64, proposal_id: u64, voter: Address) -> store.set(&tally_key, &new_tally); // Emit event: (asset_id, proposal_id, voter, weight) - env.events().publish( - ("voting", "vote_cast"), - (asset_id, proposal_id, voter, ownership.balance), - ); + crate::events::vote_cast(env, asset_id, proposal_id, &voter, ownership.balance); Ok(()) } diff --git a/contracts/contrib/src/events.rs b/contracts/contrib/src/events.rs new file mode 100644 index 000000000..ab0447c8e --- /dev/null +++ b/contracts/contrib/src/events.rs @@ -0,0 +1,331 @@ +//! Typed contract events for `contrib`. +//! +//! See `contracts/EVENTS.md` for the workspace-wide convention. Struct names +//! are ``; the SDK derives topic 0 from the name in +//! `lower_snake_case`. The primary entity identifier is marked `#[topic]`. +//! +//! Only the modules `contrib/src/lib.rs` actually declares are covered here — +//! `audit`, `pause`, `types`, `insurance`, and `lease`. The other files in +//! `contrib/src/` are not compiled into the crate; see the crate README. + +use soroban_sdk::{contractevent, Address, BytesN, Env}; + +use crate::insurance::ClaimStatus; + +/// The contract was initialized with an admin. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractInitialized { + #[topic] + pub admin: Address, + pub timestamp: u64, +} + +/// An asset was registered by an authorized registrar. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetRegistered { + #[topic] + pub asset_id: BytesN<32>, + pub owner: Address, + pub timestamp: u64, +} + +/// Ownership of an asset moved to a new owner. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetTransferred { + #[topic] + pub asset_id: BytesN<32>, + pub old_owner: Address, + pub new_owner: Address, + pub timestamp: u64, +} + +/// An asset was retired and can no longer be transferred. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetRetired { + #[topic] + pub asset_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + +/// An address was granted registrar rights. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegistrarAdded { + #[topic] + pub registrar: Address, + pub caller: Address, + pub timestamp: u64, +} + +/// An address had its registrar rights revoked. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegistrarRemoved { + #[topic] + pub registrar: Address, + pub caller: Address, + pub timestamp: u64, +} + +/// The contract was paused; mutating entrypoints now reject. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractPaused { + #[topic] + pub caller: Address, + pub timestamp: u64, +} + +/// The contract was unpaused and resumed normal operation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractUnpaused { + #[topic] + pub caller: Address, + pub timestamp: u64, +} + +/// An insurance policy was created for an asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PolicyCreated { + #[topic] + pub policy_id: BytesN<32>, + pub asset_id: BytesN<32>, + pub insurer: Address, + pub holder: Address, + pub timestamp: u64, +} + +/// An insurance policy was cancelled. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PolicyCancelled { + #[topic] + pub policy_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + +/// A claim was submitted against an insurance policy. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimSubmitted { + #[topic] + pub claim_id: BytesN<32>, + pub policy_id: BytesN<32>, + pub claimant: Address, + pub amount: i128, + pub timestamp: u64, +} + +/// The status of an insurance claim was changed by the insurer. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimStatusUpdated { + #[topic] + pub claim_id: BytesN<32>, + pub status: ClaimStatus, + pub timestamp: u64, +} + +/// A lease was created over an asset. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseCreated { + #[topic] + pub lease_id: BytesN<32>, + pub asset_id: BytesN<32>, + pub lessor: Address, + pub lessee: Address, + pub timestamp: u64, +} + +/// A leased asset was checked back in by the lessor. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseCheckedIn { + #[topic] + pub lease_id: BytesN<32>, + pub timestamp: u64, +} + +/// A lease was cancelled by the lessor or admin. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseCancelled { + #[topic] + pub lease_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Emission helpers +// --------------------------------------------------------------------------- + +pub fn contract_initialized(env: &Env, admin: &Address) { + ContractInitialized { + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_registered(env: &Env, asset_id: &BytesN<32>, owner: &Address) { + AssetRegistered { + asset_id: asset_id.clone(), + owner: owner.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_transferred( + env: &Env, + asset_id: &BytesN<32>, + old_owner: &Address, + new_owner: &Address, +) { + AssetTransferred { + asset_id: asset_id.clone(), + old_owner: old_owner.clone(), + new_owner: new_owner.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_retired(env: &Env, asset_id: &BytesN<32>, caller: &Address) { + AssetRetired { + asset_id: asset_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn registrar_added(env: &Env, registrar: &Address, caller: &Address) { + RegistrarAdded { + registrar: registrar.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn registrar_removed(env: &Env, registrar: &Address, caller: &Address) { + RegistrarRemoved { + registrar: registrar.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn contract_paused(env: &Env, caller: &Address) { + ContractPaused { + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn contract_unpaused(env: &Env, caller: &Address) { + ContractUnpaused { + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn policy_created( + env: &Env, + policy_id: &BytesN<32>, + asset_id: &BytesN<32>, + insurer: &Address, + holder: &Address, +) { + PolicyCreated { + policy_id: policy_id.clone(), + asset_id: asset_id.clone(), + insurer: insurer.clone(), + holder: holder.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn policy_cancelled(env: &Env, policy_id: &BytesN<32>, caller: &Address) { + PolicyCancelled { + policy_id: policy_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn claim_submitted( + env: &Env, + claim_id: &BytesN<32>, + policy_id: &BytesN<32>, + claimant: &Address, + amount: i128, +) { + ClaimSubmitted { + claim_id: claim_id.clone(), + policy_id: policy_id.clone(), + claimant: claimant.clone(), + amount, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn claim_status_updated(env: &Env, claim_id: &BytesN<32>, status: ClaimStatus) { + ClaimStatusUpdated { + claim_id: claim_id.clone(), + status, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn lease_created( + env: &Env, + lease_id: &BytesN<32>, + asset_id: &BytesN<32>, + lessor: &Address, + lessee: &Address, +) { + LeaseCreated { + lease_id: lease_id.clone(), + asset_id: asset_id.clone(), + lessor: lessor.clone(), + lessee: lessee.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn lease_checked_in(env: &Env, lease_id: &BytesN<32>) { + LeaseCheckedIn { + lease_id: lease_id.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn lease_cancelled(env: &Env, lease_id: &BytesN<32>, caller: &Address) { + LeaseCancelled { + lease_id: lease_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/contrib/src/insurance.rs b/contracts/contrib/src/insurance.rs index 9fd0b6dec..5f1db4ac5 100644 --- a/contracts/contrib/src/insurance.rs +++ b/contracts/contrib/src/insurance.rs @@ -1,5 +1,5 @@ use crate::DataKey as GlobalDataKey; -use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Env, String, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -71,9 +71,12 @@ pub fn create_policy(env: Env, asset_id: BytesN<32>, policy_data: InsurancePolic store.set(&key, &policy_data); - env.events().publish( - (symbol_short!("pol_cre"), policy_data.policy_id.clone()), - (asset_id, policy_data.insurer, policy_data.holder), + crate::events::policy_created( + &env, + &policy_data.policy_id, + &asset_id, + &policy_data.insurer, + &policy_data.holder, ); } @@ -97,10 +100,7 @@ pub fn cancel_policy(env: Env, policy_id: BytesN<32>, caller: Address) { policy.status = PolicyStatus::Cancelled; store.set(&key, &policy); - env.events().publish( - (symbol_short!("pol_can"), policy_id), - (caller, env.ledger().timestamp()), - ); + crate::events::policy_cancelled(&env, &policy_id, &caller); } pub fn is_policy_active(env: Env, policy_id: BytesN<32>) -> bool { @@ -159,10 +159,7 @@ pub fn submit_claim( claims.push_back(claim.claim_id.clone()); store.set(&list_key, &claims); - env.events().publish( - (symbol_short!("clm_sub"), claim.claim_id.clone()), - (claim.policy_id, claimant, amount), - ); + crate::events::claim_submitted(&env, &claim.claim_id, &claim.policy_id, &claimant, amount); } pub fn update_claim_status( @@ -184,10 +181,7 @@ pub fn update_claim_status( claim.status = new_status; store.set(&claim_key, &claim); - env.events().publish( - (symbol_short!("clm_upd"), claim_id), - (claim.status, env.ledger().timestamp()), - ); + crate::events::claim_status_updated(&env, &claim_id, claim.status); } pub fn get_claim(env: Env, claim_id: BytesN<32>) -> InsuranceClaim { diff --git a/contracts/contrib/src/lease.rs b/contracts/contrib/src/lease.rs index 27522c733..f37671ae0 100644 --- a/contracts/contrib/src/lease.rs +++ b/contracts/contrib/src/lease.rs @@ -1,5 +1,5 @@ use crate::{Asset, DataKey as GlobalDataKey}; -use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -82,10 +82,7 @@ pub fn create_lease( leases.push_back(lease_id.clone()); store.set(&list_key, &leases); - env.events().publish( - (symbol_short!("lease_cr"), lease_id.clone()), - (asset_id, lessor, lessee), - ); + crate::events::lease_created(&env, &lease_id, &asset_id, &lessor, &lessee); lease_id } @@ -103,10 +100,7 @@ pub fn check_in_lease(env: Env, lease_id: BytesN<32>, caller: Address) { lease.status = LeaseStatus::Returned; store.set(&key, &lease); - env.events().publish( - (symbol_short!("lease_in"), lease_id), - (env.ledger().timestamp(),), - ); + crate::events::lease_checked_in(&env, &lease_id); } pub fn cancel_lease(env: Env, lease_id: BytesN<32>, caller: Address) { @@ -124,10 +118,7 @@ pub fn cancel_lease(env: Env, lease_id: BytesN<32>, caller: Address) { lease.status = LeaseStatus::Cancelled; store.set(&key, &lease); - env.events().publish( - (symbol_short!("lease_can"), lease_id), - (caller, env.ledger().timestamp()), - ); + crate::events::lease_cancelled(&env, &lease_id, &caller); } pub fn get_active_leases(env: Env, asset_id: BytesN<32>) -> Vec> { diff --git a/contracts/contrib/src/lib.rs b/contracts/contrib/src/lib.rs index cffa6d7df..2d97718e7 100644 --- a/contracts/contrib/src/lib.rs +++ b/contracts/contrib/src/lib.rs @@ -30,6 +30,7 @@ //! for the full entrypoint, storage, event, and error tables. mod audit; +pub mod events; mod pause; mod types; @@ -40,9 +41,7 @@ mod lease; mod tests; use crate::types::AssetStatus; -use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec, -}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, String, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -103,6 +102,8 @@ impl ContribContract { env.storage() .persistent() .set(&DataKey::AuthorizedRegistrar(admin.clone()), &true); + + events::contract_initialized(&env, &admin); } pub fn get_admin(env: Env) -> Address { @@ -124,7 +125,9 @@ impl ContribContract { } env.storage() .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &true); + .set(&DataKey::AuthorizedRegistrar(registrar.clone()), &true); + + events::registrar_added(&env, ®istrar, &caller); } pub fn remove_authorized_registrar(env: Env, caller: Address, registrar: Address) { @@ -139,7 +142,9 @@ impl ContribContract { } env.storage() .persistent() - .set(&DataKey::AuthorizedRegistrar(registrar), &false); + .set(&DataKey::AuthorizedRegistrar(registrar.clone()), &false); + + events::registrar_removed(&env, ®istrar, &caller); } pub fn is_authorized_registrar(env: Env, address: Address) -> bool { @@ -204,10 +209,7 @@ impl ContribContract { String::from_str(&env, "Asset registered"), ); - env.events().publish( - (symbol_short!("asset_reg"), asset.id.clone()), - (asset.owner, env.ledger().timestamp()), - ); + events::asset_registered(&env, &asset.id, &asset.owner); } pub fn transfer_asset(env: Env, asset_id: BytesN<32>, new_owner: Address, caller: Address) { @@ -245,10 +247,7 @@ impl ContribContract { String::from_str(&env, "Asset transferred"), ); - env.events().publish( - (symbol_short!("asset_tra"), asset_id.clone()), - (old_owner, new_owner, env.ledger().timestamp()), - ); + events::asset_transferred(&env, &asset_id, &old_owner, &new_owner); } pub fn retire_asset(env: Env, asset_id: BytesN<32>, caller: Address) { @@ -279,10 +278,7 @@ impl ContribContract { String::from_str(&env, "Asset retired"), ); - env.events().publish( - (symbol_short!("asset_ret"), asset_id), - (caller, env.ledger().timestamp()), - ); + events::asset_retired(&env, &asset_id, &caller); } pub fn get_asset(env: Env, asset_id: BytesN<32>) -> Option { diff --git a/contracts/contrib/src/pause.rs b/contracts/contrib/src/pause.rs index 655f0c590..d11152dbe 100644 --- a/contracts/contrib/src/pause.rs +++ b/contracts/contrib/src/pause.rs @@ -14,7 +14,7 @@ pub fn pause(env: &Env, caller: Address) { panic!("Only admin can call this function"); } env.storage().persistent().set(&DataKey::Paused, &true); - env.events().publish(("pause",), ()); + crate::events::contract_paused(env, &caller); } pub fn unpause(env: &Env, caller: Address) { @@ -28,7 +28,7 @@ pub fn unpause(env: &Env, caller: Address) { panic!("Only admin can call this function"); } env.storage().persistent().set(&DataKey::Paused, &false); - env.events().publish(("unpause",), ()); + crate::events::contract_unpaused(env, &caller); } pub fn is_paused(env: &Env) -> bool { diff --git a/contracts/contrib/src/tests/mod.rs b/contracts/contrib/src/tests/mod.rs index ca24b9628..362a93afc 100644 --- a/contracts/contrib/src/tests/mod.rs +++ b/contracts/contrib/src/tests/mod.rs @@ -5,7 +5,10 @@ mod dividends; mod tokenization; mod voting; use crate::{Asset, AssetStatus, ContribContract, ContribContractClient}; -use soroban_sdk::{testutils::Address as _, testutils::Events as _, Address, BytesN, Env, String}; +use soroban_sdk::{ + testutils::Address as _, testutils::Events as _, Address, BytesN, Env, String, Symbol, + TryIntoVal, +}; fn create_env() -> Env { Env::default() @@ -120,15 +123,31 @@ fn test_register_asset_emits_event() { let asset_id = generate_asset_id(&env, 1); let asset = create_test_asset(&env, &owner, asset_id.clone()); - let initial_events = env.events().all().len(); - env.mock_all_auths(); client.register_asset(&admin, &asset); - let final_events = env.events().all().len(); - assert!( - final_events > initial_events, - "Expected asset registered event to be emitted" + // `events().all()` reports the events of the most recent invocation, so + // this is exactly what register_asset published. + let events = env.events().all(); + assert_eq!( + events.len(), + 1, + "register_asset should emit exactly one event" + ); + + let (_contract, topics, _data) = events.last().unwrap(); + + let name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + assert_eq!( + name, + Symbol::new(&env, "asset_registered"), + "topic 0 should name the event" + ); + + let emitted_id: BytesN<32> = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!( + emitted_id, asset_id, + "topic 1 should be the asset id so consumers can index on it" ); } diff --git a/contracts/multisig_transfer/Cargo.toml b/contracts/multisig-transfer/Cargo.toml similarity index 100% rename from contracts/multisig_transfer/Cargo.toml rename to contracts/multisig-transfer/Cargo.toml diff --git a/contracts/multisig_transfer/README.md b/contracts/multisig-transfer/README.md similarity index 95% rename from contracts/multisig_transfer/README.md rename to contracts/multisig-transfer/README.md index a5a450217..965a6b107 100644 --- a/contracts/multisig_transfer/README.md +++ b/contracts/multisig-transfer/README.md @@ -8,9 +8,10 @@ met, and is then executed — at which point this contract calls into a separate 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]. +The directory and package names now match (`multisig-transfer`), so +`cargo test -p multisig-transfer` resolves from the directory name like every +other crate. This was fixed in [SC-33]; the directory was previously +`multisig_transfer/` with an underscore. ## Relationship to `multisig-wallet` diff --git a/contracts/multisig_transfer/src/approvals.rs b/contracts/multisig-transfer/src/approvals.rs similarity index 100% rename from contracts/multisig_transfer/src/approvals.rs rename to contracts/multisig-transfer/src/approvals.rs diff --git a/contracts/multisig_transfer/src/auth_tests.rs b/contracts/multisig-transfer/src/auth_tests.rs similarity index 100% rename from contracts/multisig_transfer/src/auth_tests.rs rename to contracts/multisig-transfer/src/auth_tests.rs diff --git a/contracts/multisig_transfer/src/errors.rs b/contracts/multisig-transfer/src/errors.rs similarity index 100% rename from contracts/multisig_transfer/src/errors.rs rename to contracts/multisig-transfer/src/errors.rs diff --git a/contracts/multisig-transfer/src/events.rs b/contracts/multisig-transfer/src/events.rs new file mode 100644 index 000000000..50704476d --- /dev/null +++ b/contracts/multisig-transfer/src/events.rs @@ -0,0 +1,224 @@ +//! Typed contract events for `multisig-transfer`. +//! +//! Every event is a `#[contractevent]` struct following the workspace +//! convention documented in `contracts/EVENTS.md`: +//! +//! - The struct name is ``; the SDK derives topic 0 +//! from it in `lower_snake_case` (`TransferRequested` -> `transfer_requested`). +//! - The primary entity identifier is marked `#[topic]` so downstream systems +//! can index on it. +//! - Remaining fields form the data map, and every event carries `timestamp`. +//! +//! Because these types are emitted into the contract spec, clients can +//! generate bindings for them rather than hand-decoding tuples. + +use soroban_sdk::{contractevent, Address, BytesN, Env}; + +/// A transfer request was created and is awaiting approvals. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferRequested { + #[topic] + pub request_id: u64, + pub asset_id: BytesN<32>, + pub from_owner: Address, + pub to_owner: Address, + pub timestamp: u64, +} + +/// An authorized approver approved a pending transfer request. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferApproved { + #[topic] + pub request_id: u64, + pub approver: Address, + pub approval_count: u32, + pub timestamp: u64, +} + +/// An authorized approver rejected a pending transfer request. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferRejected { + #[topic] + pub request_id: u64, + pub rejector: Address, + pub reason_hash: BytesN<32>, + pub timestamp: u64, +} + +/// A fully approved transfer was executed and ownership moved in the registry. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferExecuted { + #[topic] + pub request_id: u64, + pub asset_id: BytesN<32>, + pub new_owner: Address, + pub timestamp: u64, +} + +/// A pending transfer request was cancelled by its requester or the admin. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferCancelled { + #[topic] + pub request_id: u64, + pub cancelled_by: Address, + pub timestamp: u64, +} + +/// The approval rule for an asset category was created or changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApprovalRuleUpdated { + #[topic] + pub category: BytesN<32>, + pub required_approvals: u32, + pub timestamp: u64, +} + +/// An address was granted approver rights. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApproverAdded { + #[topic] + pub approver: Address, + pub added_by: Address, + pub timestamp: u64, +} + +/// An address had its approver rights revoked. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApproverRemoved { + #[topic] + pub approver: Address, + pub removed_by: Address, + pub timestamp: u64, +} + +/// The contract was initialized with an admin and a registry address. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractInitialized { + #[topic] + pub admin: Address, + pub asset_registry: Address, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Emission helpers +// +// These keep call sites in `lib.rs` terse and ensure every event is published +// from exactly one place. +// --------------------------------------------------------------------------- + +pub fn contract_initialized(e: &Env, admin: &Address, asset_registry: &Address, timestamp: u64) { + ContractInitialized { + admin: admin.clone(), + asset_registry: asset_registry.clone(), + timestamp, + } + .publish(e); +} + +pub fn transfer_requested( + e: &Env, + request_id: u64, + asset_id: &BytesN<32>, + from_owner: &Address, + to_owner: &Address, + timestamp: u64, +) { + TransferRequested { + request_id, + asset_id: asset_id.clone(), + from_owner: from_owner.clone(), + to_owner: to_owner.clone(), + timestamp, + } + .publish(e); +} + +pub fn transfer_approved(e: &Env, request_id: u64, approver: &Address, count: u32, timestamp: u64) { + TransferApproved { + request_id, + approver: approver.clone(), + approval_count: count, + timestamp, + } + .publish(e); +} + +pub fn transfer_rejected( + e: &Env, + request_id: u64, + rejector: &Address, + reason_hash: &BytesN<32>, + timestamp: u64, +) { + TransferRejected { + request_id, + rejector: rejector.clone(), + reason_hash: reason_hash.clone(), + timestamp, + } + .publish(e); +} + +pub fn transfer_executed( + e: &Env, + request_id: u64, + asset_id: &BytesN<32>, + new_owner: &Address, + timestamp: u64, +) { + TransferExecuted { + request_id, + asset_id: asset_id.clone(), + new_owner: new_owner.clone(), + timestamp, + } + .publish(e); +} + +pub fn transfer_cancelled(e: &Env, request_id: u64, cancelled_by: &Address, timestamp: u64) { + TransferCancelled { + request_id, + cancelled_by: cancelled_by.clone(), + timestamp, + } + .publish(e); +} + +pub fn approval_rule_updated(e: &Env, category: &BytesN<32>, required: u32, timestamp: u64) { + ApprovalRuleUpdated { + category: category.clone(), + required_approvals: required, + timestamp, + } + .publish(e); +} + +#[allow(dead_code)] +pub fn approver_added(e: &Env, approver: &Address, added_by: &Address, timestamp: u64) { + ApproverAdded { + approver: approver.clone(), + added_by: added_by.clone(), + timestamp, + } + .publish(e); +} + +#[allow(dead_code)] +pub fn approver_removed(e: &Env, approver: &Address, removed_by: &Address, timestamp: u64) { + ApproverRemoved { + approver: approver.clone(), + removed_by: removed_by.clone(), + timestamp, + } + .publish(e); +} diff --git a/contracts/multisig_transfer/src/lib.rs b/contracts/multisig-transfer/src/lib.rs similarity index 99% rename from contracts/multisig_transfer/src/lib.rs rename to contracts/multisig-transfer/src/lib.rs index 9194e06c8..31e1d9ffb 100644 --- a/contracts/multisig_transfer/src/lib.rs +++ b/contracts/multisig-transfer/src/lib.rs @@ -64,6 +64,8 @@ impl MultiSigTransferContract { e.storage() .persistent() .set(&storage::DataKey::NextRequestId, &1u64); + + events::contract_initialized(&e, &admin, &asset_registry, utils::now(&e)); } // ---------------------------- diff --git a/contracts/multisig_transfer/src/registry.rs b/contracts/multisig-transfer/src/registry.rs similarity index 100% rename from contracts/multisig_transfer/src/registry.rs rename to contracts/multisig-transfer/src/registry.rs diff --git a/contracts/multisig_transfer/src/rules.rs b/contracts/multisig-transfer/src/rules.rs similarity index 100% rename from contracts/multisig_transfer/src/rules.rs rename to contracts/multisig-transfer/src/rules.rs diff --git a/contracts/multisig_transfer/src/storage.rs b/contracts/multisig-transfer/src/storage.rs similarity index 100% rename from contracts/multisig_transfer/src/storage.rs rename to contracts/multisig-transfer/src/storage.rs diff --git a/contracts/multisig_transfer/src/types.rs b/contracts/multisig-transfer/src/types.rs similarity index 100% rename from contracts/multisig_transfer/src/types.rs rename to contracts/multisig-transfer/src/types.rs diff --git a/contracts/multisig_transfer/src/utils.rs b/contracts/multisig-transfer/src/utils.rs similarity index 100% rename from contracts/multisig_transfer/src/utils.rs rename to contracts/multisig-transfer/src/utils.rs diff --git a/contracts/multisig-wallet/src/event_tests.rs b/contracts/multisig-wallet/src/event_tests.rs new file mode 100644 index 000000000..0e9757e59 --- /dev/null +++ b/contracts/multisig-wallet/src/event_tests.rs @@ -0,0 +1,162 @@ +//! Event emission tests. +//! +//! `env.events().all()` reports the events of the **most recent contract +//! invocation**, not every event since the test began, so each assertion here +//! looks at exactly what the entrypoint under test published. +//! +//! See `contracts/EVENTS.md` for the catalogue these assertions pin down. + +use soroban_sdk::testutils::{Address as _, Events as _}; +use soroban_sdk::{Address, Env, Symbol, TryIntoVal, Val, Vec}; + +use crate::{MultisigWallet, MultisigWalletClient}; + +fn setup(env: &Env) -> (MultisigWalletClient<'_>, Address, Address, Address) { + let admin = Address::generate(env); + let alice = Address::generate(env); + let bob = Address::generate(env); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(env, &contract_id); + env.mock_all_auths(); + client.initialize( + &admin, + &Vec::from_array(env, [alice.clone(), bob.clone()]), + &2, + ); + (client, admin, alice, bob) +} + +/// Topics of the last event published by the most recent invocation. +fn last_topics(env: &Env) -> Vec { + let events = env.events().all(); + assert!(!events.is_empty(), "expected at least one event"); + let (_contract, topics, _data) = events.last().unwrap(); + topics +} + +/// Names of every event published by the most recent invocation, in order. +fn event_names(env: &Env) -> Vec { + let mut names = Vec::new(env); + for (_contract, topics, _data) in env.events().all().iter() { + names.push_back(event_name(env, &topics)); + } + names +} + +fn event_name(env: &Env, topics: &Vec) -> Symbol { + topics + .get(0) + .expect("event must have a name topic") + .try_into_val(env) + .expect("topic 0 must decode as a Symbol") +} + +#[test] +fn initialize_emits_wallet_initialized() { + // initialize was silent before the event catalogue. + let env = Env::default(); + let (_client, admin, _alice, _bob) = setup(&env); + + let topics = last_topics(&env); + assert_eq!( + event_name(&env, &topics), + Symbol::new(&env, "wallet_initialized") + ); + let emitted: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, admin); +} + +#[test] +fn propose_change_threshold_emits_proposal_submitted() { + // The propose_* entrypoints were silent before the event catalogue, so a + // pending governance change was invisible off-chain until it executed. + let env = Env::default(); + let (client, _admin, alice, _bob) = setup(&env); + + let proposal_id = client.propose_change_threshold(&alice, &2); + + let topics = last_topics(&env); + assert_eq!( + event_name(&env, &topics), + Symbol::new(&env, "proposal_submitted") + ); + let emitted: u64 = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, proposal_id, "proposal id must be indexable"); +} + +#[test] +fn confirm_proposal_emits_proposal_confirmed() { + let env = Env::default(); + let (client, _admin, alice, _bob) = setup(&env); + let proposal_id = client.propose_add_owner(&alice, &Address::generate(&env)); + + client.confirm_proposal(&alice, &proposal_id); + + let topics = last_topics(&env); + assert_eq!( + event_name(&env, &topics), + Symbol::new(&env, "proposal_confirmed") + ); +} + +#[test] +fn threshold_change_reaching_quorum_emits_threshold_changed() { + let env = Env::default(); + let (client, _admin, alice, bob) = setup(&env); + let proposal_id = client.propose_change_threshold(&alice, &1); + + client.confirm_proposal(&alice, &proposal_id); + // Second confirmation reaches the 2-of-2 threshold and auto-executes. + client.confirm_proposal(&bob, &proposal_id); + + // Read the events before any other call: `events().all()` only reports the + // most recent invocation, and even a read entrypoint like get_threshold() + // is an invocation that would clear this view. + let names = event_names(&env); + assert!( + names.contains(Symbol::new(&env, "proposal_confirmed")), + "the confirming call should emit proposal_confirmed" + ); + assert!( + names.contains(Symbol::new(&env, "threshold_changed")), + "reaching quorum should auto-execute and emit threshold_changed" + ); + + assert_eq!(client.get_threshold(), 1, "proposal should have executed"); +} + +#[test] +fn freeze_and_unfreeze_emit_distinct_events() { + let env = Env::default(); + let (client, _admin, alice, bob) = setup(&env); + + client.emergency_freeze(&alice); + let topics = last_topics(&env); + assert_eq!( + event_name(&env, &topics), + Symbol::new(&env, "wallet_frozen") + ); + + // Unfreezing requires an owner, not the admin. + client.emergency_unfreeze(&bob); + let topics = last_topics(&env); + assert_eq!( + event_name(&env, &topics), + Symbol::new(&env, "wallet_unfrozen") + ); +} + +#[test] +fn set_daily_limit_emits_daily_limit_changed() { + // set_daily_limit was silent before the event catalogue. + let env = Env::default(); + let (client, _admin, alice, _bob) = setup(&env); + + client.set_daily_limit(&alice, &1_000u128); + + let topics = last_topics(&env); + assert_eq!( + event_name(&env, &topics), + Symbol::new(&env, "daily_limit_changed") + ); +} diff --git a/contracts/multisig-wallet/src/events.rs b/contracts/multisig-wallet/src/events.rs new file mode 100644 index 000000000..81df159c1 --- /dev/null +++ b/contracts/multisig-wallet/src/events.rs @@ -0,0 +1,308 @@ +//! Typed contract events for `multisig-wallet`. +//! +//! See `contracts/EVENTS.md` for the workspace-wide convention. Struct names +//! are ``; the SDK derives topic 0 from the name in +//! `lower_snake_case`. The identifier a consumer would filter on (`tx_id`, +//! `proposal_id`, an owner address) is marked `#[topic]`. + +use soroban_sdk::{contractevent, Address, Env, Vec}; + +use crate::types::{ProposalType, TransactionType}; + +/// The wallet was initialized with its owner set and threshold. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletInitialized { + #[topic] + pub admin: Address, + pub owners: Vec
, + pub threshold: u32, + pub timestamp: u64, +} + +/// An owner submitted a transaction for confirmation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionSubmitted { + #[topic] + pub tx_id: u64, + pub initiator: Address, + pub tx_type: TransactionType, + pub timestamp: u64, +} + +/// An owner confirmed a pending transaction. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionConfirmed { + #[topic] + pub tx_id: u64, + pub confirmer: Address, + pub confirmations_count: u32, + pub timestamp: u64, +} + +/// An owner withdrew a previously given confirmation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfirmationRevoked { + #[topic] + pub tx_id: u64, + pub revoker: Address, + pub timestamp: u64, +} + +/// A transaction reached its threshold and was executed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionExecuted { + #[topic] + pub tx_id: u64, + pub initiator: Address, + pub timestamp: u64, +} + +/// A pending transaction was cancelled before execution. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransactionCancelled { + #[topic] + pub tx_id: u64, + pub caller: Address, + pub timestamp: u64, +} + +/// An owner raised a governance proposal. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProposalSubmitted { + #[topic] + pub proposal_id: u64, + pub proposer: Address, + pub proposal_type: ProposalType, + pub timestamp: u64, +} + +/// An owner confirmed a governance proposal. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProposalConfirmed { + #[topic] + pub proposal_id: u64, + pub confirmer: Address, + pub confirmations_count: u32, + pub timestamp: u64, +} + +/// An owner was added to the wallet by an executed proposal. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OwnerAdded { + #[topic] + pub owner: Address, + pub proposer: Address, + pub timestamp: u64, +} + +/// An owner was removed from the wallet by an executed proposal. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OwnerRemoved { + #[topic] + pub owner: Address, + pub proposer: Address, + pub timestamp: u64, +} + +/// The confirmation threshold was changed by an executed proposal. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ThresholdChanged { + pub old_threshold: u32, + pub new_threshold: u32, + pub timestamp: u64, +} + +/// The wallet was frozen; all mutating operations now reject. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletFrozen { + #[topic] + pub caller: Address, + pub timestamp: u64, +} + +/// The wallet was unfrozen and resumed normal operation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletUnfrozen { + #[topic] + pub caller: Address, + pub timestamp: u64, +} + +/// The daily spend limit was changed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DailyLimitChanged { + #[topic] + pub caller: Address, + pub limit: u128, + pub timestamp: u64, +} + +/// A transaction was rejected because it would exceed the daily spend limit. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DailyLimitReached { + pub limit: u128, + pub attempted_total: u128, + pub timestamp: u64, +} + +// --------------------------------------------------------------------------- +// Emission helpers +// --------------------------------------------------------------------------- + +pub fn wallet_initialized(env: &Env, admin: &Address, owners: &Vec
, threshold: u32) { + WalletInitialized { + admin: admin.clone(), + owners: owners.clone(), + threshold, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn transaction_submitted(env: &Env, tx_id: u64, initiator: &Address, tx_type: TransactionType) { + TransactionSubmitted { + tx_id, + initiator: initiator.clone(), + tx_type, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn transaction_confirmed(env: &Env, tx_id: u64, confirmer: &Address, count: u32) { + TransactionConfirmed { + tx_id, + confirmer: confirmer.clone(), + confirmations_count: count, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn confirmation_revoked(env: &Env, tx_id: u64, revoker: &Address) { + ConfirmationRevoked { + tx_id, + revoker: revoker.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn transaction_executed(env: &Env, tx_id: u64, initiator: &Address) { + TransactionExecuted { + tx_id, + initiator: initiator.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn transaction_cancelled(env: &Env, tx_id: u64, caller: &Address) { + TransactionCancelled { + tx_id, + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn proposal_submitted( + env: &Env, + proposal_id: u64, + proposer: &Address, + proposal_type: ProposalType, +) { + ProposalSubmitted { + proposal_id, + proposer: proposer.clone(), + proposal_type, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn proposal_confirmed(env: &Env, proposal_id: u64, confirmer: &Address, count: u32) { + ProposalConfirmed { + proposal_id, + confirmer: confirmer.clone(), + confirmations_count: count, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn owner_added(env: &Env, owner: &Address, proposer: &Address) { + OwnerAdded { + owner: owner.clone(), + proposer: proposer.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn owner_removed(env: &Env, owner: &Address, proposer: &Address) { + OwnerRemoved { + owner: owner.clone(), + proposer: proposer.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn threshold_changed(env: &Env, old_threshold: u32, new_threshold: u32) { + ThresholdChanged { + old_threshold, + new_threshold, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn wallet_frozen(env: &Env, caller: &Address) { + WalletFrozen { + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn wallet_unfrozen(env: &Env, caller: &Address) { + WalletUnfrozen { + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn daily_limit_changed(env: &Env, caller: &Address, limit: u128) { + DailyLimitChanged { + caller: caller.clone(), + limit, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn daily_limit_reached(env: &Env, limit: u128, attempted_total: u128) { + DailyLimitReached { + limit, + attempted_total, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/multisig-wallet/src/lib.rs b/contracts/multisig-wallet/src/lib.rs index 8aaf78f0b..ef17fe6c8 100644 --- a/contracts/multisig-wallet/src/lib.rs +++ b/contracts/multisig-wallet/src/lib.rs @@ -27,10 +27,13 @@ //! 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}; +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Val, Vec}; mod errors; #[cfg(test)] +mod event_tests; +pub mod events; +#[cfg(test)] mod tests; mod types; @@ -90,6 +93,8 @@ impl MultisigWallet { .set(&DataKey::OwnerProfile(owner), &profile); } + events::wallet_initialized(&env, &admin, &owners, threshold); + Ok(()) } @@ -136,10 +141,7 @@ impl MultisigWallet { .persistent() .set(&DataKey::Transaction(tx_id), &tx); - env.events().publish( - (symbol_short!("tx_sub"), tx_id), - (initiator, tx.tx_type, env.ledger().timestamp()), - ); + events::transaction_submitted(&env, tx_id, &initiator, tx.tx_type); Ok(tx_id) } @@ -196,10 +198,7 @@ impl MultisigWallet { .persistent() .set(&DataKey::OwnerProfile(confirmer.clone()), &profile); - env.events().publish( - (symbol_short!("tx_conf"), tx_id), - (confirmer, tx.confirmations_count, env.ledger().timestamp()), - ); + events::transaction_confirmed(&env, tx_id, &confirmer, tx.confirmations_count); // Auto-execute if threshold reached if tx.confirmations_count >= tx.required_confirmations { @@ -241,10 +240,7 @@ impl MultisigWallet { .persistent() .set(&DataKey::Transaction(tx_id), &tx); - env.events().publish( - (symbol_short!("tx_rev"), tx_id), - (revoker, env.ledger().timestamp()), - ); + events::confirmation_revoked(&env, tx_id, &revoker); Ok(()) } @@ -291,10 +287,7 @@ impl MultisigWallet { let _result: Val = env.invoke_contract(&tx.target, &tx.function_name, tx.parameters.clone()); - env.events().publish( - (symbol_short!("tx_exec"), tx_id), - (tx.initiator, _result, env.ledger().timestamp()), - ); + events::transaction_executed(&env, tx_id, &tx.initiator); Ok(()) } @@ -324,10 +317,7 @@ impl MultisigWallet { .persistent() .set(&DataKey::Transaction(tx_id), &tx); - env.events().publish( - (symbol_short!("tx_can"), tx_id), - (caller, env.ledger().timestamp()), - ); + events::transaction_cancelled(&env, tx_id, &caller); Ok(()) } @@ -432,6 +422,13 @@ impl MultisigWallet { .persistent() .set(&DataKey::Proposal(proposal_id), &proposal); + events::proposal_confirmed( + &env, + proposal_id, + &confirmer, + proposal.confirmations_received, + ); + let threshold: u32 = env.storage().instance().get(&DataKey::Threshold).unwrap(); if proposal.confirmations_received >= threshold { Self::execute_proposal(env, proposal_id)?; @@ -478,14 +475,7 @@ impl MultisigWallet { .persistent() .set(&DataKey::OwnerProfile(new_owner.clone()), &profile); - env.events().publish( - (symbol_short!("own_add"),), - ( - new_owner, - proposal.proposer.clone(), - env.ledger().timestamp(), - ), - ); + events::owner_added(&env, &new_owner, &proposal.proposer); } ProposalType::RemoveOwner => { let owner_to_remove = proposal.target_address.clone().unwrap(); @@ -499,14 +489,7 @@ impl MultisigWallet { .persistent() .remove(&DataKey::OwnerProfile(owner_to_remove.clone())); - env.events().publish( - (symbol_short!("own_rem"),), - ( - owner_to_remove, - proposal.proposer.clone(), - env.ledger().timestamp(), - ), - ); + events::owner_removed(&env, &owner_to_remove, &proposal.proposer); } ProposalType::ChangeThreshold => { let new_threshold = proposal.new_threshold.unwrap(); @@ -515,10 +498,7 @@ impl MultisigWallet { .instance() .set(&DataKey::Threshold, &new_threshold); - env.events().publish( - (symbol_short!("thr_chg"),), - (old_threshold, new_threshold, env.ledger().timestamp()), - ); + events::threshold_changed(&env, old_threshold, new_threshold); } } @@ -552,10 +532,7 @@ impl MultisigWallet { // For now, let's just set it. env.storage().instance().set(&DataKey::Frozen, &true); - env.events().publish( - (symbol_short!("frozen"),), - (caller, env.ledger().timestamp()), - ); + events::wallet_frozen(&env, &caller); Ok(()) } @@ -565,10 +542,7 @@ impl MultisigWallet { env.storage().instance().set(&DataKey::Frozen, &false); - env.events().publish( - (symbol_short!("unfrozen"),), - (caller, env.ledger().timestamp()), - ); + events::wallet_unfrozen(&env, &caller); Ok(()) } @@ -578,6 +552,8 @@ impl MultisigWallet { // Should probably be a proposal too. env.storage().instance().set(&DataKey::DailyLimit, &limit); + + events::daily_limit_changed(&env, &caller, limit); Ok(()) } @@ -669,10 +645,7 @@ impl MultisigWallet { .unwrap_or(0); if spent + amount > limit { - env.events().publish( - (symbol_short!("lim_rch"),), - (limit, spent + amount, env.ledger().timestamp()), - ); + events::daily_limit_reached(env, limit, spent + amount); return Err(Error::DailyLimitExceeded); } @@ -713,6 +686,8 @@ impl MultisigWallet { .persistent() .set(&DataKey::Proposal(id), &proposal); + events::proposal_submitted(env, id, &proposer, proposal.proposal_type); + // Auto-confirm for proposer // Self::confirm_proposal(env.clone(), proposer, id)?; diff --git a/contracts/multisig_transfer/src/events.rs b/contracts/multisig_transfer/src/events.rs deleted file mode 100644 index 1c0536e8f..000000000 --- a/contracts/multisig_transfer/src/events.rs +++ /dev/null @@ -1,84 +0,0 @@ -use soroban_sdk::{Address, BytesN, Env}; - -pub fn transfer_requested( - e: &Env, - request_id: u64, - asset_id: &BytesN<32>, - from_owner: &Address, - to_owner: &Address, - timestamp: u64, -) { - e.events().publish( - ("TransferRequested",), - ( - request_id, - asset_id.clone(), - from_owner.clone(), - to_owner.clone(), - timestamp, - ), - ); -} - -pub fn transfer_approved(e: &Env, request_id: u64, approver: &Address, count: u32, timestamp: u64) { - e.events().publish( - ("TransferApproved",), - (request_id, approver.clone(), count, timestamp), - ); -} - -pub fn transfer_rejected( - e: &Env, - request_id: u64, - rejector: &Address, - reason_hash: &BytesN<32>, - timestamp: u64, -) { - e.events().publish( - ("TransferRejected",), - (request_id, rejector.clone(), reason_hash.clone(), timestamp), - ); -} - -pub fn transfer_executed( - e: &Env, - request_id: u64, - asset_id: &BytesN<32>, - new_owner: &Address, - timestamp: u64, -) { - e.events().publish( - ("TransferExecuted",), - (request_id, asset_id.clone(), new_owner.clone(), timestamp), - ); -} - -pub fn transfer_cancelled(e: &Env, request_id: u64, cancelled_by: &Address, timestamp: u64) { - e.events().publish( - ("TransferCancelled",), - (request_id, cancelled_by.clone(), timestamp), - ); -} - -pub fn approval_rule_updated(e: &Env, category: &BytesN<32>, required: u32, timestamp: u64) { - e.events().publish( - ("ApprovalRuleUpdated",), - (category.clone(), required, timestamp), - ); -} - -#[allow(dead_code)] -pub fn approver_added(e: &Env, approver: &Address, added_by: &Address, timestamp: u64) { - e.events().publish( - ("ApproverAdded",), - (approver.clone(), added_by.clone(), timestamp), - ); -} - -#[allow(dead_code)] -pub fn approver_removed(e: &Env, approver: &Address, removed_by: &Address, timestamp: u64) { - e.events().publish( - ("ApproverRemoved",), - (approver.clone(), removed_by.clone(), timestamp), - ); -}