diff --git a/.circleci/config.yml b/.circleci/config.yml index 4279046e2e..5d8c00f9ca 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -546,7 +546,7 @@ jobs: exit 1 - run: name: Run polymesh-api integration tests - command: cargo nextest run --release --features current_release --locked + command: cargo nextest run --release --features current_release,timed --locked working_directory: ./integration no_output_timeout: 30m contract-deploy-test-foundry: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..dd4ebd1a8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md + +## Overview + +Polymesh is a Substrate-based L1 blockchain (Rust/FRAME). The root package builds the `polymesh` node binary (`src/bin/main.rs`); runtime WASM is built per chain by `substrate-wasm-builder` during `cargo build`. + +Detailed specs of the chain logic (identity/permissions, assets, settlement, etc.) live in [`docs/spec/`](docs/spec/README.md) — read the relevant spec before reviewing or modifying a subsystem. + +## Build & toolchain + +- Toolchain is pinned nightly via `rust-toolchain.toml` (with `rust-src`, `wasm32v1-none`). Let rustup pick it; don't force stable. +- First full `cargo build --release` is slow (builds all three runtime WASMs). +- Set `SKIP_WASM_BUILD=1` for clippy/unit-test iteration (what CI does); CI also builds/tests with `RUSTFLAGS=-D warnings`. +- All `sp-*`/`sc-*`/`frame-*` deps come from the Polymesh fork of polkadot-sdk (branch pinned in root `[workspace.dependencies]`). Use workspace deps, not crates.io equivalents, to avoid mismatched duplicates. Several other crypto crates are patched in `[patch.crates-io]` to Polymesh forks. + +## Verification (order used by CI) + +```sh +./scripts/rustfmt.sh # == cargo fmt -- --check +SKIP_WASM_BUILD=1 cargo clippy -- -A clippy::all -W clippy::complexity -W clippy::perf # non-standard flags +./scripts/test.sh # canonical unit-test subset (sets SKIP_WASM_BUILD/RUST_BACKTRACE) +cargo test -p # single package, e.g. -p pallet-asset +``` + +Two extra CI checks that break silently-unrelated-looking PRs: +- `./scripts/check_spec_and_cargo_version.sh` — all three runtimes must share one identical `spec_version`, encoded `8_001_000` ⇔ workspace version `8.1.0`. Bump both together. +- `./scripts/check_storage_versions.sh` — each pallet's `storage_migration_ver!` must equal its `StorageVersion::new(...)`. Update both whenever pallet storage changes. + +## Layout + +- Root workspace: node (`src/`), `pallets/`, `primitives/`, `rpc/`, `worker/`, `native-crypto/`, `precompiles/` (EVM precompiles with Solidity interfaces). +- **Three runtimes**: `pallets/runtime/{develop,testnet,mainnet}` — runtime changes usually need wiring in all three. Shared config in `pallets/runtime/common`; shared tests in `pallets/runtime/tests` (`polymesh-runtime-tests`, ext_builder-based). +- Pallet weights live centrally in `pallets/weights/src/*.rs`, not inside pallets. +- `integration/` and `metadata-tools/` are **separate** cargo workspaces (own lockfiles/toolchains), excluded from the root. +- Dev chains: `--dev` / `--chain dev` (develop runtime), plus `--chain testnet-dev`, `--chain mainnet-dev`. Raw chain specs tracked in `src/chain_specs/`. + +## Integration tests (`integration/`) + +They drive a **live chain over RPC**, not an in-process mock. Detailed authoring rules live in [`integration/AGENTS.md`](integration/AGENTS.md) — read that before adding or debugging tests. + +**Chain + eth-rpc are long-lived dependencies.** Prefer the user starts them (or start once as a background task); do **not** restart or kill them mid-session unless asked. Match CI (`rust-integration-test` in `.circleci/config.yml`): + +```sh +# Build once (from repo root). Prefer ci-runtime for local runs. +cargo build --locked --release --features ci-runtime + +# Terminal / background 1 — Polymesh node (WS :9944) +./target/release/polymesh --bob --dev --tmp --pool-limit 100000 \ + --unsafe-force-node-key-generation --no-prometheus --no-telemetry + +# Terminal / background 2 — eth-rpc (HTTP :8545); required for revive_* tests +docker run --rm --name parity-eth-rpc --network host \ + paritypr/eth-rpc:stable2606-73b734d9 \ + --node-rpc-url ws://127.0.0.1:9944 \ + --rpc-port 8545 --rpc-cors=all --allow-unprotected-txs + +# Terminal 3 — tests (node must already be up before first compile if using download_metadata) +export POLYMESH_NODE_URL=ws://127.0.0.1:9944 +export ETH_RPC_URL=http://127.0.0.1:8545 +cd integration && ./reset_db.sh # only after a fresh/restarted chain +cargo nextest run --release --features current_release,timed --locked +``` + +- Default feature `current_release` pins the matching `polymesh-api` version (`previous_release` exists for upgrade testing). +- `timed` gates tests that wait on blocks/timestamps; CI always enables it. +- `download_metadata` (enabled under `current_release`) codegen needs the node **up before** `cargo` starts. +- After any chain wipe/restart: `cd integration && ./reset_db.sh` before re-running tests. + +## Generated artifacts committed to git (CI lint verifies freshness) + +Regenerate and commit after editing sources: +- `integration/contracts/artifacts/*` ← `integration/contracts/build.sh` after editing `.sol` files (needs `solc` 0.8.33; `resolc` optional for PolkaVM blobs). +- `precompiles/src/interfaces/FungibleAssetStub.bin` ← `scripts/build_precompile_stub.sh` (requires exactly solc 0.8.33). +- `worker/*.polkavm|.wasm` protocol blobs ← rebuild scripts under `worker/`. +- `.metadata//*.meta` snapshots ← compared against running dev/testnet/mainnet nodes by `metadata-tools check`; intentional extrinsic/storage metadata changes require regenerating snapshots or CI fails. + +## Misc + +- Benchmarks need a release binary built with `--features runtime-benchmarks` (see README); resulting weight updates go into `pallets/weights/src/`. +- Branches: `develop` is the working branch; `staging` leads releases; `mainnet`/`testnet` track deployed code. Docker publishes/releases only trigger off these branches. diff --git a/docs/spec/01-identity-keys.md b/docs/spec/01-identity-keys.md new file mode 100644 index 0000000000..875caf19ef --- /dev/null +++ b/docs/spec/01-identity-keys.md @@ -0,0 +1,230 @@ +# 01 — Identity & Key Management + +Sources: `pallets/identity/src/{lib.rs,keys.rs,auth.rs,types.rs}`, +`primitives/src/secondary_key.rs`, `primitives/src/authorization.rs`, `primitives/src/crypto.rs`. +Related specs: [02-permissions](02-permissions.md) (how keys are permission-checked), +[03-claims](03-claims.md), [14-fees-and-extensions](14-fees-and-extensions.md) (who pays for +auth-accepting calls), [16-multisig](16-multisig.md). + +## 1. Purpose + +Every actor on Polymesh is an **identity** (`IdentityId`, a DID): a container that groups account +keys, claims, portfolios, and asset roles. Account keys sign transactions; identities carry the +on-chain rights. The identity pallet owns the key↔DID mapping, DID creation, the generic +two-phase **authorization** machinery, and claims (doc 03). + +## 2. Data model + +### Types + +| Type | Definition | Notes | +|---|---|---| +| `IdentityId` | `primitives/src/identity_id.rs` | 32-byte DID | +| `DidRecord` | primitives/src/identity.rs:75 | `{ primary_key: Option }` — `None` after the primary key was unlinked without replacement | +| `KeyRecord` | primitives/src/secondary_key.rs:287 | `PrimaryKey(IdentityId) \| SecondaryKey(IdentityId) \| MultiSigSignerKey(AccountId)` | +| `SecondaryKey` | primitives/src/secondary_key.rs:432 | `{ key: AccountId, permissions: Permissions }` (perms detailed in doc 02) | +| `Signatory` | primitives/src/secondary_key.rs:343 | `Identity(IdentityId) \| Account(AccountId)` — target of an authorization | +| `Authorization` | primitives/src/authorization.rs:108 | `{ authorization_data, authorized_by: IdentityId, expiry: Option, auth_id: u64, count: u32 }` | +| `AuthorizationData` | primitives/src/authorization.rs:30 | variant per two-phase operation, see §5 | + +### Storage (pallets/identity/src/lib.rs) + +| Item | Key → Value | Ref | +|---|---|---| +| `DidRecords` | DID → `DidRecord` | lib.rs:379 | +| `KeyRecords` | AccountId → `KeyRecord` | lib.rs:417 | +| `DidKeys` | (DID, AccountId) → bool — reverse index of all keys of a DID | lib.rs:440 | +| `IsDidFrozen` | DID → bool — secondary keys frozen | lib.rs:384 | +| `KeyExtrinsicPermissions` | AccountId → `ExtrinsicPermissions` | lib.rs:423 | +| `KeyAssetPermissions` | AccountId → `AssetPermissions` | lib.rs:429 | +| `KeyPortfolioPermissions` | AccountId → `PortfolioPermissions` | lib.rs:435 | +| `AccountKeyRefCount` | AccountId → u64 strong refs (blocks unlinking) | lib.rs:486 | +| `MultiPurposeNonce` | u64 nonce for DID generation | lib.rs:445 | +| `OffChainAuthorizationNonce` | DID → u64 nonce for off-chain key-add signatures | lib.rs:449 | +| `Authorizations` | (Signatory, auth_id) → `Authorization` | lib.rs:455 | +| `AuthorizationsGiven` | (issuer DID, auth_id) → Signatory | lib.rs:467 | +| `NumberOfGivenAuths` | DID → u32 (capped by `MaxGivenAuths`) | lib.rs:491 | +| `OutdatedAuthorizations` | Signatory → u64 threshold; auths with id ≤ threshold are invalid | lib.rs:495 | +| `CurrentAuthId` | u64 global auth id counter | lib.rs:500 | +| claims storage | see doc 03 | lib.rs:389-413 | + +Per-key permissions are stored in three separate maps (not inside a `SecondaryKey` struct); +`get_key_permissions` (keys.rs:107) reassembles them, defaulting each missing map to its +`Default` (= full access — but maps are always written when a secondary key is linked, via +`set_key_permissions` keys.rs:204). + +### Key model invariants + +1. **One key, one identity**: a key can be linked to at most one DID (or one multisig). + Enforced by `add_key_record` (keys.rs:227, no-op if already linked) and + `can_add_key_record`/`ensure_key_did_unlinked` (keys.rs:194-202); error `AlreadyLinked`. +2. **One primary key per identity**: `DidRecords[did].primary_key` is a single value; rotation + replaces it atomically (`common_rotate_primary_key`, keys.rs:296). +3. **Primary keys cannot be frozen** — freezing affects only secondary keys (`get_identity` + keys.rs:69-76 returns `None` for a frozen *secondary* key but always resolves primary keys). +4. **Strong references block unlinking**: `AccountKeyRefCount` > 0 ⇒ + `ensure_key_unlinkable_from_did` fails with `AccountKeyIsBeingUsed` (keys.rs:185). Refs are + added by pallets holding balances against the key (asset holdings, NFTs — see + `add_account_key_ref_count` keys.rs:175 callers). +5. **MultiSig signer keys are not identity keys**: `KeyRecord::MultiSigSignerKey` maps signer → + multisig account; the multisig account itself is the identity key (see doc 16). + +## 3. DID creation + +| Path | Origin requirement | Ref | +|---|---|---| +| `register_did(target_account)` | caller DID ∈ `DidRegistrars` group (pallet_group Instance2) | lib.rs:882, claims.rs:214 (`base_register_did`) | +| `self_register_did()` | any unlinked signed account (permissionless self-onboarding) | lib.rs:898 | +| `cdd_register_did`, `cdd_register_did_with_cdd` | DID registrar; **deprecated since 8.0.0** (CDD not enforced); only path that supports initial secondary keys | lib.rs:597, lib.rs:857 | +| genesis config | — | lib.rs:512-578 | +| systematic identities | chain-internal (`register_systematic_id` keys.rs:651) | `SystematicIssuers`, primitives/src/constants.rs:109 | + +All funnel into `register_did_without_cdd` (keys.rs:602): +1. target must be unlinked (keys.rs:608); +2. secondary keys must not contain the primary key nor duplicates (keys.rs:610-617); +3. DID = `blake2_256(USER, babe_randomness(nonce), nonce)` via `make_did` (keys.rs:586) — + `MultiPurposeNonce` increments even on failure for unpredictability (keys.rs:588); +4. protocol fee `IdentityRegisterDid` charged (keys.rs:623); +5. primary key linked, `InitialPOLYX` deposited (0 on mainnet/develop, 100k POLYX testnet — + `pallets/runtime/testnet/src/runtime.rs:153`); +6. secondary keys are **not** linked directly — a `JoinIdentity` authorization is created per + key (keys.rs:634-639), which each key must accept. + +Since 8.0.0, **DID existence == active** (`is_did_active` claims.rs:64); no CDD claim is required +to transact. `is_did_locked` is a stub always returning `false` (claims.rs:70, TODO). + +## 4. Key management operations + +### Extrinsic & authorization matrix + +| Extrinsic (call_index) | Who may call | Behavior | Ref | +|---|---|---|---| +| `accept_primary_key(2)` | new key (auth target) | consume `RotatePrimaryKey` auth; old primary key **unlinked** | lib.rs:620 → keys.rs:280 | +| `rotate_primary_key_to_secondary(15)` | new key (auth target) | consume `RotatePrimaryKeyToSecondary(perms)`; old primary key becomes secondary with `perms` | lib.rs:770 → keys.rs:364 | +| `join_identity_as_key(4)` | key (auth target) | consume `JoinIdentity(perms)`; link key as secondary | lib.rs:627 → keys.rs:512 | +| `leave_identity_as_key(5)` | the secondary key itself | unlink self; blocked if `AccountKeyRefCount` > 0 | lib.rs:634 → keys.rs:550 | +| `add_secondary_keys_with_authorization(16)` | **primary key only** | batch-link keys that signed an off-chain `ChainScopedMessage` | lib.rs:792 → keys.rs:445 | +| `set_secondary_key_permissions(17)` | **primary key only** | overwrite one secondary key's permissions | lib.rs:806 → keys.rs:383 | +| `remove_secondary_keys(18)` | **primary key only** | unlink keys (each must have refcount 0); outdates their pending auths | lib.rs:822 → keys.rs:410 | +| `freeze_secondary_keys(8)` / `unfreeze_secondary_keys(9)` | **primary key only** | set/clear `IsDidFrozen` | lib.rs:683/690 → keys.rs:570 | +| `add_authorization(10)` | any permissioned key of issuer DID | create an authorization | lib.rs:698 → auth.rs:30 | +| `remove_authorization(11)` | issuer (revoke) or target (reject) | delete an authorization | lib.rs:712 → auth.rs:94 | + +"Primary key only" is enforced via `ensure_primary_key` (keys.rs:690): the caller's `KeyRecord` +must be `PrimaryKey(_)`, else `KeyNotAllowed`. Note this does **not** go through the +extrinsic-permission pipeline — a secondary key with `Whole` extrinsic permissions still cannot +call these. + +### Primary key rotation (`common_rotate_primary_key`, keys.rs:296) + +Rules, in order: +1. Identity must currently have a primary key (else `InvalidAccountKey`). +2. New key must be **unlinked**, or already a **secondary key of the same DID** (promote-in-place); + a key of another DID / a multisig signer key ⇒ `AlreadyLinked` (keys.rs:304-323). +3. If the old primary key is being dropped (plain `accept_primary_key`), it must have + `AccountKeyRefCount == 0` (keys.rs:325-327). +4. Storage updates: new key becomes `PrimaryKey(did)`, `DidRecords[did]` repointed; when promoting + a secondary key its old record is overwritten (its per-key permission maps are *not* explicitly + cleared here — the maps are only removed by `remove_key_record` keys.rs:262; promoted keys keep + stale permission entries that are ignored while primary). Events: `SecondaryKeysRemoved` (if + promoted), `PrimaryKeyUpdated`, and `SecondaryKeysAdded` (if old key demoted with perms). + +Both rotation extrinsics are auth-accepting: the *issuing identity's primary key pays the fees* +(fee redirection in `pallets/runtime/common/src/fee_details.rs:199-220`, see doc 14). + +### Batch key addition with off-chain signatures (keys.rs:445) + +`add_secondary_keys_with_authorization` verifies for each key an sr25519/ed25519 signature over a +`ChainScopedMessage { genesis_hash, nonce, label: "Polymesh Identity Add Secondary Key", +expires_at, did }` (primitives/src/crypto.rs:80,93; wrapped in `...` for Polkadot-JS +compat, crypto.rs:59-77). The per-DID `OffChainAuthorizationNonce` increments once per batch +(keys.rs:459), so old signatures cannot be replayed; `expires_at` must be in the future +(crypto.rs:110-113 via `ChainScopedMessage::new`, error `AuthorizationExpired`). Protocol fee +charged per key (keys.rs:453). Duplicate keys in a batch ⇒ `DuplicateKey`; already-linked keys ⇒ +`AlreadyLinked`. + +## 5. Authorization machinery (two-phase operations) + +Flow: issuer calls `add_authorization` (or a pallet calls `add_auth` internally) → target later +accepts via a *type-specific* extrinsic that runs `accept_auth_with` (auth.rs:183) → auth is +validated (exists, not outdated, not expired), the per-type closure applies the change, the auth +is consumed. Rejection/revocation via `remove_authorization` (auth.rs:94): issuer may revoke; +target may reject. + +| `AuthorizationData` variant | Consuming extrinsic | Ref | +|---|---|---| +| `RotatePrimaryKey` | `Identity::accept_primary_key` | keys.rs:286-289 | +| `RotatePrimaryKeyToSecondary(Permissions)` | `Identity::rotate_primary_key_to_secondary` | keys.rs:370-377 | +| `JoinIdentity(Permissions)` | `Identity::join_identity_as_key`; also `MultiSig::approve_join_identity` (multisig-as-key flow, doc 16) | keys.rs:512-534 | +| `TransferTicker(Ticker)` | `Asset::accept_ticker_transfer` | pallets/asset/src/lib.rs:2059 | +| `TransferAssetOwnership(AssetId)` | `Asset::accept_asset_ownership_transfer` | pallets/asset/src/lib.rs:2081 | +| `BecomeAgent(AssetId, AgentGroup)` | `ExternalAgents::accept_become_agent` | pallets/external-agents/src/lib.rs:391 | +| `AddMultiSigSigner(AccountId)` | `MultiSig::accept_multisig_signer` | pallets/multisig/src/lib.rs:1225 | +| `PortfolioCustody(PortfolioId)` | `Portfolio::accept_portfolio_custody` | pallets/portfolio/src/lib.rs:852 | +| `AttestPrimaryKeyRotation`, `OldAddRelayerPayingKey` | deprecated, no consumer | primitives/src/authorization.rs:32,52 | + +Mechanics and limits: +- Auth ids are globally unique (`CurrentAuthId` increment, auth.rs:66). +- Per-issuer cap: `NumberOfGivenAuths < MaxGivenAuths` (=1024 all runtimes, + `pallets/runtime/develop/src/runtime.rs:148`), else `ExceededNumberOfGivenAuths` (auth.rs:58-62). +- `JoinIdentity`/`RotatePrimaryKeyToSecondary` payloads are complexity-checked at creation + (auth.rs:37-41). +- Expiry is checked at acceptance (`expiry > now`, auth.rs:192-195); expired auths are *not* + garbage-collected automatically. +- **Outdating**: when a secondary key is removed, all auths targeting it with + `auth_id <= CurrentAuthId` are invalidated via `OutdatedAuthorizations` (keys.rs:429-441, + checked in `ensure_authorization` auth.rs:222-226). Despite the comment at keys.rs:428, there is + no `on_initialize` cleanup — outdated auth storage entries persist and are only rejected on use. +- **Retry counting**: `count` starts at `MaxAuthRetries` (=10, runtime.rs:149; auth.rs:74). + For the fee-redirected accept calls, a *failed* dispatch decrements the count + (`polymesh-transaction-payment` post_dispatch, pallets/transaction-payment/src/lib.rs:533-536 → + fee_details.rs:297-315 → auth.rs:231). `get_non_expired_auth` (auth.rs:162) treats `count == 0` + as unusable, so the payer lookup fails and the tx becomes invalid at the pool — this stops a + malicious target from draining the issuer via repeatedly failing accepts. Note: the + `AuthorizationRetryLimitReached` event (lib.rs:353) is declared but never emitted. +- Fee redirection: for `join_identity_as_key`, `accept_primary_key`, + `rotate_primary_key_to_secondary`, `accept_multisig_signer` and + `remove_authorization{auth_issuer_pays: true}`, the **auth issuer's primary key** is charged + instead of the caller (fee_details.rs:189-244; doc 14). + +## 6. Cross-pallet surface + +Helpers other pallets rely on (all `pallets/identity/src/keys.rs` unless noted): + +| Helper | Purpose | Ref | +|---|---|---| +| `get_identity(key)` | key → DID; `None` if frozen secondary / multisig signer | keys.rs:69 | +| `ensure_perms(origin)` / `ensure_origin_call_permissions(origin)` | full permission pipeline (doc 02) | keys.rs:735/719 | +| `ensure_did(origin)` | key → DID without extrinsic-permission check | keys.rs:706 | +| `ensure_primary_key(origin)` | primary-key-only gate | keys.rs:690 | +| `ensure_valid_origin(origin, must_be_primary_key)` | permission check with optional primary-only mode | keys.rs:776 | +| `add_auth` / `accept_auth_with` / `ensure_auth_by` | authorization machinery for other pallets | auth.rs:52/183/176 | +| `add_account_key_ref_count` / `remove_account_key_ref_count` | strong refs (asset/NFT account holdings) | keys.rs:175/180 | +| `add_key_record` / `remove_key_record` | used by multisig for signer keys | keys.rs:227/243 | +| `asset_holder_did(AssetHolder)` | resolve Portfolio/Account holder → DID | keys.rs:790 | +| `CheckAccountCallPermissions` impl | the permission `Checker` (doc 02) | keys.rs:807 | + +## 7. Invariants & review checklist + +When reviewing changes touching identity/keys, verify: + +- [ ] No path links a key already present in `KeyRecords` (would break 1-key-1-DID); + all insertions go through `add_key_record` / check `can_add_key_record`. +- [ ] Any path unlinking a key checks `AccountKeyRefCount == 0` (else asset balances become + inaccessible-but-orphaned) and removes the per-key permission maps. +- [ ] `DidRecords[did].primary_key`, `KeyRecords[key]`, and `DidKeys[did][key]` stay mutually + consistent (all three updated together in add/remove/rotate paths). +- [ ] New auth-accepting extrinsics must: use `accept_auth_with`, validate `auth.authorized_by` + has authority over the object (e.g. via `ensure_auth_by`), and consider adding them to + `fee_details.rs` if the caller may lack POLYX. +- [ ] Primary-key-only extrinsics use `ensure_primary_key`, not `ensure_perms`. +- [ ] Anything creating authorizations respects `MaxGivenAuths` (use `add_auth`, don't insert + into `Authorizations` directly). +- [ ] Permission payloads validated with `ensure_perms_length_limited` (keys.rs:740). + +## 8. Test map + +- Unit: `pallets/runtime/tests/src/identity_test.rs` (rotation, join/leave, freeze, auths, + off-chain key adds), `fee_details.rs` + `signed_extra.rs` (payer redirection). +- Integration: `integration/tests/` identity flows (secondary keys, portfolios custody use + auth machinery heavily). diff --git a/docs/spec/02-permissions.md b/docs/spec/02-permissions.md new file mode 100644 index 0000000000..3afeb01fe9 --- /dev/null +++ b/docs/spec/02-permissions.md @@ -0,0 +1,203 @@ +# 02 — Permission Model & Enforcement Pipeline + +Sources: `primitives/src/secondary_key.rs`, `primitives/src/subset.rs`, +`pallets/permissions/src/lib.rs`, `pallets/identity/src/keys.rs`, +`pallets/runtime/common/src/runtime.rs`. +Related specs: [01-identity-keys](01-identity-keys.md), [05-external-agents](05-external-agents.md) +(asset-scoped agent permissions), [08-portfolio](08-portfolio.md) (portfolio permission checks), +[14-fees-and-extensions](14-fees-and-extensions.md). + +## 1. Purpose + +Polymesh restricts what each account key may do *within its identity*. The **primary key has +unrestricted access** to the identity. **Secondary keys** carry a `Permissions` value restricting +(a) which extrinsics they may call, (b) which assets they may administer, and (c) which portfolios +they may operate on. This doc covers the data model, the per-call enforcement pipeline, and the +catalog of checks pallets must apply. + +Layer summary (a call may pass through all four): + +``` +signed extrinsic + │ 1. StoreCallMetadata TxExtension records (pallet, extrinsic) names + ▼ +pallet dispatch → Identity::ensure_origin_call_permissions(origin) + │ 2. key → DID resolution; if secondary key: DID-not-frozen + + │ extrinsic-permission subset check (generic, same for all pallets) + ▼ +pallet-specific logic + │ 3. secondary-key asset subset → ExternalAgents::ensure_agent_asset_perms + │ secondary-key portfolio subset → Portfolio::ensure_portfolio_custody_and_permission + ▼ + │ 4. asset-scoped agent-group check (applies to primary keys too; doc 05) + ▼ storage changes +``` + +## 2. Permission data model (`primitives/src/secondary_key.rs`) + +``` +Permissions { // secondary_key.rs:217 + asset: SubsetRestriction, // = AssetPermissions, :41 + extrinsic: ExtrinsicPermissions, // :107 + portfolio: SubsetRestriction, // = PortfolioPermissions, :207 +} +``` + +- `SubsetRestriction` (primitives/src/subset.rs:28): `Whole` (everything) | + `These(BTreeSet)` (only these) | `Except(BTreeSet)` (all but these). +- `Permissions::default()` = full access (`Whole` everywhere); `Permissions::empty()` = none + (secondary_key.rs:226-234). **Caution when reviewing**: `default()` is *permissive*. +- `ExtrinsicPermissions` is two-level (secondary_key.rs:107): `Whole` | + `These(BTreeMap)` | `Except(...)`, where + `PalletPermissions { extrinsics: SubsetRestriction }` (secondary_key.rs:59) + selects functions within the pallet. +- Matching: `ExtrinsicPermissions::sufficient_for(pallet, extrinsic)` (secondary_key.rs:162) — + names are the literal Rust pallet module/function names from `GetCallMetadata` (e.g. pallet + `"Asset"`, extrinsic `"issue"`). +- Per-key checks: `SecondaryKey::has_extrinsic_permission` (:464), `has_asset_permission` (:474), + `has_portfolio_permission` (:483). + +### Validation limits (applied wherever permissions are accepted as input) + +`Identity::ensure_perms_length_limited` (pallets/identity/src/keys.rs:740): +- total complexity ≤ 1,000,000 (`MAX_PERMISSION_COMPLEXITY` keys.rs:53; complexity = + name lengths (min 10/name) + 16·assets + 40·portfolios, secondary_key.rs:248-260); +- asset/portfolio set sizes and pallet/extrinsic counts ≤ `MAX_ASSETS`/`MAX_PORTFOLIOS` (2000) + and `MAX_PALLETS`/`MAX_EXTRINSICS` (80) — primitives/src/identity.rs:43-54 (smaller under the + `running-ci` feature, :27-39); +- **`Except` is forbidden for extrinsic permissions** at both levels + (`ensure_no_except_perms` keys.rs:750, error `ExceptNotAllowedForExtrinsics`) because + extrinsic renames/additions would silently widen an `Except` grant. `Except` **is** allowed + for asset/portfolio subsets. + +Callers of this validation: `set_secondary_key_permissions` (keys.rs:393), +`add_secondary_keys_with_authorization` (keys.rs:487), auth creation for +`JoinIdentity`/`RotatePrimaryKeyToSecondary` (auth.rs:37-41), `base_register_did` +(claims.rs:226). Weights scale with permission counts (`permissions_cost_perms`, +pallets/identity/src/lib.rs:144). + +## 3. Layer 1 — recording call metadata (`pallets/permissions`) + +`StoreCallMetadata` transaction extension (pallets/permissions/src/lib.rs:156): +- `prepare()` stores the dispatched call's pallet/function names into `CurrentPalletName` + (lib.rs:110) and `CurrentDispatchableName` (lib.rs:115) using `GetCallMetadata` (lib.rs:222-233). +- `post_dispatch()` clears them (lib.rs:235-244). +- Wired into the runtime `TxExtension` tuple (pallets/runtime/common/src/runtime.rs:913), + after `ChargeTransactionPayment`, so fee logic runs before metadata is stored. + +**Nested calls**: wrappers that dispatch inner calls must swap metadata so permission checks see +the *inner* call: `with_call_metadata`/`swap_call_metadata` (lib.rs:251/263). Users: +- `Utility` batch/relay (pallets/utility/src/lib.rs:494,601) +- `MultiSig` proposal execution (pallets/multisig/src/lib.rs:1125) +- EVM precompiles dispatching runtime calls (pallets/precompiles/src/common.rs:102,227,262) + +A wrapper that forgets this lets a secondary key smuggle a forbidden call inside an allowed +wrapper — check this on any new dispatch-wrapping code. + +## 4. Layer 2 — the generic permission check + +Entry points (used by nearly every extrinsic in asset/settlement/portfolio/CA/etc.): + +| Entry point | Returns | Ref | +|---|---|---| +| `Identity::ensure_origin_call_permissions(origin)` | `PermissionedCallOriginData { sender, primary_did, secondary_key }` | keys.rs:719 | +| `Identity::ensure_perms(origin)` | just the DID | keys.rs:735 | +| `Identity::ensure_valid_origin(origin, must_be_primary_key)` | `(AccountId, IdentityId)`; optional primary-only mode | keys.rs:776 | +| `pallet_permissions::Pallet::ensure_call_permissions(who)` | `AccountCallPermissionsData` | permissions lib.rs:126 | + +All delegate to the `CheckAccountCallPermissions` trait (permissions lib.rs:76); the runtime binds +`type Checker = Identity` (runtime.rs:640). Identity's implementation +(`ensure_valid_origin_permissions`, keys.rs:824-862) resolves the caller key: + +| Caller `KeyRecord` | Result | +|---|---| +| none | `Err(MissingIdentity)` | +| `PrimaryKey(did)` | **pass unconditionally**; `secondary_key = None` | +| `SecondaryKey(did)`, DID frozen | `Err(UnauthorizedCallerFrozenDid)` (keys.rs:847) | +| `SecondaryKey(did)`, insufficient extrinsic perms for (`CurrentPalletName`, `CurrentDispatchableName`) | `Err(UnauthorizedCallerMissingPermissions)` (keys.rs:853) | +| `SecondaryKey(did)`, sufficient | pass; `secondary_key = Some(SecondaryKey)` | +| `MultiSigSignerKey(_)` | `Err(KeyNotAllowed)` — signers act *through* the multisig, doc 16 | +| `must_be_primary_key = true` and not primary | `Err(KeyNotAllowed)` (keys.rs:832-837) | + +`check_account_call_permissions` (keys.rs:807) additionally rejects locked DIDs +(`is_did_locked` — currently a stub returning false, claims.rs:70). + +**Convention**: `secondary_key == None` ⇒ caller is the primary key ⇒ skip layer-3 subset checks. +Every layer-3 helper follows this pattern (`if let Some(sk) = secondary_key { check... }`). + +## 5. Layer 3 — asset & portfolio subset checks (per-pallet duty) + +The generic check only covers *extrinsic* permissions. Pallets touching an asset or portfolio must +additionally check the secondary key's asset/portfolio subsets: + +- **Asset scope**: `ExternalAgents::ensure_asset_perms` (pallets/external-agents/src/lib.rs:639) + → `sk.has_asset_permission(asset_id)` (:649), error `SecondaryKeyNotAuthorizedForAsset`. + Usually invoked via `ensure_agent_asset_perms` (:628) which also applies the agent-group check + (layer 4, doc 05). Used by asset, compliance, statistics, corporate actions, sto, nft, etc. +- **Portfolio scope**: `Portfolio::ensure_portfolio_custody_and_permission` + (pallets/portfolio/src/lib.rs:814) = custody check + `ensure_user_portfolio_permission` + (:782) → `sk.has_portfolio_permission(portfolio_id)`, error `InsufficientPortfolioPermissions`. + Used by settlement affirmation, portfolio moves, sto investment, etc. (doc 08). + +**Review rule**: an extrinsic that operates on a caller-chosen asset/portfolio but only calls +`ensure_perms` (never a layer-3 helper) lets any secondary key with matching *extrinsic* +permissions act on *all* assets/portfolios of the identity. That is sometimes intentional +(e.g. pure-identity operations) but must be deliberate. + +## 6. Primary-key-only actions + +Enforced via `ensure_primary_key` (keys.rs:690) or `ensure_valid_origin(_, true)` — not +expressible through `Permissions`: + +| Action | Ref | +|---|---| +| `Identity::set_secondary_key_permissions` | keys.rs:388 | +| `Identity::remove_secondary_keys` | keys.rs:414 | +| `Identity::add_secondary_keys_with_authorization` | keys.rs:450 | +| `Identity::freeze_secondary_keys` / `unfreeze_secondary_keys` | keys.rs:574 | + +Also primary-key-relevant: primary key rotation targets (doc 01 §4) and multisig admin calls that +require the multisig's *creator/paying* identity primary key (doc 16). When adding a new +"identity administration" extrinsic, decide explicitly whether secondary keys may call it; default +to primary-only for anything that changes the key set or permissions (a secondary key must never +be able to escalate its own permissions). + +## 7. Freezing + +`freeze_secondary_keys` sets `IsDidFrozen` (keys.rs:570-583): all secondary keys are disabled at +once (layer-2 check keys.rs:847; also `get_identity` returns `None` for frozen secondary keys, +keys.rs:72). The primary key is unaffected and is the only key able to unfreeze. Freezing does +not cancel authorizations or unlink keys. + +## 8. What is *not* checked anymore + +- **CDD**: since 8.0.0 no transaction gate checks CDD claims; DID existence suffices + (doc 01 §3, doc 03 §4). +- **DID locking**: `is_did_locked` is a TODO stub (claims.rs:70); the + `UnauthorizedCallerDidInactive` error paths (keys.rs:697-700, 711-714, 816-819) are currently + unreachable. + +## 9. Invariants & review checklist + +- [ ] Every signed extrinsic resolves its origin through one of the §4 entry points (or + explicitly `ensure_did`/`ensure_primary_key` with justification). Raw `ensure_signed` + without identity resolution is suspect outside low-level pallets + (balances/indices/multisig signer calls/revive). +- [ ] Extrinsics operating on caller-chosen assets/portfolios apply layer-3 checks when + `secondary_key.is_some()`. +- [ ] New dispatch wrappers use `with_call_metadata` around inner-call dispatch. +- [ ] Permission inputs pass `ensure_perms_length_limited`; extrinsic perms reject `Except`. +- [ ] No path grants a secondary key the ability to modify its own or others' permissions. +- [ ] Pallet/extrinsic renames break existing `These`-permissions silently (grants reference + names, not indices) — renaming requires a migration or release note. +- [ ] `StoreCallMetadata` must remain positioned in `TxExtension` such that it runs for every + dispatchable path (runtime.rs:901-917); revive/EVM entry (`SetOrigin`, doc 21) and + off-chain-submitted calls need equivalent handling. + +## 10. Test map + +- `pallets/runtime/tests/src/identity_test.rs` (frozen keys, permission checks, + `secondary_keys_with_auth`), `signed_extra.rs` (extension ordering), + `utility_test.rs` (batch permission semantics), `multisig.rs` (nested call metadata), + `portfolio.rs` / `external_agents_test.rs` (layer-3 checks). +- Permission subset unit tests: primitives/src/secondary_key.rs:524 (`has_permission_test`). diff --git a/docs/spec/03-claims.md b/docs/spec/03-claims.md new file mode 100644 index 0000000000..eecbd24f8f --- /dev/null +++ b/docs/spec/03-claims.md @@ -0,0 +1,123 @@ +# 03 — Identity Claims + +Sources: `pallets/identity/src/claims.rs`, `pallets/identity/src/lib.rs`, +`primitives/src/identity_claim.rs`, `primitives/src/cdd_id.rs`, `primitives/src/constants.rs`. +Related specs: [01-identity-keys](01-identity-keys.md), [06-compliance](06-compliance.md) (main +consumer), [07-statistics](07-statistics.md) (claim-scoped stats). + +## 1. Purpose + +Claims are attestations attached to a target DID by an issuer DID ("issuer says X about target"). +They are the raw material for asset **compliance rules** (doc 06) and claim-scoped **transfer +statistics** (doc 07). Any identity can issue claims; *which* issuers matter is decided by each +asset's trusted-issuer configuration (doc 06), not by the identity pallet — with the exception of +CDD claims, which only DID registrars may issue. + +## 2. Data model + +### Claim variants (primitives/src/identity_claim.rs:79) + +| Claim | Payload | ClaimType | +|---|---|---| +| `Accredited(Scope)` | scope | `Accredited` | +| `Affiliate(Scope)` | scope | `Affiliate` | +| `BuyLockup(Scope)` / `SellLockup(Scope)` | scope; lockup end = claim expiry | `BuyLockup`/`SellLockup` | +| `CustomerDueDiligence(CddId)` | `CddId` = opaque 32 bytes (primitives/src/cdd_id.rs:10); no scope | `CustomerDueDiligence` | +| `KnowYourCustomer(Scope)` | scope | `KnowYourCustomer` | +| `Jurisdiction(CountryCode, Scope)` | ISO country (primitives/src/jurisdiction.rs) + scope | `Jurisdiction` | +| `Exempted(Scope)` / `Blocked(Scope)` | scope | `Exempted`/`Blocked` | +| `Custom(CustomClaimTypeId, Option)` | registered custom type id | `Custom(id)` | + +`Scope` (identity_claim.rs:37): `Identity(IdentityId) | Asset(AssetId) | Custom(Vec)`. +Custom scopes are capped at 32 bytes (`ensure_custom_scopes_limited`, +pallets/identity/src/claims.rs:36). + +`IdentityClaim` (identity_claim.rs:171) stores `{ claim_issuer, issuance_date, +last_update_date, expiry: Option, claim }`. + +### Storage (pallets/identity/src/lib.rs) + +| Item | Key → Value | Ref | +|---|---|---| +| `Claims` | `Claim1stKey { target, claim_type }` → `Claim2ndKey { issuer, scope }` → `IdentityClaim` | lib.rs:389; key types pallets/identity/src/types.rs:80/86 | +| `CustomClaims` / `CustomClaimsInverse` | id ↔ name for custom claim types | lib.rs:402/408 | +| `CustomClaimIdSequence` | next `CustomClaimTypeId` | lib.rs:413 | + +**Uniqueness**: one claim per `(target, claim_type, issuer, scope)`. Re-adding **upserts**: +`issuance_date` is preserved from the existing claim, `last_update_date`/`expiry` refresh +(claims.rs:116-140). There is at most one `Jurisdiction` claim per issuer+scope — adding a new +country replaces the previous one (same claim_type key). + +## 3. Claim lifecycle + +| Extrinsic (call_index) | Who may call | Behavior | Ref | +|---|---|---|---| +| `add_claim(6)` | any permissioned key of issuer DID; target DID must exist | upsert claim; CDD variant → registrar check; protocol fee `IdentityAddClaim` for non-CDD | lib.rs:643 → claims.rs:98/158 | +| `revoke_claim(7)` | issuer (permissioned key) | delete claim by `(target, claim_type, issuer, scope from claim)` | lib.rs:666 → claims.rs:170 | +| `revoke_claim_by_index(14)` | issuer (permissioned key) | same, scope passed explicitly (needed when scope unknown from claim value) | lib.rs:745 | +| `gc_add_cdd_claim(12)` / `gc_revoke_cdd_claim(13)` | `GCVotingMajorityOrigin` (committee) | add/remove a systematic CDD claim issued by `SystematicIssuers::Committee` | lib.rs:725/734 | +| `register_custom_claim_type(19)` | any permissioned identity | registers name→id (unique, length-limited) | lib.rs:838 → claims.rs:259 | + +Notes: +- Claim issuance/revocation authorization is **only** "caller has extrinsic permission on + `Identity::add_claim` for the issuer DID". Nothing restricts *which* claim types an identity may + issue (except CDD). Consumers filter by trusted issuers. +- Expiry: claims are not deleted on expiry; readers filter — `fetch_claim` (claims.rs:46) returns + only claims with `expiry > now`. `BuyLockup`/`SellLockup` invert this meaning (lockup active + until expiry) — interpretation is up to the consumer (compliance conditions). +- Revocation of a *CDD* claim is `Operational` dispatch class (`revoke_claim_class`, + lib.rs:1053). +- `add_claim` fails with `DidMustAlreadyExist` if the target doesn't exist + (`ensure_signed_and_validate_claim_target`, claims.rs:185). + +## 4. CDD claims after 8.0.0 + +Historically CDD (Customer Due Diligence) claims gated all transactions. Since 8.0.0: + +- **No transaction path checks CDD**. Onboarding = DID existence (`is_did_active`, + claims.rs:64). `cdd_register_did*` are deprecated (lib.rs:591-594, 851-854); + `register_did`/`self_register_did` create no claim. +- CDD claims still exist as data: only DID-registrar identities may issue them + (`base_add_cdd_claim` → `ensure_authorized_did_registrar`, claims.rs:158-167,198 — membership + in `pallet_group` Instance2, "DidRegistrars", runtime index 8), and the GC can force-add/revoke + them (lib.rs:725/734). Compliance rules may still *reference* them like any claim. +- **Systematic CDD claims**: group membership changes automatically maintain CDD claims for + committee members / DID registrars via `ChangeMembers`/`InitializeMembers` hooks + (lib.rs:1029-1049 → claims.rs:240/248), issued by `SystematicIssuers::CDDProvider` or + `::Committee`. +- `CddId` is now effectively opaque; systematic/genesis claims use `CddId::default()` + (all zeros, claims.rs:242). + +### Systematic identities (primitives/src/constants.rs:109) + +Chain-maintained identities with no known private key: `Committee` (= `GC_DID`, +constants.rs:177), `CDDProvider`, `Treasury`, `BlockRewardReserve`, `Settlement`, +`ClassicMigration`, `FiatTickersReservation`. Registered at genesis +(pallets/identity/src/lib.rs:518-521, keys.rs:651). GC-issued claims/authorizations use `GC_DID` +as issuer. + +## 5. Consumers of claims + +| Consumer | How | Ref | +|---|---|---| +| Compliance manager | `fetch_claims` per condition against per-asset trusted issuers; proposition evaluation over `Context { claims }` | pallets/compliance-manager/src/lib.rs:621-693; primitives/src/proposition/mod.rs:24 (doc 06) | +| Statistics | claim-scoped stat buckets & transfer conditions (`fetch_claim_as_key`) | pallets/statistics/src/lib.rs:584-606 (doc 07) | +| Corporate actions | CA target lists don't use claims, but distributions respect asset compliance (doc 12) | — | + +## 6. Invariants & review checklist + +- [ ] Non-registrar identities must never be able to create `CustomerDueDiligence` claims + (`add_claim` special-case, lib.rs:651-654) — check any new claim-writing path. +- [ ] Claim reads for enforcement must filter expiry (`fetch_claim`, claims.rs:56) — direct + `Claims::get` without expiry filtering is a bug for enforcement purposes. +- [ ] `Custom` claims must verify the type id exists (`base_add_claim`, claims.rs:105-110). +- [ ] Upsert semantics: verify consumers don't assume `issuance_date` = last write. +- [ ] Custom scope length ≤ 32 enforced on add (claims.rs:36-41). +- [ ] Claims are unbounded storage (`#[pallet::unbounded]`, lib.rs:388) — new claim payloads + must stay length-limited. + +## 7. Test map + +- `pallets/runtime/tests/src/identity_test.rs`: claim add/revoke/expiry, custom claim types, + GC CDD claims, `revoke_claim_by_index`. +- Compliance-side consumption: `compliance_manager_test.rs`, `transfer_compliance_test.rs`. diff --git a/docs/spec/04-asset-lifecycle.md b/docs/spec/04-asset-lifecycle.md new file mode 100644 index 0000000000..2e5a51b228 --- /dev/null +++ b/docs/spec/04-asset-lifecycle.md @@ -0,0 +1,236 @@ +# 04 — Asset Lifecycle (Fungible & NFT) + +Sources: `pallets/asset/src/{lib.rs,types.rs}`, `pallets/nft/src/lib.rs`, +`primitives/src/asset.rs`, `primitives/src/nft.rs`, `primitives/src/asset_metadata.rs`. +Related specs: [05-external-agents](05-external-agents.md) (who may administer), +[09-asset-transfers](09-asset-transfers.md) (transfer paths), [06-compliance](06-compliance.md), +[07-statistics](07-statistics.md), [11-checkpoints](11-checkpoints.md). + +"Agent" below = caller passing `ExternalAgents::ensure_perms` (permissioned external agent of the +asset — the owner is the initial `Full` agent; doc 05). "Permissioned DID" = caller passing the +generic identity pipeline (doc 02) with no asset-role requirement. + +## 1. Data model + +### Fungible assets + +| Type | Shape | Ref | +|---|---|---| +| `AssetId` | `[u8;16]`, formatted as UUIDv8 | primitives/src/asset.rs:33, 35-43 | +| `AssetDetails` | `{ total_supply, owner_did, divisible, asset_type }` | pallets/asset/src/types.rs:19-28 | +| `AssetType` | Equity/Commodity/.../`Custom(CustomAssetTypeId)`/StableCoin/`NonFungible(NonFungibleType)` | primitives/src/asset.rs:93-131 | +| `TickerRegistration` | `{ owner, expiry: Option }` | types.rs:59-62 | +| `AssetHolder` | `Portfolio(PortfolioId) \| Account(AccountId32)` — assets can be held by portfolios **or** raw account keys | primitives/src/asset.rs:194-199 | + +**AssetId generation** (`generate_asset_id`, pallets/asset/src/lib.rs:3646-3660): deterministic +`blake2_128(("modlpy/pallet_asset", genesis_hash, caller_account, AssetNonce[caller]))`, +nonce post-incremented, then UUIDv8-normalized. Collision guard `ensure_new_asset_id` +(lib.rs:3632-3638). + +### Asset storage highlights (pallets/asset/src/lib.rs) + +| Item | Purpose | Ref | +|---|---|---| +| `Assets` | AssetId → `AssetDetails` | :389 | +| `BalanceOf` | (AssetId, DID) → aggregate per-identity balance | :402 | +| `AssetBalance` / `LockedBalance` / `FrozenBalance` / `FrozenAccounts` | per-**account-key** holdings, locks, frozen amounts, frozen flag | :602/:614/:645/:657 | +| `Frozen` | AssetId → bool (asset-wide freeze) | :443 | +| `Allowances` | (owner, spender, AssetId) → Balance; ERC-20 style (doc 09) | :632 | +| `UniqueTickerRegistration` / `TickerConfig` | ticker ownership + expiry / max len & duration | :379/:384 | +| `TickerAssetId` / `AssetIdTicker` | 1:1 ticker↔asset link | :593/:588 | +| `AssetDocuments`(+`IdSequence`) | attached documents | :447/:460 | +| metadata maps (local/global names, specs, values, details) | see §6 | :465-575 | +| `AssetsExemptFromAffirmation` / `PreApprovedAsset` | receiver-affirmation exemptions (doc 09 §5) | :547/:552 | +| `MandatoryMediators` | AssetId → bounded set of required mediator DIDs (doc 10) | :558 | +| `SecurityTokensOwnedByUser` / `TickersOwnedByUser` | owner indexes | :583/:578 | +| `AssetNonce` | per-account nonce for id generation | :598 | + +Portfolio-held balances live in the portfolio pallet (doc 08); `BalanceOf` is the DID-level sum +used by compliance/statistics/checkpoints. + +### NFTs (pallets/nft/src/lib.rs) + +| Item | Purpose | Ref | +|---|---|---| +| `Collection` / `CollectionAsset` | NFTCollectionId → `NFTCollection { id, asset_id }`; asset → collection | :103/:98; primitives/src/nft.rs:31 | +| `CollectionKeys` | collection → mandatory `AssetMetadataKey` set | :108 | +| `MetadataValue` | ((collection, NFTId), key) → value | :114 | +| `NumberOfNFTs` / `NFTsInCollection` | per-DID count / total supply | :93/:127 | +| `NFTHolder` / `Owner` | account-key-held NFTs (`NFTOwnerStatus`: Owner/OwnerLocked) / reverse owner lookup | :141/:154 | +| `CurrentNFTId` / `CurrentCollectionId` | id sequences (start at 1) | :132/:137 | + +## 2. Ticker system + +- Validation: chars `A-Z 0-9 _ - . /`, ≤ `TickerConfig.max_ticker_length` (12 at genesis, + `src/chain_spec/common.rs:128-131`), 60-day registration at genesis + (`verify_ticker_characters` lib.rs:3088-3111; length lib.rs:3123-3129). +- `register_unique_ticker` (lib.rs:736 → 2033): any permissioned DID. Re-registration matrix + `can_reregister_ticker` (lib.rs:3132-3161): free renewal of own live ticker; fee-charged + takeover of unregistered/expired (`AssetRegisterTicker` fee, lib.rs:4033-4035); denial of + another's live ticker. +- Transfer: `TransferTicker` authorization → `accept_ticker_transfer` (lib.rs:757 → 2057); + issuer must still own the ticker at acceptance (lib.rs:2067); linked tickers can't transfer + (lib.rs:2062). +- Link to asset: `link_ticker_to_asset_id` (lib.rs:1610 → 2814) — caller must be *agent AND + ticker owner*; **linking clears expiry to `None`** (permanent, lib.rs:2837); 1:1 both ways + (lib.rs:2845-2850). `unlink_ticker_from_asset_id` (lib.rs:1640 → 2858) **deletes the ticker + registration entirely** (`take`, lib.rs:2867). + +## 3. Fungible lifecycle + +### Create (`create_asset`, lib.rs:810 → `validate_and_create_asset` 3580) + +1. Generate AssetId (§1); validate name ≤ `AssetNameMaxLength` (128), funding-round name ≤ 128, + custom type exists, identifiers valid (lib.rs:3164-3226). +2. Charge `AssetCreateAsset` protocol fee (lib.rs:4071). (Doc comment at lib.rs:4061 claiming two + fees is stale — tickers are decoupled from creation.) +3. Insert `AssetDetails { total_supply: 0, owner_did: caller, divisible, asset_type }` + (lib.rs:4073); **owner becomes `AgentGroup::Full` agent** (lib.rs:4097 → + `unchecked_add_agent`). +- `create_asset_with_custom_type` (lib.rs:1195) registers the custom type first; + `register_custom_asset_type` (lib.rs:1159) is idempotent (lib.rs:4249-4274). +- Divisibility: chosen at creation; indivisible assets require amounts in whole multiples of + `ONE_UNIT = 1_000_000` (`ensure_asset_granular` lib.rs:3281-3286; + primitives/src/constants.rs:29). One-way upgrade via `make_divisible` (lib.rs:994, agent). +- Ownership transfer: `TransferAssetOwnership` auth → `accept_asset_ownership_transfer` + (lib.rs:778 → 2075): auth issuer must be permissioned agent at acceptance (lib.rs:2092); + linked ticker registration moves too (lib.rs:2095-2099); new owner added as Full agent, old + owner removed as agent (lib.rs:2107-2108). + +### Issue / mint (`issue`, lib.rs:926 → `base_issue` 2190, `unverified_issue_tokens` 4113) + +- Caller: agent + holding-destination permission (`ensure_asset_and_holding_permissions` + lib.rs:3234-3272) — portfolio validity + secondary-key portfolio perms; **custody NOT + required** for issuance destination (lib.rs:2197). +- Rules (lib.rs:3612-3629): fungible only, granularity, `total_supply + amount ≤ MAX_SUPPLY` + (=10¹² × ONE_UNIT, constants.rs:30). +- Effects, in order: `AssetIssue` fee (lib.rs:4122); **checkpoint pre-update with pre-change + balance** (`Checkpoint::advance_update_balances`, lib.rs:4127-4131, doc 11); `BalanceOf` +=, + `total_supply` += (lib.rs:4133-4137); holder balance += (portfolio pallet or `AssetBalance`, + lib.rs:4140-4143); **statistics update** (from=None, lib.rs:4145-4153, doc 07); + `IssuedInFundingRound` += (lib.rs:4155). **No compliance check on issuance.** + +### Redeem / burn (`redeem`, lib.rs:958 → `base_redeem` 2226) + +- Caller: agent + holding permission **with custody** over the source (lib.rs:2233-2234 — + asymmetric with issue, which doesn't require custody). +- Checks: fungible, sufficient *available* balance (net of locked+frozen, + `ensure_sufficient_balance` lib.rs:3838-3867). +- Effects: checkpoint pre-update (lib.rs:2252); supply/balances −= (lib.rs:2244-2260); + statistics update (to=None, lib.rs:2264-2272). No protocol fee, **no compliance check**. + +### Freeze layers (three distinct mechanisms) + +| Layer | Set by | Blocks | Ref | +|---|---|---|---| +| `Frozen` (asset-wide) | agent `freeze`/`unfreeze` (lib.rs:848/871) | all settlement transfers (`ensure_asset_is_not_frozen` lib.rs:3434, checked lib.rs:3406); **not** issue/redeem; controller transfers exempt | :443 | +| `FrozenBalance` (amount per holder) | agent `set_frozen_tokens` (lib.rs:1840 → 4431-4460) | reduces available balance: available = balance − locked − frozen (lib.rs:3853-3858); controller transfers bypass and *reduce* it (lib.rs:4207-4218) | :645 | +| `FrozenAccounts` (bool per holder) | agent `set_holder_frozen` (lib.rs:1852 → 3026) | holder as **sender** only (`ensure_holder_is_not_frozen` lib.rs:3443-3459) | :657 | + +### Documents & funding rounds + +- `add_documents` (lib.rs:1017, agent, `AssetAddDocuments` fee per doc lib.rs:2324) / + `remove_documents` (lib.rs:1041, agent). Sequenced `DocumentId` (lib.rs:2317-2321). +- `set_funding_round` (lib.rs:1068, agent); `rename_asset` (lib.rs:895, agent); + `update_identifiers` (lib.rs:1095, agent); `update_asset_type` (lib.rs:1397, agent — + cannot cross fungible↔non-fungible, lib.rs:2571-2574). + +## 4. NFT lifecycle (pallets/nft/src/lib.rs) + +### Collection creation (`create_nft_collection`, :199 → base :383) + +- Existing asset: must exist, be `AssetType::NonFungible`, caller agent (:392-402). **Not + auto-created in this branch.** With `asset_id = None`: auto-creates the asset (type must be + `NonFungible(nft_type)`, :407-452; `AssetCreateAsset` fee via asset pallet). +- Collection keys = mandatory metadata attributes for every NFT: ≤ `MaxNumberOfCollectionKeys` + (u8::MAX in all runtimes, e.g. `pallets/runtime/develop/src/runtime.rs:152`), deduped, each key + must be a registered metadata type (:419-439). +- `NFTCreateCollection`/`NFTMint` protocol ops exist (primitives/src/protocol_fee.rs:53,55) but + are **never charged** by the NFT pallet. + +### Mint (`issue_nft`, :227 → base :469) + +Caller: agent + holding perms (custody not required, :484). Metadata attributes must exactly +match the collection keys (count :489, dedup :495, membership :505). Supply/balance overflow +guards (:513-518). Holder placement: portfolio (via portfolio pallet) or account key +(`NFTHolder`, + `AccountKeyRefCount` strong ref on first NFT, :1004-1008). +**No compliance/statistics on mint.** + +### Burn (`redeem_nft`, :258 → base :540) + +Caller: agent + holding perms **with custody** (:556). NFT must be held and not locked +(:560-567). Metadata drained (:581); account-key strong ref removed when holdings empty +(:1026-1030). + +### NFT transfers — see doc 09 §6 (validation `validate_nft_transfer` :631; **no statistics for +NFTs**, compliance checked :688; per-leg cap `MaxNumberOfNFTsCount` = 10). + +## 5. Controller transfer (forced transfer) + +- Fungible: `controller_transfer` (lib.rs:1127 → 2375). Caller: agent; destination = caller's + chosen portfolio/account (perms lib.rs:2383-2384). `validate_asset_transfer(..., + is_controller_transfer=true)` checks fungibility/balances/holdings/locks then **early-returns + before frozen/receiver-active/statistics/compliance checks** (lib.rs:3401-3404). Also bypasses + holder-frozen and frozen-balance limits, decrementing `FrozenBalance` if needed + (lib.rs:3849-3858, 4207-4218). Statistics *updates* still applied (lib.rs:4224-4232). +- NFT: `controller_transfer` (nft :281 → 799). Ownership/limit checks still apply; compliance and + frozen checks skipped (:671-674). +- Purpose: regulatory forced recovery — powerful; watch that only agent-group-permissioned + callers reach it (`PolymeshV1PIA` group includes it; doc 05 §2). + +## 6. Asset metadata + +- **Global** types: root-only registration/spec-update (`register_asset_metadata_global_type` + lib.rs:1365 origin-checked at 1371; `update_global_metadata_spec` lib.rs:1665 → 2894). + **Local** types: agent (`register_asset_metadata_local_type` lib.rs:1338; combined + register+set lib.rs:1303). +- Values: `set_asset_metadata` (lib.rs:1237, agent) — key must exist, value ≤ 8 KiB + (`AssetMetadataValueMaxLength`, runtime.rs), not locked (lib.rs:4287-4296). +- Locking: `AssetMetadataValueDetail { expire, lock_status: Unlocked|Locked|LockedUntil(t) }` + (primitives/src/asset_metadata.rs:74-79, 99-117); set via `set_asset_metadata_details` + (lib.rs:1269); locking an empty value is forbidden (lib.rs:2492-2495). +- Removal: `remove_local_metadata_key` (lib.rs:1426) — refused if value locked (lib.rs:2594) or + key is an NFT collection key (lib.rs:2601-2604); `remove_metadata_value` (lib.rs:1454) — + refused if locked (lib.rs:2638). + +## 7. Extrinsic authorization summary + +Agent-gated (via `ensure_agent_asset_perms`): freeze/unfreeze, rename, issue, redeem, +make_divisible, documents add/remove, funding round, identifiers, controller_transfer, metadata +set/register-local/details/remove, update_asset_type, mediators add/remove, ticker link/unlink +(+ owner), set_frozen_tokens, set_holder_frozen, checkpoint create (doc 11), compliance config +(doc 06), statistics config (doc 07), NFT collection-on-existing-asset/mint/burn/controller. + +Permissionless (any permissioned DID): register_unique_ticker, create_asset(+custom type), +register_custom_asset_type, pre_approve_asset / remove_asset_pre_approval, approve (allowance), +transfer_asset / receiver_affirm_asset_transfer / reject_asset_transfer (doc 09). + +Root-only: register_asset_metadata_global_type, update_global_metadata_spec, +exempt_asset_affirmation / remove_asset_affirmation_exemption (lib.rs:2660/2671). + +Auth-accepting: accept_ticker_transfer, accept_asset_ownership_transfer. + +## 8. Invariants & review checklist + +- [ ] Every `BalanceOf` mutation must be preceded by `Checkpoint::advance_update_balances` with + **pre-change** balances (issue lib.rs:4127, redeem lib.rs:2252, transfer lib.rs:4193) and + followed by `Statistics::update_asset_stats` — new balance-mutating paths must do both. +- [ ] Account-key holdings must maintain `AccountKeyRefCount` (0↔nonzero transitions, + lib.rs:3673-3695; NFT :1004/:1026) or keys could be unlinked while holding assets. +- [ ] `total_supply` changes only in issue/redeem; must stay ≤ `MAX_SUPPLY` and consistent with + Σ balances. +- [ ] Fungibility boundary: fungible entry points reject NFT assets and vice versa + (lib.rs:3616-3619, 2237-2240; nft collection checks) — check any new entry point. +- [ ] Controller-transfer skip-list (frozen/compliance/stats) must not leak into normal paths — + the `is_controller_transfer` flag gates it (lib.rs:3401). +- [ ] Ticker link/unlink keeps `TickerAssetId`/`AssetIdTicker` in 1:1 sync. +- [ ] NFT collection keys are immutable-in-practice (removal blocked lib.rs:2601) — metadata + integrity of issued NFTs depends on it. + +## 9. Test map + +`pallets/runtime/tests/src/asset_pallet/*` (setup, register_ticker, accept_ticker_transfer, +link/unlink_ticker, asset_ownership_transfer, issue, controller_transfer, allowances, +base_transfer, asset_transfer, register_metadata), `asset_test.rs` (incl. checkpoint fuzz :354), +`asset_metadata_test.rs`, `nft.rs` (collection/mint/burn/transfer/controller), +`external_agents_test.rs`. diff --git a/docs/spec/05-external-agents.md b/docs/spec/05-external-agents.md new file mode 100644 index 0000000000..47b4275a7e --- /dev/null +++ b/docs/spec/05-external-agents.md @@ -0,0 +1,107 @@ +# 05 — External Agents (Asset Administration Permissions) + +Sources: `pallets/external-agents/src/lib.rs`, `primitives/src/agent.rs`. +Related specs: [02-permissions](02-permissions.md) (layer-4 of the permission pipeline), +[04-asset-lifecycle](04-asset-lifecycle.md), [01-identity-keys](01-identity-keys.md) §5 +(authorizations). + +## 1. Purpose + +Asset administration (mint, burn, freeze, compliance config, CAs, ...) is performed by +**external agents** of the asset. Each agent belongs to exactly one **agent group** per asset, +which resolves to a set of permitted pallets/extrinsics. The asset owner is just the initial +`Full` agent (doc 04 §3) — ownership itself confers no extra dispatch rights beyond the agent +system (ownership matters for ticker linking and receiving the owner role on transfer). + +## 2. Data model + +| Item | Shape | Ref | +|---|---|---| +| `GroupOfAgent` | (AssetId, DID) → `AgentGroup` | pallets/external-agents/src/lib.rs:120 | +| `AgentOf` | (DID, AssetId) → () reverse index | :108 | +| `GroupPermissions` | (AssetId, AGId) → `ExtrinsicPermissions` (custom groups) | :129 | +| `AGIdSequence` | AssetId → AGId (starts at 1) | :101 | +| `NumFullAgents` | AssetId → u32 | :125 | +| `AgentGroup` | `Full \| Custom(AGId) \| ExceptMeta \| PolymeshV1CAA \| PolymeshV1PIA` | primitives/src/agent.rs:15-31 | + +### Group → permission resolution (`agent_permissions`, :669-702) + +| Group | Resolves to | +|---|---| +| not an agent | `ExtrinsicPermissions::empty()` (:674) | +| `Full` | everything (:675) | +| `Custom(id)` | `GroupPermissions[asset, id]` or empty (:676) | +| `ExceptMeta` | everything **except the `ExternalAgents` pallet** (can't manage agents) (:679-681) | +| `PolymeshV1CAA` | only `CorporateAction`, `CorporateBallot`, `CapitalDistribution` pallets (:683-687) | +| `PolymeshV1PIA` | `Sto` except `invest` + `Asset::{issue, redeem, controller_transfer}` (:688-700) | + +Permissions are `ExtrinsicPermissions` — same type as secondary-key extrinsic perms (doc 02 §2); +`Except` variant rejected for custom groups (`ExceptPermissionsNotAllowed`, :437-439). + +## 3. Enforcement entry points (consumed by asset-scope pallets) + +| Fn | Semantics | Ref | +|---|---|---| +| `ensure_asset_perms(origin, asset)` | identity pipeline + secondary-key **asset subset** check (`SecondaryKeyNotAuthorizedForAsset`). No agent check. | :637-655 | +| `ensure_agent_permissioned(asset, did)` | group permissions `sufficient_for(CurrentPalletName, CurrentDispatchableName)` else `UnauthorizedAgent`. Applies to **primary keys too**. | :657-667 | +| `ensure_agent_asset_perms(origin, asset)` | both of the above | :627-635 | +| `ensure_perms(origin, asset)` | `ensure_agent_asset_perms` → DID | :619-625 | + +The group check reads the call metadata recorded by `StoreCallMetadata` (doc 02 §3), so nested +dispatches must swap metadata for agent checks to see the inner call. + +Callers: asset (~29 sites), compliance-manager, statistics, checkpoint, corporate-actions, +ballot, distribution, sto, nft, settlement (venue-filter admin). + +## 4. Agent management extrinsics + +| Extrinsic (call_index) | Who may call | Behavior | Ref | +|---|---|---|---| +| `create_group(0)` | agent | new custom group; AGId from sequence; perms length-limited (:443), no `Except` | :225 → :406 | +| `set_group_permissions(1)` | agent | overwrite custom group perms; `NoSuchAG` if id invalid (:549-555) | :250 → :467 | +| `remove_agent(2)` | agent | remove target agent; last-Full guard | :275 → :490 | +| `abdicate(3)` | the agent itself (only `ensure_asset_perms` — **no group check**, :502) | remove self; last-Full guard | :296 → :501 | +| `change_group(4)` | agent | move target to another group; custom group must exist (:541-546) | :321 → :518 | +| `accept_become_agent(5)` | auth target | consume `BecomeAgent(asset, group)`; **issuer must be a permissioned agent at acceptance time** (:394); group must exist (:395); `AlreadyAnAgent` guard (:396) | :348 → :389 | +| `create_group_and_add_auth(6)` | agent | create group + issue `BecomeAgent` auth (optional expiry) | :359 → :450 | +| `create_and_change_custom_group(7)` | agent | create group + move existing agent into it atomically | :376 → :508 | + +No protocol fees anywhere in this pallet; no cap on the number of agents (config has only +`WeightInfo`, :93-96). + +## 5. Full-agent liveness protection + +`try_mutate_agents_group` (:557-585) adjusts `NumFullAgents` on promote/demote; +`dec_full_count` (:604-612) uses `checked_sub(1).filter(|&x| x > 0)` → error +`RemovingLastFullAgent`: **an asset can never reach zero Full agents** via remove_agent / +abdicate / change_group. Ownership transfer swaps Full agents (add new then remove old — asset +lib.rs:2107-2108) preserving the invariant. + +## 6. Becoming an agent + +`BecomeAgent(asset_id, group)` authorizations are created via the generic +`Identity::add_authorization` or `create_group_and_add_auth`. Issuer competence is checked at +**acceptance**, not issuance (:394) — a stale auth from a since-removed agent is unusable. +Acceptance requires the target to pass the identity pipeline (:390). +Initial agent: `unchecked_add_agent` (:587-601) — called by asset creation (asset lib.rs:4097), +ownership transfer, and benchmarking/tests. + +## 7. Invariants & review checklist + +- [ ] Every asset-admin extrinsic in any pallet must call `ensure_agent_asset_perms` (or + `ensure_perms`), not just `ensure_asset_perms` — the latter skips the group check. +- [ ] `NumFullAgents ≥ 1` for every asset with agents; all group mutations must go through + `try_mutate_agents_group`. +- [ ] Custom group ids validated against `AGIdSequence` (`ensure_agent_group_valid`, :541-546) + wherever accepted as input. +- [ ] `ExceptMeta` must keep excluding the `ExternalAgents` pallet, else privilege escalation + (agent adds/removes agents). +- [ ] Wrappers dispatching inner calls must swap call metadata or agent checks evaluate the + wrong extrinsic name (doc 02 §3). +- [ ] `GroupPermissions` are per-asset: check new code doesn't read another asset's group id. + +## 8. Test map + +`pallets/runtime/tests/src/external_agents_test.rs` (group create/set-perms, remove/abdicate/ +change, multi-group perms :382, Except rejection :427-444), `asset_test.rs` (agent setup), +`corporate_actions_test.rs` (CAA group usage). diff --git a/docs/spec/06-compliance.md b/docs/spec/06-compliance.md new file mode 100644 index 0000000000..11bf3f8f29 --- /dev/null +++ b/docs/spec/06-compliance.md @@ -0,0 +1,114 @@ +# 06 — Asset Compliance + +Sources: `pallets/compliance-manager/src/lib.rs`, `primitives/src/compliance_manager.rs`, +`primitives/src/condition.rs`, `primitives/src/proposition/{mod.rs,base.rs}`. +Related specs: [03-claims](03-claims.md) (the evaluated data), [05-external-agents](05-external-agents.md) +(who configures), [09-asset-transfers](09-asset-transfers.md) (when evaluated). + +## 1. Purpose + +Per-asset transfer rules over **claims**: a transfer passes if *any* configured requirement is +satisfied, where a requirement = conditions on the **sender** identity AND conditions on the +**receiver** identity. Configured by asset agents; evaluated on every cross-identity transfer of +the asset (fungible and NFT). + +## 2. Data model + +| Type | Shape | Ref | +|---|---|---| +| `AssetCompliance` | `{ paused: bool, requirements: Vec }` | primitives/src/compliance_manager.rs:116-121 | +| `ComplianceRequirement` | `{ sender_conditions, receiver_conditions, id: u32 }` | compliance_manager.rs:28-35 | +| `Condition` | `{ condition_type, issuers: Vec }` | primitives/src/condition.rs:131-136 | +| `ConditionType` | `IsPresent(Claim) \| IsAbsent(Claim) \| IsAnyOf(Vec) \| IsNoneOf(Vec) \| IsIdentity(TargetIdentity)` | condition.rs:41-52 | +| `TargetIdentity` | `ExternalAgent \| Specific(IdentityId)` | condition.rs:30-35 | +| `TrustedIssuer` | `{ issuer, trusted_for: Any \| Specific(Vec) }` | condition.rs:79-85, 69-74 | + +Storage: `AssetCompliances` (AssetId → `AssetCompliance`, lib.rs:217), +`TrustedClaimIssuer` (AssetId → default `Vec`, lib.rs:223). + +## 3. Configuration extrinsics (all agent-gated via `ExternalAgents::ensure_perms`) + +| Extrinsic (call_index) | Behavior | Ref | +|---|---|---| +| `add_compliance_requirement(0)` | append with id = latest+1 (:551); dedup conditions; complexity check; **protocol fee `ComplianceManagerAddComplianceRequirement`** (:567) | :283 → :540 | +| `remove_compliance_requirement(1)` | remove by id; `InvalidComplianceRequirementId` (:321) | :309 | +| `replace_asset_compliance(2)` | replace all; sorted/dedup by id, `DuplicateComplianceRequirements` (:363-370); complexity | :350 | +| `reset_asset_compliance(3)` | remove entry entirely (**also clears `paused`**) (:404) | :402 | +| `pause_asset_compliance(4)` / `resume_asset_compliance(5)` | toggle `paused` (:746-754) | :419/:435 | +| `add_default_trusted_claim_issuer(6)` | append; issuer DID must exist (:583); dup ⇒ `IncorrectOperationOnTrustedIssuer` (:597); complexity re-check (:603) | :452 → :578 | +| `remove_default_trusted_claim_issuer(7)` | remove; absent ⇒ same error (:481) | :472 | +| `change_compliance_requirement(8)` | replace one by id (:518) | :504 | + +**Complexity cap** (the only size limit): Σ over all conditions of +`claims_count × max(issuers, default_issuer_count)` ≤ `MaxConditionComplexity` = **50** in all +runtimes (`base_verify_compliance_complexity` lib.rs:780-798; e.g. +`pallets/runtime/develop/src/runtime.rs:132`); exceeding ⇒ `ComplianceRequirementTooComplex`. + +## 4. Evaluation (`ComplianceFnConfig` impl, lib.rs:869-940) + +`is_compliant(asset, sender_did, receiver_did)` (lib.rs:870-890): + +1. **Paused ⇒ pass. Zero requirements ⇒ pass** (lib.rs:878-881). A new asset with no compliance + configured is freely transferable (subject to statistics, doc 07). +2. Else `is_any_requirement_compliant` (lib.rs:841-866): **OR over requirements**; each + requirement passes iff **ALL** `sender_conditions` hold for the sender **AND ALL** + `receiver_conditions` hold for the receiver (lib.rs:850-860, AND helper :717-730). + +Condition evaluation (`is_condition_satisfied` lib.rs:733-743 → `proposition::run` +primitives/src/proposition/mod.rs:107-122): + +| ConditionType | Semantics | +|---|---| +| `IsPresent(c)` | a trusted issuer has issued a matching, unexpired claim `c` | +| `IsAbsent(c)` | negation of IsPresent | +| `IsAnyOf(cs)` / `IsNoneOf(cs)` | membership / non-membership over the fetched claim set | +| `IsIdentity(Specific(did))` | evaluated identity == did (primitives/src/proposition/base.rs:17-22) | +| `IsIdentity(ExternalAgent)` | evaluated identity is **any agent of the asset** (`GroupOfAgent` lookup, lib.rs:741) | + +- **Trusted issuers**: per-condition `issuers` if non-empty, else the asset's default + `TrustedClaimIssuer` list (`issuers_for` lib.rs:641-651). An issuer counts only if + `trusted_for` covers the claim type (condition.rs:100-105). +- **Claim fetching** (`fetch_claims` lib.rs:621-636): looks up + `Identity::fetch_claim(target, claim_type, issuer, scope)` with **exactly the scope embedded + in the condition's claim** — scope matching is exact, no widening; expiry filtered (doc 03 §3). +- CDD special case: a condition claim `CustomerDueDiligence(default CddId)` matches any CDD + claim (primitives/src/proposition/base.rs:44-49). +- One-sided variant `is_holder_compliant` (lib.rs:892-929) used for holder-freeze reporting + (asset lib.rs:4011-4016): passes if any requirement's relevant side holds. + +Evaluation is weight-metered (`WeightMeter`, `WeightLimitExceeded` lib.rs:261) — failures of the +meter fail the transfer, not the block. + +### Call sites + +- Fungible: `Asset::validate_asset_transfer` → `is_compliant` (pallets/asset/src/lib.rs:3426); + skipped for controller transfers (lib.rs:3401-3404) and same-identity moves (never reaches — + doc 09 §2); **not re-checked** in `simplified_fungible_transfer` for locked settlements + (asset lib.rs:4381; doc 10 §6). +- NFT: `validate_nft_transfer` → `is_compliant` (pallets/nft/src/lib.rs:688-695). +- **Not checked** on issue/redeem (asset lib.rs:4113/2226). +- Dry-run RPC: `compliance_report` (lib.rs:948-1000; runtime API + `rpc/runtime-api/src/compliance.rs:24-48`, JSON-RPC `compliance_complianceReport` + `rpc/src/compliance.rs:31-41`). No requirements ⇒ `any_requirement_satisfied = true` (:956-962). + +## 5. Invariants & review checklist + +- [ ] Empty/paused compliance **allows** transfers — deliberate default-open design; adding a + first requirement flips the asset to default-closed (only matching transfers pass). + Confirm intent when changing this asymmetry. +- [ ] OR-of-requirements / AND-of-conditions structure must be preserved; short-circuits at + lib.rs:861 and :726. +- [ ] Claim lookups must remain expiry-filtered and exact-scope (`fetch_claim`); any caching + must not outlive claim revocation. +- [ ] `IsIdentity(ExternalAgent)` depends on `GroupOfAgent` — agent removal instantly changes + compliance outcomes. +- [ ] Complexity checks must run on every mutation path (add/replace/change + trusted-issuer + adds) — they bound transfer-time weight. +- [ ] Requirement ids must stay unique (auto-increment :551; replace dedups :363). + +## 6. Test map + +`pallets/runtime/tests/src/compliance_manager_test.rs` (evaluation matrix, reports), +`transfer_compliance_test.rs` (interaction with statistics), asset/settlement transfer tests +exercising `validate_asset_transfer`. Proposition unit tests: primitives/src/proposition/base.rs:166-304. +Integration: `integration/tests/compliance.rs`, `compliance_enforcement.rs`. diff --git a/docs/spec/07-statistics.md b/docs/spec/07-statistics.md new file mode 100644 index 0000000000..7f1bc6b614 --- /dev/null +++ b/docs/spec/07-statistics.md @@ -0,0 +1,110 @@ +# 07 — Transfer Restrictions (Statistics) + +Sources: `pallets/statistics/src/lib.rs`, `primitives/src/statistics.rs`, +`primitives/src/transfer_compliance.rs`. +Related specs: [06-compliance](06-compliance.md) (claim-based rules; statistics are +count/percentage-based), [09-asset-transfers](09-asset-transfers.md), [03-claims](03-claims.md). + +## 1. Purpose + +Numeric transfer restrictions per asset: investor-count caps, ownership-percentage caps, and +claim-scoped variants (e.g. "max 50 non-accredited investors", "jurisdiction X holds ≤ 20%"). +Built on **stat counters** maintained on every balance change, and **transfer conditions** +evaluated on every cross-identity fungible transfer. **NFTs are not statistics-checked.** + +## 2. Data model + +| Type | Shape | Ref | +|---|---|---| +| `StatType` | `{ operation_type: Count \| Balance, claim_issuer: Option<(ClaimType, IdentityId)> }` | primitives/src/statistics.rs:42-47 | +| `Stat1stKey` | `{ asset_id, stat_type }` | statistics.rs:71-76 | +| `Stat2ndKey` | `NoClaimStat \| Claim(StatClaim)` | statistics.rs:97-102 | +| `StatClaim` | `Accredited(bool) \| Affiliate(bool) \| Jurisdiction(Option)` | statistics.rs:163-170 | +| `TransferCondition` | `MaxInvestorCount(u64) \| MaxInvestorOwnership(Permill) \| ClaimCount(StatClaim, issuer, min, Option) \| ClaimOwnership(StatClaim, issuer, min, max)` | primitives/src/transfer_compliance.rs:30-48 | +| `AssetTransferCompliance` | `{ paused: bool, requirements: BoundedBTreeSet }` | transfer_compliance.rs:132-137 | + +Storage (pallets/statistics/src/lib.rs): `ActiveAssetStats` (AssetId → bounded set of +`StatType`, :137), `AssetStats` ((asset, stat_type), key2 → u128, :147), +`AssetTransferCompliances` (:159), `TransferConditionExemptEntities` +((asset, op, claim_type), DID → bool, :169). + +Limits (all runtimes): `MaxStatsPerAsset` = 10, `MaxTransferConditionsPerAsset` = 4 +(`pallets/runtime/develop/src/runtime.rs:139-140`; +50 under benchmarks). + +## 3. Configuration extrinsics (agent-gated via `ExternalAgents::ensure_perms`, :305-310) + +| Extrinsic (call_index) | Behavior | Ref | +|---|---|---| +| `set_active_asset_stats(0)` | replace active stat set; cannot remove a type used by a transfer condition (`CannotRemoveStatTypeInUse` :328-349); removal wipes that stat's `AssetStats` (:352-362) | :218 → :316 | +| `batch_update_asset_stats(1)` | manual counter (re)initialization; stat must be active (`StatTypeMissing` :386); `None` value removes (:395-409) | :243 → :377 | +| `set_asset_transfer_compliance(2)` | replace conditions; each condition's stat type **must already be active** (`StatTypeMissing` :432-439); empty set removes entry (:444) | :269 → :415 | +| `set_entities_exempt(3)` | add/remove exempt DIDs per (asset, op, claim_type) key | :293 → :457 | + +**Operational gotcha**: activating a stat does *not* backfill counters — the chain only tracks +changes from activation onward. Agents must `batch_update_asset_stats` to seed correct values, +else conditions evaluate against wrong counts. No protocol fees in this pallet. + +## 4. Stat maintenance (on every balance change) + +`update_asset_stats(asset, from_did?, to_did?, from_balance?, to_balance?, amount)` (:629-689) — +called from asset pallet on transfer (asset lib.rs:4224-4232), issue (from=None, +lib.rs:4145-4153), redeem (to=None, lib.rs:2264-2272). Controller transfers update stats too. + +- Investor-count transitions (`investor_count_changes` :609-626): sender counted out iff + post-balance == 0; receiver counted in iff post-balance == amount (was 0). +- `Count` stats: ±1 on the respective `Stat2ndKey` bucket (:535-581). +- `Balance` stats: ±amount per bucket (:488-528). +- Claim-scoped buckets resolved via `fetch_claim_as_key` (:584-602): + `Identity::fetch_claim(did, claim_type, issuer, Some(Scope::Asset(asset_id)))` — **scope is + always `Scope::Asset(asset)`** for stats, unlike compliance's exact-scope matching. + **Claim changes do not retro-update stat buckets** — a claim issued/revoked after balances + exist leaves counters stale until manually corrected via `batch_update_asset_stats`. + +## 5. Enforcement (`verify_transfer_restrictions`, :984-1012) + +Called from `Asset::validate_asset_transfer` (asset lib.rs:3414-3423); **also enforced in the +locked-settlement `simplified_fungible_transfer` path** (asset lib.rs:4405-4414) — unlike +compliance. Skipped for controller transfers (asset lib.rs:3401-3404). + +- `paused ⇒ pass` (:997) — but note **no production extrinsic sets `paused`** + (transfer_compliance.rs:134; only benchmark code writes it, + pallets/statistics/src/benchmarking.rs:187). Effectively always active. +- **ALL conditions must pass** (AND, :1033-1047) — opposite of compliance's OR. Failure ⇒ + `InvalidTransferStatisticsFailure`. +- Evaluation uses **post-transfer projections** (sender−amount, receiver+amount, :1027-1031) + against **pre-transfer stored counters**: + +| Condition | Pass rule | Ref | +|---|---|---| +| `MaxInvestorCount(max)` | only checked when receiver is a *new* investor: stored count `< max` (i.e. count after entry ≤ max); sender-exit or investor-swap auto-pass | :692-734 | +| `MaxInvestorOwnership(max)` | `(receiver_balance + amount) / total_supply ≤ max` | :810-824 | +| `ClaimCount(claim, issuer, min, max?)` | sender exiting a matching bucket: fail if `count ≤ min`; receiver entering: fail if `count ≥ max` | :737-807 | +| `ClaimOwnership(claim, issuer, min, max)` | receiver-side: `(bucket + amount)/supply ≤ max`; sender-side: `(bucket − amount)/supply ≥ min`; both/neither match ⇒ pass | :827-890 | + +- **Exemptions** (`is_exempt` :963-981): checked only after a condition fails; `Count`-type + conditions exempt by **sender** DID, `Balance`-type by **receiver** DID. Exempt key = + (asset, op, claim_type) — one exemption covers all conditions of that shape. +- Dry-run: `transfer_restrictions_report` (:1053-1095; runtime API + `rpc/runtime-api/src/statistics.rs:25-35`, no JSON-RPC wrapper — use `state_call`). + +## 6. Invariants & review checklist + +- [ ] Any new balance-mutating path must call `update_asset_stats` with correct pre-change + balances, or counters drift (and conditions misfire). +- [ ] `set_asset_transfer_compliance` ↔ `set_active_asset_stats` coupling: conditions require + active stats; stats in use can't be deactivated. Keep both directions enforced. +- [ ] Stat counters are *not* claim-reactive: docs/UI must treat claim changes as requiring + manual `batch_update_asset_stats`; on-chain code must not assume bucket accuracy. +- [ ] Exemption side (sender for Count / receiver for Balance) is intentional — e.g. an exempt + treasury can send to new investors past the cap? No: MaxInvestorCount exemption is checked + against **sender**, letting an exempt *sender* mint new investors past the cap. Confirm + that any new condition type picks the correct side. +- [ ] AND semantics across conditions; a failing meter (`WeightLimitExceeded`) must fail closed. +- [ ] `paused` for statistics is currently unreachable in production — adding a setter changes + security posture; do deliberately. + +## 7. Test map + +`pallets/runtime/tests/src/transfer_compliance_test.rs` (main suite: count/ownership/claim +conditions, exemptions), `asset_test.rs:139`, settlement tests for enforcement-in-instructions. +Integration: `integration/tests/statistics.rs`, `statistics_enforcement.rs`. diff --git a/docs/spec/08-portfolio.md b/docs/spec/08-portfolio.md new file mode 100644 index 0000000000..535dcddf0f --- /dev/null +++ b/docs/spec/08-portfolio.md @@ -0,0 +1,125 @@ +# 08 — Portfolios & Custodianship + +Sources: `pallets/portfolio/src/lib.rs`, `primitives/src/identity_id.rs` (PortfolioId), +`primitives/src/portfolio.rs` (Fund). +Related specs: [02-permissions](02-permissions.md) (portfolio subset checks), +[09-asset-transfers](09-asset-transfers.md), [10-settlement](10-settlement.md) (custody checked +at affirmation), [13-sto](13-sto.md), [12-corporate-actions](12-corporate-actions.md) (lock users). + +## 1. Purpose + +Portfolios partition an identity's asset holdings. Each portfolio can be placed under the +**custody** of another identity — the custodian (not the owner) then controls fund movements out +of it. Assets can also be held directly by account keys (doc 09 §7); portfolios are the +identity-native holding container. + +## 2. Data model + +- `PortfolioId { did, kind }`; `PortfolioKind = Default | User(PortfolioNumber)` + (primitives/src/identity_id.rs:260-265, 294-302). The Default portfolio always exists; + User portfolios are created explicitly (numbers from 1, identity_id.rs:244-248). +- `Fund { description: Fungible { asset_id, amount } | NonFungible(NFTs), memo }` + (primitives/src/portfolio.rs:25-52) — the unit of movement. + +### Storage (pallets/portfolio/src/lib.rs) + +| Item | Key → Value | Ref | +|---|---|---| +| `Portfolios` / `NameToNumber` / `NextPortfolioNumber` | naming + existence of user portfolios | :222/:236/:215 | +| `PortfolioAssetBalances` / `PortfolioAssetCount` | fungible balances / count of nonzero assets | :254/:249 | +| `PortfolioLockedAssets` | locked amount per (portfolio, asset) | :267 | +| `PortfolioNFT` / `PortfolioLockedNFT` | held / locked NFTs | :291/:304 | +| `PortfolioCustodian` | portfolio → custodian DID; `None` ⇒ owner | :279 | +| `PortfoliosInCustody` | reverse custody index | :283 | +| `PreApprovedPortfolios` | (portfolio, asset) receive-affirmation skip | :316 | +| `AllowedCustodians` | (owner, trusted) → bool — may create custody portfolios | :320 | +| `PortfolioFrozenAssets` / `FrozenPortfolios` | frozen amount / frozen flag per (portfolio, asset), written by asset pallet (doc 04 §3) | :325/:337 | + +## 3. Extrinsics & authorization + +| Extrinsic (call_index) | Who may call | Behavior | Ref | +|---|---|---|---| +| `create_portfolio(0)` | any permissioned DID | unique name; number from sequence | :410 → :672 | +| `delete_portfolio(1)` | **owner who still holds custody** + portfolio perms | requires zero assets & zero NFTs (`PortfolioNotEmpty` :439-446) | :425 | +| `rename_portfolio(2)` | **owner** (portfolio perm; no custody check :493-497) | unique-name rename | :479 | +| `quit_portfolio_custody(3)` | current custodian | custody reverts to owner (:534-538) | :527 | +| `accept_portfolio_custody(4)` | auth target | consume `PortfolioCustody` auth (§4) | :542 → :850 | +| `move_portfolio_funds(5)` | **custodian of source** (+ portfolio perms) | same-identity move (§5) | :566 | +| `pre_approve_portfolio(6)` / `remove_portfolio_pre_approval(7)` | **custodian** | toggle per-portfolio receive pre-approval | :595/:614 → :1016/:1038 | +| `allow_identity_to_create_portfolios(8)` / `revoke_create_portfolios_permission(9)` | owner | manage `AllowedCustodians` (self-add rejected :1065) | :629/:643 | +| `create_custody_portfolio(10)` | trusted DID (in `AllowedCustodians`) | creates portfolio under **owner's** DID, custody immediately to caller — no auth round-trip (:1090-1114) | :658 | + +No protocol fees in this pallet. `MaxNumberOfFungibleMoves = 10` / `MaxNumberOfNFTsMoves = 100` +bound weights (`pallets/runtime/develop/src/runtime.rs:155-156`). + +## 4. Custody model + +- Resolver: `custodian(pid) = PortfolioCustodian.unwrap_or(pid.did)` (:693-696). +- Transfer: current custodian (owner initially) issues `AuthorizationData::PortfolioCustody(pid)` + (primitives/src/authorization.rs:49); target accepts (`base_accept_portfolio_custody` :850-875). + Rules: **Default portfolios cannot have custodians** (:855-858); auth must be issued by the + *current custodian* (:860-861) — custody can be passed onward; accepting as the owner resets to + `None` (:865-867). +- Rights split: + +| Action | Owner | Custodian | +|---|---|---| +| move funds out | only if custodian | yes (:902-906) | +| delete | only if still custodian (:450-454) | no (not owner) | +| rename | yes (no custody needed) | no | +| pre-approve receives | no (unless custodian) | yes (:1023-1027) | +| settlement affirmation for the portfolio | custody required (settlement lib.rs:1686-1692) | yes | +| receiver-affirmation policy governing DID | custodian if set, else owner (:1181-1182) | — | + +Layer-3 permission checks (doc 02 §5): `ensure_portfolio_custody` (:798), +`ensure_user_portfolio_permission` (:782, secondary-key subset), +`ensure_portfolio_custody_and_permission` (:814), `ensure_portfolio_validity` (:759). + +## 5. `move_portfolio_funds` (same-identity moves) + +`base_move_portfolio_funds` (:566-584 → checks :888-911, :915-945, effects :967-1014): +1. `from != to` (`DestinationIsSamePortfolio`); **`from.did == to.did` required** + (`DifferentIdentityPortfolios` :897) — strictly intra-identity. +2. Source: custody + portfolio permission (:902-906). Destination: validity + secondary-key + portfolio permission only — **destination custody not required** (:909). +3. Per fund: amount > 0, no duplicate assets, source portfolio not frozen for the asset, + asset not frozen, sufficient free balance (balance − locked, :921-931); NFTs owned & unlocked + (:948-964). +4. **No compliance/statistics/checkpoint involvement** — identity-level `BalanceOf` unchanged. +5. Funds in deleted portfolios remain recoverable via this call (source existence not re-checked; + doc comment :550). + +## 6. Locks (who locks portfolio assets) + +`PortfolioLockedAssets` amounts stack (`unchecked_lock_tokens` :844-848); locked balance shows in +balance but blocks moves/redeem/transfers (available = balance − locked − frozen). + +| Locker | Lock | Unlock | Ref | +|---|---|---|---| +| Settlement affirmation | `lock_asset` per leg on affirm | on reject/withdraw/execution | settlement lib.rs:1697-1713/1715-1731 → asset lib.rs:3774/3794 | +| STO | offering locked at fundraiser creation | on stop / per-investment | pallets/sto/src/lib.rs:538-542, 785-789, 1019-1023 | +| Capital distributions | CAA locks distribution amount | reclaim/remove/per-claim | pallets/corporate-actions/src/distribution/mod.rs:699, 607-610 | +| NFT locks | `lock_nft`/`unlock_nft` | — | :1164/:1169 via nft lib.rs:1067/1090 | + +## 7. Invariants & review checklist + +- [ ] All fund-out paths must check **custody of the source** (`ensure_portfolio_custody*`); + destination custody is deliberately not required — receiving is gated by affirmation + policy instead (doc 09 §5). +- [ ] `move_portfolio_funds` must stay same-identity (`DifferentIdentityPortfolios`) — relaxing + it would bypass compliance/statistics entirely. +- [ ] `PortfolioAssetCount` must track 0↔nonzero balance transitions (`transition_asset_count` + :748-756) — delete-empty depends on it. +- [ ] Locked ≤ balance must hold; lock without balance check only via trusted internal paths + (`unchecked_lock_tokens` callers). +- [ ] Default portfolios: always valid, cannot be deleted/renamed/custodied — check new code + doesn't assume a `Portfolios` entry exists for them. +- [ ] `create_custody_portfolio` bypasses the auth round-trip by design; it must stay gated on + `AllowedCustodians` (`MissingOwnersPermission` :1097-1100). + +## 8. Test map + +`pallets/runtime/tests/src/portfolio.rs` (locks :389/:473, custody auths :56/:565/:1222, +affirmation-skip :1025-1073); `settlement_pallet/transfer_funds.rs` (custody paths :345/:382); +`asset_pallet/issue.rs` (:154). Integration: `integration/tests/portfolio.rs`, +`portfolio_custody.rs`. diff --git a/docs/spec/09-asset-transfers.md b/docs/spec/09-asset-transfers.md new file mode 100644 index 0000000000..3cf818377b --- /dev/null +++ b/docs/spec/09-asset-transfers.md @@ -0,0 +1,163 @@ +# 09 — Asset Transfer Code Paths + +Sources: `pallets/asset/src/lib.rs`, `pallets/settlement/src/lib.rs`, `pallets/nft/src/lib.rs`, +`primitives/src/asset.rs` (AssetHolder). +Related specs: [10-settlement](10-settlement.md) (instruction machinery), +[08-portfolio](08-portfolio.md), [06-compliance](06-compliance.md), [07-statistics](07-statistics.md). + +## 1. Master map — every way tokens move + +| Path | Entry | Compliance | Statistics | Notes | +|---|---|---|---|---| +| Settlement instruction leg execution | `transfer_assets` settlement:2215 → `Asset::base_transfer` asset:2745 / `Nft::base_nft_transfer` nft:601 | yes | yes (fungible) | the canonical cross-identity path | +| `Settlement::transfer_funds` same-DID branch | settlement:1596-1638 | **no** | **no** | direct holder-to-holder move within one identity | +| `Settlement::transfer_funds` cross-DID branch | settlement:3850-3962 | yes (via instruction) | yes | auto-created 1-leg instruction | +| `Asset::transfer_asset` / `Nft::transfer_nft` | asset:1708→2919 / nft:309→768 | (wraps transfer_funds) | — | account-holding UX wrappers | +| `Portfolio::move_portfolio_funds` | portfolio:568 | **no** | **no** | same-identity portfolio moves (doc 08 §5) | +| Controller transfer (fungible/NFT) | asset:2375 / nft:799 | **no** | updates only | agent-forced (doc 04 §5) | +| Locked-instruction execution | `simplified_asset_transfer` settlement:3501 | **no (checked at lock)** | **yes, re-verified** | doc 10 §6 | +| Issue / redeem | asset:4113 / asset:2226 | no | updates only | mint/burn, not transfers | + +Same-identity moves are cheap by design: identity-level `BalanceOf` doesn't change, so +compliance, statistics, and checkpoints are all skipped soundly. Settlement instructions +**reject same-DID legs** at creation (`SameSenderReceiver`, settlement:2901/2915/2983), so the +fast paths above are the *only* same-identity routes. + +## 2. Holders: portfolios vs accounts + +Both leg endpoints are `AssetHolder = Portfolio(PortfolioId) | Account(AccountId32)` +(primitives/src/asset.rs:194-199). + +- **Account-held** balances live in asset-pallet storage: `AssetBalance`, `LockedBalance`, + `FrozenBalance`, `FrozenAccounts` (asset:602-659). The account must be linked to a DID + (`asset_holder_did`, identity keys.rs:790-804 — else `IdentityNotFoundForAccountPortfolio`); + identity-level `BalanceOf` still accrues to that DID. 0↔nonzero transitions maintain + `AccountKeyRefCount` (asset:3673-3695) so holding keys can't be unlinked (doc 01 §2). +- Acting *for* an account holding: caller must be that exact key, or the DID's primary key + (`ensure_account_permissions`, asset:3911-3928). Portfolio holdings: custody + portfolio + permission (`ensure_holder_permissions`, asset:3889-3906). + +## 3. The canonical validated transfer (`base_transfer`, asset:2745) + +Called only from settlement leg execution (custody was already checked at affirmation — +comment asset:2755-2758). `validate_asset_transfer` (asset:3362-3431) checks **in order**: + +1. asset exists & fungible (:3370-3374) +2. sender DID ≠ receiver DID (`SenderSameAsReceiver` :3379) +3. sender identity `BalanceOf` ≥ value (:3381); receiver overflow (:3385) +4. holdings valid: receiver portfolio exists / receiver account has DID (:3393 → 3815-3834); + sender available balance = balance − locked − frozen ≥ value, sender holder not frozen, + granularity (:3838-3867) +5. `is_controller_transfer` ⇒ **return early** (skip 6-9) (:3401-3404) +6. asset not frozen (:3406) +7. receiver DID active (:3408) +8. `Statistics::verify_transfer_restrictions` (:3414, doc 07) +9. `ComplianceManager::is_compliant` (:3426, doc 06) + +Effects (`unverified_transfer_asset`, asset:4174-4244) in order: checkpoint pre-update for both +DIDs (:4193-4199) → `BalanceOf` ± (:4202) → (controller only: reduce sender frozen balance +:4207-4218) → holder balances via `set_holders_balance` incl. refcounts (:4221 → 3736-3750) → +statistics update (:4224) → `AssetBalanceUpdated` event (:4234). + +## 4. Direct transfer UX (`transfer_funds` and wrappers) + +`Settlement::transfer_funds(origin, from: Option, to: AssetHolder, fund)` +(settlement:1519 → `base_transfer_funds` 1561-1654). `from = None` defaults to the **caller's +account holding** (:1574-1578). `Asset::transfer_asset(asset_id, to: AccountId, amount, memo)` +(asset:1708) and `Nft::transfer_nft` (nft:309) wrap it with `to = AssetHolder::Account(to)` — +these wrappers never touch portfolios. + +**Sender authorization** (`ensure_transfer_source_authorized`, settlement:1660-1695): +- Account source, caller == owner: implicit. +- Account source, caller ≠ owner: **spender mode** — `Asset::spend_allowance(owner, caller, + asset, amount)` (settlement:1673, asset:2782). NFTs not supported + (`AllowancesNotSupportedForNFTs`). +- Portfolio source: custody + portfolio permission (:1686-1692) — works cross-DID for custodians. + +**Same-DID branch** (settlement:1596-1638): amount > 0, asset not frozen, holder not frozen, +available balance / NFT ownership; direct holder-balance move; `FundsTransferred` event. +No instruction, no compliance/statistics/checkpoints. + +**Cross-DID branch** (`base_transfer_and_try_execute`, settlement:3850-3962): builds a +**one-leg instruction: venue = None, `SettleManual(current_block)`** (:3896-3905, executable +immediately, never scheduled); auto-affirms the sender side (locks tokens, :3908-3913); if the +caller also controls the receiver holding, affirms that too (:3916-3946); if no affirmations +remain pending (receiver pre-approved / default-skip) it **executes inline** (:3948-3955) +returning no id, else returns `Some(instruction_id)` for the receiver to act on. Full +compliance/statistics run at execution via `base_transfer`. + +## 5. Receiver affirmation model (default: not required) + +Whether the receiver leg auto-affirms is decided at instruction creation +(`skip_asset_holder_affirmation`, asset:3938-3958): + +1. Governing DID = receiving portfolio's custodian-else-owner (portfolio:1181-1182), or the + account's DID. +2. If that DID has **not** opted in via `Settlement::set_mandatory_receiver_affirmation` + (settlement:1477-1492, storage `MandatoryReceiverAffirmation` settlement:683-687) ⇒ **skip + (auto-affirm)** — the chain default. +3. If opted in, affirmation is still skipped when: asset globally exempt + (`AssetsExemptFromAffirmation`, root-set, asset:547) OR receiver DID pre-approved the asset + (`PreApprovedAsset`, asset:552, set via `pre_approve_asset` asset:1510) OR the specific + portfolio is pre-approved (`PreApprovedPortfolios`, portfolio:316, custodian-set). + +Receiver actions on a pending direct transfer: `Asset::receiver_affirm_asset_transfer` +(asset:1754 → settlement:3965-4012; account holdings only — portfolio receivers use the normal +`Settlement::affirm_instruction`) executes immediately after affirming; +`Asset::reject_asset_transfer` (asset:1786 → settlement:4058) — **either party** may reject a +Pending/Failed instruction. + +## 6. Spender approvals (ERC-20 style allowances) + +- `Asset::approve(asset_id, spender: AccountId, amount)` (asset:1812): amount 0 removes the + entry; `Balance::MAX` = infinite (never decremented, asset:2792-2793). Storage `Allowances` + NMap (owner, spender, asset) → Balance (asset:632-642). Event `Approval` (asset:348). +- Spend: only from settlement spender mode (asset `spend_allowance` :2782-2812; + `InsufficientAllowance` :1969; `AllowanceSpent` event :355). Depletion to zero removes entry. +- **Allowance is consumed *before* instruction creation and is not refunded if the receiver + later rejects the pending cross-DID transfer** — rejection only releases asset locks + (settlement:2802). Spenders bear that risk; wallets should surface it. +- Owner/spender granularity is per **account key**, not per identity. +- RPC: `allowance(owner, spender, asset)` runtime API (rpc/runtime-api/src/asset.rs:53-57). + EVM surface: `FungibleAssetStub.sol` approve/transferFrom (doc 21). + +## 7. NFT specifics + +`validate_nft_transfer` (nft:632-698): collection exists, cross-DID only, sender count, per-leg +limits (≤ `MaxNumberOfNFTsPerLeg` = 10, `ZeroCount`/dup checks nft:717-731), ownership + not +locked, receiver overflow; controller transfers return early; else sender-holder/asset frozen +checks, receiver DID active, **compliance** (nft:688). **No statistics for NFTs.** Effects: +per-DID `NumberOfNFTs` counts + per-NFT owner reassignment (nft:734-766). + +## 8. Dry-run RPCs + +- `asset_transfer_report(sender, receiver, asset, value, skip_locked_check)` (asset:3462-3570) — + accumulates all failing checks; `skip_locked_check=true` ignores locks/frozen when sizing + balance (used to pre-validate instructions whose locks are already placed). +- `nft_transfer_report` (nft:830-917) analogous. +- `Settlement::transfer_report(leg, skip_locked_check)` dispatches per leg type + (settlement:3695-3724); `execute_instruction_report` uses `skip_locked_check=true` + (settlement:3729-3759). + +## 9. Invariants & review checklist + +- [ ] Same-identity fast paths must verify `from.did == to.did` semantics precisely — any + relaxation reintroduces unchecked cross-identity movement. +- [ ] Every path that changes identity-level `BalanceOf` must run checkpoint-advance + + statistics-update; every path that doesn't change it must not (doc 04 §8, doc 11). +- [ ] Custody/permission is checked at affirmation (settlement) or at source-authorization + (transfer_funds), **never** in `base_transfer` — don't add transfer-time custody checks + (double-check) or remove affirmation-time ones (hole). +- [ ] Spender mode: allowance spend must precede instruction creation atomically with it + (`#[transactional]` semantics); NFT spender mode must stay rejected. +- [ ] Receiver-affirmation skip logic: adding new receive paths must consult + `skip_asset_holder_affirmation`, or opted-in identities lose their protection. +- [ ] `SenderSameAsReceiver` guards exist at instruction creation AND inside base transfers + (asset:3379, nft:645) — keep the defense in depth (key-unlink edge case covered by + `base_transfer.rs:429` test). + +## 10. Test map + +`pallets/runtime/tests/src/asset_pallet/{base_transfer.rs, asset_transfer.rs, allowances.rs, +controller_transfer.rs}`; `settlement_pallet/transfer_funds.rs` (23 scenarios: spender modes, +custody, frozen matrix, NFT variants); `settlement_pallet/reject_instruction.rs`. diff --git a/docs/spec/10-settlement.md b/docs/spec/10-settlement.md new file mode 100644 index 0000000000..9c57f53a08 --- /dev/null +++ b/docs/spec/10-settlement.md @@ -0,0 +1,227 @@ +# 10 — Settlement Engine + +Sources: `pallets/settlement/src/lib.rs`, `primitives/src/settlement.rs`, +`primitives/src/crypto.rs` (receipts). +Related specs: [09-asset-transfers](09-asset-transfers.md) (leg execution & +`transfer_funds`), [08-portfolio](08-portfolio.md) (custody/locks), [04-asset-lifecycle](04-asset-lifecycle.md) +(mandatory mediators, venue-filter admin). + +## 1. Purpose + +Atomic multi-leg, multi-party asset exchanges (DvP etc.). Parties **affirm** an instruction +(locking their outgoing assets); when all affirmations (holders + off-chain receipts + +mediators) are in, the instruction executes all legs atomically. Includes venue scoping, +off-chain receipts, per-instruction mediators, and a two-phase-commit **lock** mode for +off-chain/cross-chain coordination. + +## 2. Data model (primitives/src/settlement.rs) + +- `InstructionStatus`: `Unknown` (pruned/invalid) | `Pending` | `Failed` | `Success(block)` | + `Rejected(block)` | `LockedForExecution` (:50-64). +- `SettlementType`: `SettleOnAffirmation` | `SettleOnBlock(b)` | `SettleManual(b)` (executable + on/after b) | `SettleAfterLock` (:128-138). +- `Leg`: `Fungible { sender, receiver: AssetHolder, asset_id, amount }` | + `NonFungible { sender, receiver, nfts }` | + `OffChain { sender_identity, receiver_identity, ticker, amount }` (:182-214). +- `LegStatus`: `PendingTokenLock` | `ExecutionPending` (locked) | + `ExecutionToBeSkipped(signer, receipt_uid)` (off-chain, receipt claimed) (:84-92). +- `AffirmationStatus`: `Unknown | Pending | Affirmed` (:97-105); + `MediatorAffirmationStatus`: `Unknown | Pending | Affirmed { expiry }` (:755-766). +- `Instruction { venue_id: Option, settlement_type, trade_date, value_date, ... }` + (:164-177). +- Receipts: `Receipt { instruction_id, leg_id, sender/receiver identity, ticker, amount }` + (:248-261); `ReceiptDetails { uid, instruction_id, leg_id, signer, signature, expires_at, + metadata }` (:292-307). + +Storage highlights (pallets/settlement/src/lib.rs): `VenueInfo` (:613, `Venue { creator, +venue_type }`), `VenueSigners` (:631), `NumberOfVenueSigners` (:759), `VenueFiltering` / +`VenueAllowList` (:707/:712), `InstructionDetails` (:644), `InstructionLegs` (:741), +`InstructionLegStatus` (:654), `InstructionAffirmsPending` (:666), `AffirmsReceived` (:671), +`UserAffirmations` (:689), `OffChainAffirmations` (:747), `ReceiptsUsed` (:702), +`InstructionMediatorsAffirmations` (:764), `InstructionStatuses` (:731), +`MandatoryReceiverAffirmation` (:683), `LockedTimestamp`/`UnlockedTimestamp`/ +`InstructionRelockCount` (:776/:781/:786), `VenueCounter`/`InstructionCounter` (:718/:722). + +Runtime constants (mainnet `pallets/runtime/mainnet/src/runtime.rs:97-106`; develop differs on +lock timings, `develop/src/runtime.rs:98-107`): `MaxNumberOfFungibleAssets` 10, +`MaxNumberOfNFTsPerLeg` 10, `MaxNumberOfNFTs` 100, `MaxNumberOfOffChainAssets` 10, +`MaxNumberOfVenueSigners` 50, `MaxInstructionMediators` 4, `MaximumLockPeriod` 24h (develop +24min), `RelockCooldown` 4h (develop 10min), `MaxRelockCount` 3. +**No protocol fees in settlement** — costs are weight-based only. + +## 3. Venues + +- `create_venue(0)` (:816) — **any permissioned DID**; details length-limited; initial signers ≤ + `MaxNumberOfVenueSigners`. `update_venue_details(1)` / `update_venue_type(2)` / + `update_venue_signers(7)` — **venue creator only** (`ensure_venue_creator` :1748-1752; + signers add/remove :2675-2728). +- **Instruction↔venue rule**: instructions may have `venue_id: None`; if `Some`, **only the venue + creator can create the instruction** (:1792-1794). Venue signers exist solely to sign off-chain + receipts. Off-chain legs require a venue (`OffChainAssetsMustHaveAVenue` :2928). +- **Venue filtering** (per asset): asset agents toggle `set_venue_filtering(4)` and manage the + allow-list via `allow_venues(5)` / `disallow_venues(6)` (agent-gated, + `ExternalAgents::ensure_perms` :933/:957/:981). Enforced at **instruction creation** per leg + (`ensure_venue_filtering` :2857-2870 — filtering on ⇒ instruction must have an allowed venue) + and **re-checked at execution and lock** (`ensure_allowed_venue` :2841 from + `validate_execute_instruction_pre_conditions` :2094). Disallowing a venue after creation + blocks execution. + +## 4. Instruction lifecycle + +### Creation + +`add_instruction(9)`, `add_and_affirm_instruction(10)`, `*_with_mediators(19/20)`, +`*_with_count(15/16/17)` variants → `base_add_instruction` (:1754-1898): + +- `SettleOnBlock` must be a future block (:1765); **`SettleAfterLock` requires ≥1 instruction + mediator** (:1771-1779); value_date ≥ trade_date (:1784). +- Per-leg validation (`ensure_valid_leg` :2886-2936): fungible/NFT sender-DID ≠ receiver-DID + (`SameSenderReceiver` :2901/:2915), amount > 0, venue filtering, NFT per-leg caps, off-chain + distinct identities. Instruction-wide caps (:2990-3004). +- Pending-affirmation count = sender holders + non-pre-approved receivers + off-chain legs + + mediators (primitives :538-542). Receiver auto-affirm policy: doc 09 §5. **Asset-level + `MandatoryMediators` are merged into the mediator set** (:1948-1949). +- `SettleOnBlock` schedules execution via the substrate scheduler under a named task + (`schedule_instruction` :2344-2370, root origin, priority constant). + +### Affirmation + +`affirm_instruction(11)` → `base_affirm_instruction` (:2467): caller must control the holder +(custody+perms via `Asset::ensure_holder_permissions`, asset lib.rs:3889-3906) and the +affirmation must be Pending (:2487-2503). Affirming **locks the sender-side assets** +(`lock_asset` :1697-1713 → portfolio/account locked balances, NFT locks). When pending count +hits 0 and type is `SettleOnAffirmation`, execution is scheduled for the next block +(`maybe_schedule_instruction` :2323-2337). + +**There is no affirmation withdrawal** — the withdraw extrinsics were removed (call indices +12/18/22 are gaps; `AffirmationWithdrawn`/`MediatorAffirmationWithdrawn` events :125/:175 are +declared but never emitted). To back out, a party **rejects** the instruction. + +### Mediators + +Per-instruction mediators (bounded `MaxInstructionMediators`) + per-asset mandatory mediators. +`affirm_instruction_as_mediator(21)` (:1398 → :3256-3310) with optional **expiry** — expired +mediator affirmations block execution (`MediatorAffirmationExpired`, checked :2126-2148). +`reject_instruction_as_mediator(23)` (:1416). Mediators count toward pending affirmations. + +### Rejection + +`reject_instruction(13)` → `base_reject_instruction` (:2730-2817). Pending/Failed: any party +holder, venue creator, mediator, or off-chain-leg party (`ensure_valid_caller` :3317-3346). +LockedForExecution: **mediator only**, unless the lock period has expired — then any valid party +(:2777-2794). Releases locks, cancels scheduled task, prunes, sets `Rejected(block)`. + +### Execution + +- Scheduled: `execute_scheduled_instruction(14)` — **root only** (:1196), dispatched by the + scheduler; failure emits `FailedToExecuteInstruction` and marks `Failed` (:2873-2882). +- Manual: `execute_manual_instruction(8)` (:1024 → :3011-3097) with leg counts + weight limit + (RPC `get_execute_instruction_info` supplies them). Branches: Pending requires + `SettleManual(b)` reached (:3099-3113); **Failed = retry by any valid caller**; Locked = + mediator-only fast path (§6). +- Core: `execute_instruction` (:2017-2070) — pre-conditions (status, all affirmations incl. + mediator expiry, venue allow-list :2077-2148) → transactional `release_locks` (:2312) + + `transfer_assets` (:2215-2260 → `Asset::base_transfer` / `Nft::base_nft_transfer`; off-chain + legs are no-ops :2255) → prune → `Success(block)`. A failing leg emits `LegFailedExecution` + and the whole instruction rolls back to `Failed`. + +### State machine + +``` +Unknown ──add──▶ Pending ──lock──▶ LockedForExecution + │ ▲ │ │ + │ └──────unlock────────┘ │ + execute ok ────┼────────────────────────▶│ Success(block) (terminal) + execute err ──▶ Failed ──retry ok──▶ Success + Pending/Failed/Locked ──reject──▶ Rejected(block) (terminal) +``` +Transitions: Pending :1808; Locked :3470; unlock→Pending :3488; Success :2053/:3541; +Failed :2010-2012; Rejected :2809-2812. Terminal statuses persist; everything else is pruned +(`prune_instruction` :2273-2310). + +## 5. Off-chain legs & receipts + +`Leg::OffChain` represents value moving outside the chain (e.g. fiat). Each off-chain leg must be +affirmed with a **receipt** signed by a **venue signer**: + +- `affirm_with_receipts(3)` (:903 → :2374-2465): instruction must have a venue (:2390); + per-receipt validation (:3153-3217): instruction id match, unique (signer, uid) + (`DuplicateReceiptUid`), one receipt per leg, signer ∈ `VenueSigners` + (`UnauthorizedSigner`), not replayed (`ReceiptsUsed` ⇒ `ReceiptAlreadyClaimed`), leg is + OffChain and Pending. +- Signature = sr25519/ed25519 over `ChainScopedMessage { genesis_hash, uid, + "Polymesh Settlement Receipt", expires_at, Receipt {...} }` (:3191-3209; + primitives/src/crypto.rs:89,93) — chain-scoped, expiring, uid-replay-protected. +- Effects: leg → `ExecutionToBeSkipped(signer, uid)`, `ReceiptsUsed[signer][uid] = true`, + off-chain affirmation Affirmed, pending count −1 (:2415-2443). At execution the leg is + skipped (asset already moved off-chain). `mark_receipt_as_used` (:3138) is also called by STO + (pallets/sto/src/lib.rs:993). + +## 6. Locking — two-phase commit (`SettleAfterLock`) + +Purpose: guarantee an instruction *will* execute (e.g. after an off-chain/cross-chain +counterpart settles), by freezing validation outcomes at lock time. + +- `lock_instruction(24)` (:1453 → `base_lock_instruction` :3384-3475): **mediator only** + (:3392); type must be `SettleAfterLock` (:3396). Validation at lock = full pre-conditions + (all affirmations, mediator expiry, venue allow-list, :3451) **plus a complete execution + dry-run in a storage transaction that always rolls back** (:3453-3468) — compliance, + statistics, balances are all exercised. On success: status `LockedForExecution`, + `LockedTimestamp = now` (:3470-3471), event `InstructionLocked`. +- Execution of a locked instruction: `execute_manual_instruction` locked branch (:3065-3087), + **mediator only** → `simplified_asset_transfer` (:3501-3547): requires + `now − LockedTimestamp ≤ MaximumLockPeriod` (:3550-3561, `ExceededMaximumLockingPeriod`); + releases locks; **fungible: compliance skipped, statistics re-verified** + (asset `simplified_fungible_transfer` lib.rs:4381-4429, stats at :4405-4414); **NFT: + compliance skipped**, ownership/frozen-holder checked (nft :953-981) → `Success`. + The bounded lock period is what makes skipping sound: rule changes made after locking take + effect only once the lock expires (execution then requires unlock/relock, re-running full + validation). + + **This skip is deliberate design, not an oversight.** It has been reported repeatedly via the + bug bounty and is closed as working-as-intended. There is no attack advantage over a normal + settlement: a `SettleAfterLock` instruction can only be locked *after* every party has + affirmed, so anyone holding that full affirmation set could instead run a plain + `SettleOnAffirmation` instruction at least as fast — executing promptly under fully + current rules. Exploiting staleness would require knowing *ahead of time* that the issuer + will change compliance mid-window, and even then a normal settlement affirmed just before the + change achieves the same result without locking. Mediators lock precisely to *guarantee* + execution across rule churn, accepting only the short `MaximumLockPeriod` window of frozen + rules as the trade-off. +- `unlock_instruction(27)` (:1554 → :3479-3494): mediator only; → `Pending`, + records `UnlockedTimestamp`. +- Relock protections: relock over a live lock only after `MaximumLockPeriod + RelockCooldown` + (:3402-3413); after explicit unlock, `RelockCooldown` applies (:3414-3421); total relocks ≤ + `MaxRelockCount` (:3426-3435). +- Reject-while-locked: mediator only within the lock window; anyone valid after expiry + (:2777-2794). + +## 7. Invariants & review checklist + +- [ ] Affirmation ⇒ lock: every path that marks a holder affirmation Affirmed must lock the + sender-side assets, and every terminal path (execute/reject) must release exactly those + locks (`release_locks` :2312-2319). +- [ ] `InstructionAffirmsPending` must equal outstanding (holders + off-chain legs + mediators); + double-decrements would enable premature execution (first-affirm-only decrement for + mediators :3287-3289). +- [ ] Locked instructions: no full re-validation at execution **by design** (see §6) — any new + check added to `validate_asset_transfer` must be considered for `simplified_*` too, or + documented as lock-skipped; the lock dry-run and `MaximumLockPeriod` are the safety + envelope. Reviewers: do not file "locked settlement skips compliance" as a finding — + compare any such claim against the equivalent normal-settlement path, which requires no + more affirmations and executes sooner. +- [ ] Venue allow-list must be enforced at creation **and** execution/lock (assets rely on + revocation working for pending instructions). +- [ ] Receipt security: signer ∈ `VenueSigners` at claim time; (signer, uid) never reusable; + chain-scoped signatures only. +- [ ] Same-DID legs must stay rejected (`SameSenderReceiver`). +- [ ] Pruning must never delete `InstructionStatuses` terminal states (audit trail). +- [ ] `SettleAfterLock` must keep requiring ≥1 mediator (:1771-1779) — mediators are the only + actors who can lock/execute/unlock. + +## 8. Test map + +`pallets/runtime/tests/src/settlement_pallet/` — add_instruction, execute_instruction, +lock_instruction (565 lines), unlock_instruction, manual_execution, reject_instruction, +allow_disallow_venues, transfer_funds; plus legacy `settlement_test.rs` (venue caps, filtering, +NFT leg limits, receipts). Integration: settlement flows in `integration/tests/`. diff --git a/docs/spec/11-checkpoints.md b/docs/spec/11-checkpoints.md new file mode 100644 index 0000000000..57078f17fc --- /dev/null +++ b/docs/spec/11-checkpoints.md @@ -0,0 +1,81 @@ +# 11 — Checkpoints (Balance Snapshots) + +Sources: `pallets/asset/src/checkpoint/mod.rs`, `primitives/src/checkpoint.rs`. +Related specs: [04-asset-lifecycle](04-asset-lifecycle.md), [12-corporate-actions](12-corporate-actions.md) +(main consumer — ballots and capital distributions read balances "as of" a checkpoint). + +## 1. Purpose + +A checkpoint captures every holder's balance and the total supply of an asset at a moment, using +**copy-on-write**: nothing is stored at creation; a holder's pre-change balance is recorded the +first time it changes after the checkpoint. Corporate actions use checkpoints so votes/dividends +are computed from balances at the record date regardless of later transfers. + +## 2. Storage (pallets/asset/src/checkpoint/mod.rs) + +| Item | Key → Value | Ref | +|---|---|---| +| `CheckpointIdSequence` | AssetId → `CheckpointId` (first = 1) | :154 | +| `Timestamps` | (AssetId, CheckpointId) → Moment | :178 | +| `TotalSupply` | (AssetId, CheckpointId) → Balance at checkpoint | :123 | +| `Balance` | ((AssetId, CheckpointId), DID) → recorded balance | :137 | +| `BalanceUpdates` | (AssetId, DID) → `Vec` where a record exists | :161 | +| `SchedulesMaxComplexity` | global cap on aggregate pending scheduled points | :192 | +| `ScheduleIdSequence` / `ScheduledCheckpoints` / `SchedulePoints` / `ScheduleRefCount` | schedule machinery (§4) | :198/:216/:243/:235 | +| `CachedNextCheckpoints` | AssetId → next-due cache across schedules | :208 | + +## 3. Core mechanics + +- **Manual creation**: `create_checkpoint` (call 0, :285) — asset agent + (`ExternalAgents::ensure_perms`, :288); records `TotalSupply` + `Timestamp` only + (`create_at`, :632-646). +- **Copy-on-write updates**: `advance_update_balances` (:428-435) is invoked by the asset pallet + **before every `BalanceOf` mutation** with the pre-change (did, balance) pairs — issue + (asset lib.rs:4127-4131), redeem (lib.rs:2252-2255), transfer (lib.rs:4193-4199). + `update_balances` (:442-454) writes the pre-change balance under the *latest* checkpoint only + if that DID has no record there yet, and appends to `BalanceUpdates`. +- **Reads**: `balance_at(asset, did, cp)` (:404-424) — binary-search the DID's `BalanceUpdates` + for the first recorded checkpoint ≥ cp (`find_ceiling` :683); if none, the balance hasn't + changed since, so callers fall back to the **current** balance (`Asset::get_balance_at`, + asset lib.rs:3357-3360). + +## 4. Schedules + +- `ScheduleCheckpoints` = ordered set of future moments (primitives/src/checkpoint.rs:30-33); + `from_period` caps a repeating period at 10 points (checkpoint.rs:46-64). +- `create_schedule` (call 2, :334) — agent; non-empty, all moments future, per-schedule **and** + aggregate pending count ≤ `SchedulesMaxComplexity` (:534-555); protocol fee + `CheckpointCreateSchedule` (:563). `set_schedules_max_complexity` (call 1, :302) is root/PIP. +- `remove_schedule` (call 3, :360) — agent; fails with `ScheduleNotRemovable` if + `ScheduleRefCount > 0` (:588-591). Corporate actions take refs on schedules they depend on + (`inc_schedule_ref` :649, CA side pallets/corporate-actions/src/lib.rs:1132/1143). +- **Lazy materialization**: scheduled checkpoints are created inside `advance_schedules` + (:457-523) — i.e. only when the *next balance-mutating operation* of that asset occurs after + the due moment. There is **no `on_initialize` hook**. Due moments become checkpoints + (`create_at` :502-506) with the *scheduled* timestamp; exhausted schedules are deleted + (:493-496); `CachedNextCheckpoints` maintained (:512-519). + +Consequence: a checkpoint's `Timestamps` value can predate its actual creation block. Consumers +(CAs) treat the scheduled moment as authoritative. A dormant asset (no transfers) materializes +overdue checkpoints only on its next activity — reads via `balance_at` before materialization +return `None`/current-balance, which is consistent because no balance changed in between. + +## 5. Invariants & review checklist + +- [ ] Asset pallet must call `advance_update_balances` with **pre-change** balances before + *every* `BalanceOf` write — any new mint/burn/transfer path included; missing calls + silently corrupt historical balances. +- [ ] `Balance` records are immutable once written (first-write-wins per (cp, did)); + no code should overwrite them. +- [ ] `ScheduleRefCount` discipline: CA code must inc on attach and dec on detach + (corporate-actions lib.rs:1109-1117), else schedules become unremovable or vanish + under a dependent CA. +- [ ] `SchedulesMaxComplexity` bounds per-transfer work in `advance_schedules`; schedule + creation must keep enforcing the aggregate cap (:542-548). +- [ ] `CheckpointIdSequence` monotonicity — ids order checkpoints for `find_ceiling`. + +## 6. Test map + +`pallets/runtime/tests/src/asset_test.rs` — `checkpoints_fuzz_test` (:354), schedule tests +(:737-953); `corporate_actions_test.rs` — scheduled-checkpoint consumption +(`vote_scheduled_checkpoint` :1733, `dist_claim_scheduled_checkpoint` :2328). diff --git a/docs/spec/12-corporate-actions.md b/docs/spec/12-corporate-actions.md new file mode 100644 index 0000000000..248041b9c4 --- /dev/null +++ b/docs/spec/12-corporate-actions.md @@ -0,0 +1,124 @@ +# 12 — Corporate Actions, Ballots & Capital Distributions + +Sources: `pallets/corporate-actions/src/lib.rs` (LIB), `.../ballot/mod.rs` (BAL), +`.../distribution/mod.rs` (DIST). +Related specs: [11-checkpoints](11-checkpoints.md) (record dates), [05-external-agents](05-external-agents.md) +(`PolymeshV1CAA` group), [08-portfolio](08-portfolio.md) (distribution locks), +[09-asset-transfers](09-asset-transfers.md) (benefit payouts are compliance-checked transfers). + +"CAA" below = an external agent whose group grants the CA pallets — `AgentGroup::PolymeshV1CAA` +grants exactly `CorporateAction` + `CorporateBallot` + `CapitalDistribution` +(pallets/external-agents/src/lib.rs:683-687); `Full`/suitable custom groups also qualify. + +## 1. Corporate actions base (LIB) + +### Data model + +- `CAId { asset_id, local_id }` (LIB:296-304); per-asset sequence (`CAIdSequence` LIB:417). +- `CAKind`: `PredictableBenefit | UnpredictableBenefit | IssuerNotice | Reorganization | Other` + (LIB:182-205); `is_benefit()` = the two benefit kinds (LIB:207-212). +- `TargetIdentities { identities, treatment: Include | Exclude }` (LIB:156-163); + **default is `Exclude` with an empty list ⇒ everyone targeted** (LIB:138-143); + `targets(did)` via binary search (LIB:173-178). +- Withholding tax `Tax = Permill` (LIB:125): CA-level default + per-DID overrides; + `tax_of(did)` (LIB:277-286). +- `CorporateAction { kind, decl_date, record_date: Option, targets, + default_withholding_tax, withholding_tax }` (LIB:259-275) — **targets/taxes are snapshotted + from the asset defaults at creation** (LIB:1014-1021); later default changes don't affect + existing CAs. +- `RecordDateSpec`: `Scheduled(Moment) | ExistingSchedule(ScheduleId) | Existing(CheckpointId)` + (LIB:245-255) → resolved to `CACheckpoint::{Scheduled(id, idx), Existing(id)}` (LIB:219-231). + +### Extrinsics + +| Extrinsic (idx) | Who | Behavior | Ref | +|---|---|---|---| +| `set_max_details_length(0)` | **root** (PIP) | global cap | LIB:479-486 | +| `set_default_targets(1)` / `set_default_withholding_tax(2)` / `set_did_withholding_tax(3)` | CAA | asset-level defaults (bounded `MaxTargetIds`/`MaxDidWhts`) | LIB:501/:533/:562 | +| `initiate_corporate_action(4)` | CAA | create CA; `decl_date ≤ now` (LIB:997), `decl_date ≤ record_date` (LIB:1003-1009); record-date handling §2 | LIB:621 → :957-1038 | +| `link_ca_doc(5)` | CAA | **replace** doc links (docs must exist) | LIB:669-687 | +| `remove_ca(6)` | CAA | removes CA + attached ballot (only before start) / distribution (only before payment_at); decrements schedule ref | LIB:707-737 | +| `change_record_date(7)` | CAA | re-resolve record date; constrained by attached ballot/distribution timing | LIB:754-794 | +| `initiate_corporate_action_and_distribute(8)` / `..._and_ballot(9)` | CAA | atomic combos | LIB:796/:854 | + +### Record dates & checkpoints (§ LIB:1119-1159) + +- `Scheduled(date)` ⇒ creates a checkpoint schedule with **initial ref count 1** (LIB:1127-1135). +- `ExistingSchedule(id)` ⇒ pins the schedule (`inc_schedule_ref`) and records the index of its + next checkpoint (LIB:1137-1144). +- `Existing(cp)` ⇒ uses a materialized checkpoint (LIB:1146-1151). +- Reads: `record_date_cp` (LIB:1082-1100) maps `Scheduled(id, idx)` through `SchedulePoints`; + `balance_at_cp` (LIB:1070-1080) — **falls back to the live balance if the scheduled checkpoint + hasn't materialized yet** (lazy checkpoints, doc 11 §4; sound because no balance change ⇒ + live == checkpoint value). + +## 2. Ballots (BAL) — corporate voting + +- Attach: `attach_ballot(0)` — CAA; CA kind must be **`IssuerNotice`** (`CANotNotice` + BAL:715-718); range `start ≤ end`, `now ≤ end`; **record date required and ≤ start** + (BAL:720, LIB:1058-1068); one ballot per CA; protocol fee `CorporateBallotAttachBallot` + (BAL:743). Motions ≤ 8 with ≤ 128 choices each (weight guard, BAL:103-104). +- Config changes `change_end(2)` / `change_meta(3)` / `change_rcv(4)` / `remove_ballot(5)` — + CAA, all **strictly before start** (BAL:810-817). +- `vote(1)` (BAL:437-532) — any permissioned signer whose DID is **targeted by the CA** + (BAL:449-450): + - within `[start, end]` inclusive (BAL:445-446); + - one `BallotVote { power, fallback }` per choice, flat across motions; count must match + (BAL:453-459); + - **voting power = balance at the record-date checkpoint** (BAL:498-501 → LIB:1070-1080); + per-motion Σ power ≤ voting power — full power is reusable across motions (BAL:503-511); + - RCV: fallback must point to a *different* choice in the *same* motion (BAL:483-485); + fallbacks forbidden when RCV off (BAL:489-496); + - **re-voting replaces** the previous vote and adjusts the running tally (BAL:513-527). +- Results are a flat per-choice `Vec` tally (BAL:326-335); RCV fallback resolution is + an off-chain concern. + +## 3. Capital distributions (DIST) — dividends + +- `distribute(0)` (DIST:233 → :642-718) — CAA **with custody+permission of the source + portfolio** (DIST:670-679): CA must be a benefit kind (`CANotBenefit`); **record date required + and ≤ payment_at** (DIST:686-690); `amount`/`per_share` nonzero; expiry after payment; + one distribution per CA; protocol fee `CapitalDistributionDistribute` (DIST:695); **locks + `amount` in the source portfolio** (DIST:698-699). `Distribution { from, currency, per_share, + amount, remaining, reclaimed, payment_at, expires_at }` (DIST:101-124). + Note: nothing forbids `currency == the CA's asset` (compliance still applies at payout). +- Payout (`transfer_benefit`, DIST:536-605) — via `claim(1)` (holder claims own) or + `push_benefit(2)` (CAA pushes to a holder): + 1. not already paid (`HolderPaid`), within `[payment_at, expires_at)`, holder targeted by CA; + 2. **benefit = balance_at_record_date × per_share / 1_000_000** (truncating, DIST:612-619); + 3. `remaining -= benefit` (checked); + 4. tax = `ca.tax_of(holder)`; `gain = benefit − tax·benefit`; indivisible currencies round + gain down to whole units (DIST:568-573); + 5. the full `benefit` is **unlocked** but only `gain` is transferred — the withheld tax stays + (unlocked) in the distributor's portfolio for off-chain remittance (DIST:566-576); + 6. transfer = **`Asset::base_transfer` to the holder's default portfolio — full compliance + and statistics checks apply** (DIST:578-590); a non-compliant holder cannot be paid. +- `reclaim(3)` — CAA + **custodian of the source portfolio** (DIST:462-477): only after expiry; + unlocks `remaining`, marks reclaimed. (`NotDistributionCreator` error is declared but unused — + the real gate is custody, DIST:396-397 vs :473-477.) +- `remove_distribution(4)` — CAA; only **before `payment_at`**; unlocks everything + (DIST:507-525). + +## 4. Invariants & review checklist + +- [ ] CA snapshot semantics: targets/taxes copied at initiation must stay immutable per-CA. +- [ ] Schedule ref-counting: every record-date attach/detach path must inc/dec + (`handle_record_date`/`dec_strong_ref_count`, LIB:1108-1159) or checkpoints get removed + under live CAs / schedules become unremovable. +- [ ] Ballot voting power and distribution benefits must read `balance_at_cp` (checkpoint), never + the live balance directly — the live-balance *fallback* is only valid pre-materialization. +- [ ] Distribution accounting: `remaining + Σ paid benefits = amount` until reclaim; + the lock covers `remaining` at all times (lock at create, unlock per-claim/reclaim/remove). +- [ ] Payouts must remain compliance-checked transfers (`base_transfer`) — switching to an + unverified move would bypass the currency asset's rules. +- [ ] Ballot mutation lockout after start (BAL:810-817); distribution mutation lockout after + payment_at (DIST:527-534). +- [ ] Re-vote tally math: subtract-then-add (BAL:513-527) must stay atomic per vote. + +## 5. Test map + +`pallets/runtime/tests/src/corporate_actions_test.rs` (2330 lines: CAA gating :238, CA init +matrix :488-722, record-date changes :878, schedule refs :1019; ballots :1074-1733 incl. RCV and +scheduled-checkpoint voting; distributions :1744-2328 incl. rounding and no-remaining cases). +Integration: `integration/tests/corporate_actions.rs`, `corporate_ballot.rs`, +`capital_distribution.rs`, `ca_extended.rs`. diff --git a/docs/spec/13-sto.md b/docs/spec/13-sto.md new file mode 100644 index 0000000000..125477a332 --- /dev/null +++ b/docs/spec/13-sto.md @@ -0,0 +1,96 @@ +# 13 — STO (Fundraising) + +Sources: `pallets/sto/src/lib.rs`, `primitives/src/sto.rs`. +Related specs: [10-settlement](10-settlement.md) (instructions execute the swap), +[05-external-agents](05-external-agents.md) (`PolymeshV1PIA` group), [08-portfolio](08-portfolio.md) +(offering locks). + +## 1. Purpose + +Primary-issuance fundraisers: an asset agent offers `offering_asset` from a portfolio at tiered +prices against `raising_asset` (or off-chain funds). Investments settle atomically through the +settlement engine, so **asset compliance and transfer restrictions fully apply** to both legs. + +## 2. Data model (pallets/sto/src/lib.rs) + +- `Fundraiser { creator, offering_portfolio, offering_asset, raising_portfolio, raising_asset, + tiers, venue_id, start, end: Option, status, minimum_investment }` (:131-159). +- `FundraiserTier { total, price, remaining }` (:177-188); ≤ `MAX_TIERS = 10` (:81). Prices are + fixed-point ×10⁶. +- `FundraiserStatus`: `Live | Frozen | Closed | ClosedEarly` (:90-103). +- `FundingMethod::OnChain(PortfolioId) | OffChain(FundraiserReceiptDetails)` (:105-119); + STO-specific receipts (`FundraiserReceipt`, primitives/src/sto.rs:40-51) are distinct from + settlement leg receipts. +- Storage: `Fundraisers` (:384), `FundraiserCount` (:398), `FundraiserNames` (:403), + `FundraiserOffchainAsset` (asset+id → Ticker; presence enables off-chain funding, :417-427). + +No protocol fees anywhere in this pallet. + +## 3. Extrinsics + +| Extrinsic (idx) | Who | Behavior | Ref | +|---|---|---|---| +| `create_fundraiser(0)` | agent of offering asset (`ensure_agent_asset_perms` :498) + custody of offering & raising portfolios (:504-513) | venue must exist, be creator's, and be `VenueType::Sto` (:500-502); 1..=10 tiers, totals > 0 (:515-525); `start < end` (:530); **locks the total offering amount** (:538-542); status Live | :477-572 | +| `invest(1)` | **any DID** with custody of the investment (+funding) portfolios | §4 | :605 → :856-1076 | +| `freeze_fundraiser(2)` / `unfreeze_fundraiser(3)` | agent | toggle Frozen/Live (not-closed guard) | :645/:673 → :1078-1104 | +| `modify_fundraiser_window(4)` | agent | not closed & not expired; new `start < end` | :706-741 | +| `stop(5)` | **creator DID** (asset-perm check only) or any permissioned agent (:771-775) | sums tier `remaining`, **unlocks it** (:785-789); status `ClosedEarly` (end in future) else `Closed` (:790-793) | :762-802 | +| `enable_offchain_funding(6)` | creator or agent (:832-838) | registers the off-chain ticker | :824-851 | + +`AgentGroup::PolymeshV1PIA` = all Sto extrinsics **except `invest`** + Asset +issue/redeem/controller_transfer (external-agents lib.rs:688-700). + +## 4. `invest` flow (:856-1076) + +1. Fundraiser Live (:879-882) and within `[start, end)` (:884-888). +2. **Tier consumption is vector order** (creation order — the "lowest price first" doc comment + at :577-578 is inaccurate; tiers are not sorted): skip empty tiers, buy + `min(tier.remaining, wanted)` per tier (:905-933). Purchase must be fully fillable + (`InsufficientTokensRemaining` :935). +3. Cost = Σ `amount_in_tier × tier.price / 1_000_000` (checked math, :927-932); + `cost ≥ minimum_investment` (:936-939); slippage guard + `cost ≤ max_price × purchase_amount / 1_000_000` (:940-945). +4. Legs: always `offering_portfolio → investment_portfolio` for the offering asset (:960-965). + - **OnChain funding**: second leg `funding_portfolio → raising_portfolio` for the raising + asset at `cost` (:982-987); custody of the funding portfolio required (:968-972). + - **OffChain funding** (:990-1016): requires `enable_offchain_funding`; validates an + STO receipt — signer must be a **venue signer**, uid replay-protected via settlement's + `ReceiptsUsed` (`mark_receipt_as_used`, sto:993-997 → settlement lib.rs:3138-3148); + signature over `ChainScopedMessage { …, "Polymesh STO Fundraiser Receipt", + FundraiserReceipt { fundraiser_id, investor, raiser, ticker, cost } }` + (crypto.rs:86; :998-1014). **No second leg is added** — payment happens off-chain. +5. Offering amount is unlocked (:1019-1023), then a settlement instruction is created on the + fundraiser's venue (`SettleOnAffirmation`, :1025-1034), the fundraiser side auto-affirmed + (:1036-1050), and the investor side affirmed+executed **in the same transaction** + (`affirm_and_execute_instruction`, settlement lib.rs:2618-2659 — STO-only entry point, + executes immediately and non-retryably when no affirmations remain). + Compliance/statistics run inside normal instruction execution. +6. Tier `remaining` decremented post-settlement (:1060-1062); `Invested` event. + +Receiver-affirmation policy applies: the investor's portfolio is only added to the affirmation +set if their identity opted into mandatory receiver affirmation (:951-959, doc 09 §5). + +## 5. Lifecycle notes + +- Expiry is enforced only at invest time; an expired fundraiser keeps its remaining offering + **locked until `stop` is called** — no automatic close/unlock. +- Sell-out does not auto-close (`Live` until `stop`). +- Frozen fundraisers reject investments but keep locks. + +## 6. Invariants & review checklist + +- [ ] Offering lock accounting: locked amount must always equal Σ tier `remaining` (lock at + create, unlock per-invest and at stop). A drift strands or double-spends offering tokens. +- [ ] Investment must be atomic: unlock (:1019) is only sound because instruction creation + + execution happen in the same transactional extrinsic — don't split this flow. +- [ ] Off-chain receipts: venue-signer check + `ReceiptsUsed` replay protection must precede any + state change; STO receipts and settlement receipts share the uid replay space per signer. +- [ ] `stop`'s creator bypass (:771-775) intentionally lets the original creator wind down even + after losing agent perms — reassess if creator trust model changes. +- [ ] Tier math is checked arithmetic throughout; price precision 10⁶ must match UI expectations. + +## 7. Test map + +`pallets/runtime/tests/src/sto_test.rs` (happy path :105-271 incl. same-block settlement +`Success`; unhappy :273; invalid fundraiser :417; expiry :513; window :560; freeze :615; +stop :651). diff --git a/docs/spec/14-fees-and-extensions.md b/docs/spec/14-fees-and-extensions.md new file mode 100644 index 0000000000..717fe851c2 --- /dev/null +++ b/docs/spec/14-fees-and-extensions.md @@ -0,0 +1,130 @@ +# 14 — Transaction Extensions & Fee Payment + +Sources: `pallets/transaction-payment/src/lib.rs` (polymesh-transaction-payment), +`pallets/protocol-fee/src/lib.rs`, `pallets/runtime/common/src/{runtime.rs,fee_details.rs,impls.rs,lib.rs}`, +`primitives/src/{transaction_payment.rs,protocol_fee.rs,traits.rs}`. +Related specs: [02-permissions](02-permissions.md) (StoreCallMetadata), +[15-relayer](15-relayer.md) (subsidies), [16-multisig](16-multisig.md) (fee redirection), +[21-revive-evm](21-revive-evm.md) (ETH path). +Note: the base `pallet-transaction-payment` is the **Polymesh fork** of polkadot-sdk — helpers +like `get_priority`, `remaining_txfee`, `deposit_txfee`, `ChargeFeesControl` come from there. + +## 1. The TxExtension tuple (pallets/runtime/common/src/runtime.rs:901-917) + +| # | Extension | Role | +|---|---|---| +| 0 | `AuthorizeCall`, `CheckNonZeroSender`, `CheckSpecVersion`, `CheckTxVersion`, `CheckGenesis` (:903-907) | standard validity/implicit payload | +| 1 | `CheckEra` (:909) | mortality | +| 2 | `CheckNonce` (:910) | nonce | +| 3 | `CheckWeight` (:911) | block limits — before money moves | +| 4 | **`polymesh_transaction_payment::ChargeTransactionPayment`** (:912) | fee logic (§2) — **tuple index 4 is hard-coded** by the ETH path (`tx_ext.4.set_storage_deposit`, runtime.rs:1037) | +| 5 | `pallet_permissions::StoreCallMetadata` (:913) | records pallet/extrinsic for permission checks (doc 02 §3) | +| 6 | `CheckMetadataHash` (:914) | disabled mode (`new(false)`) | +| 7 | `pallet_revive::evm::tx_extension::SetOrigin` (:915) | ETH-derived origin marking (doc 21); `new_from_eth_transaction()` on the ETH path (runtime.rs:941) | +| 8 | `WeightReclaim` (:916) | refunds over-estimated extension weight (last) | + +Same order used for offchain-signed (runtime.rs:678-694), authorized (:726-743) and ETH +(`EthExtraImpl::get_eth_extension` :926-944) construction. Test replica: +`pallets/runtime/tests/src/signed_extra.rs:32-50`. + +## 2. ChargeTransactionPayment lifecycle (pallets/transaction-payment/src/lib.rs) + +Struct `{ tip (compact), storage_deposit (codec-skipped, ETH-only) }` (:161-177). + +1. **validate** (:414-457): unsigned/root ⇒ `NoCharge` (:431-433). Else: + - `ensure_valid_tip` (:324-345): **Normal class ⇒ tip must be 0**; Operational ⇒ tip allowed + **only for Governance Committee members** (`is_gc_member` :314-318); violation ⇒ + `InvalidTransaction::Custom(ZeroTip)`. + - `can_withdraw_fee` (:207-241): compute fee (base+len+weight+tip via forked base pallet, + `Pays::No` ⇒ 0); resolve payer via `CurrentFeePayer::call_payment_info` (§3); check subsidy + (§5); dry-run withdrawability; **set `CurrentPayer` context** (:236). + - priority via forked `get_priority`. +2. **prepare** (:459-501): actually `withdraw_fee` (:243-290) from subsidiser-or-payer; set payer + context again; if subsidised, **reserve** the full `fee_with_tip + storage_deposit` from the + subsidy budget (:481-489) so protocol fees charged mid-dispatch can't exhaust it. +3. **post_dispatch** (:503-572): `CurrentPayer::take()` first (:510); on failed dispatch, + decrement the authorization retry count (:533-536, doc 01 §5); compute actual fee, settle + subsidy (refund unspent, `SubsidyDebited` event, :541-559); refund payer difference and route + the fee via `DealWithFees` (:565-567); emit fee-paid event. + +`CallPaymentInfo { paying_account, auth_id, ms_signatory }` +(primitives/src/transaction_payment.rs:5-42). `CurrentPayer` storage (:91-93) is readable during +dispatch — consumed by protocol fees (§4) and temporarily overridden by utility's +`dispatch_as`/`as_derivative` (`run_with_temporary_payer`, pallets/utility/src/lib.rs:587-612). + +Dev/CI chains can disable fees entirely (`disable_fees` feature; storage `DisableFees` :87-89, +root-only `set_disable_fees` :121-131). + +## 3. Payer resolution (`TxFeeHandler`, pallets/runtime/common/src/fee_details.rs) + +Default: caller pays (:285). Special-cased calls (matched even when wrapped in +`Revive::eth_substrate_call`, runtime.rs:195-205): + +| Call | Payer | +|---|---| +| `Identity::join_identity_as_key`, `accept_primary_key`, `rotate_primary_key_to_secondary` | **auth issuer's DID primary key** (:189-220) | +| `Identity::remove_authorization { auth_issuer_pays: true }` (target = caller) | auth issuer's primary key (:221-241) | +| `MultiSig::accept_multisig_signer` | `AddMultiSigSigner` auth issuer's primary key (:133-140) | +| `MultiSig::approve_join_identity` | JoinIdentity auth issuer's primary key; AlreadyVoted pre-check (:141-159) | +| `MultiSig::approve` / `reject` / `create_proposal` | multisig's `PayingDid` primary key, else the multisig account (:160-183, `get_multisig_payer` :92-111); duplicate votes rejected at pool level (`AlreadyVoted`) | +| `Relayer::accept_subsidy` (pending subsidy exists) | the prospective **paying key** (:247-255) | + +Auth-based redirection requires a valid, unexpired auth with retries left +(`get_non_expired_auth`); failing dispatches burn a retry (doc 01 §5). + +## 4. Protocol fees (pallets/protocol-fee/src/lib.rs) + +- Storage: `BaseFees` (ProtocolOp → Balance, :103) and `Coefficient` (`PosRatio`, :107). + fee = coefficient × base (:185-194). Genesis: only `AssetCreateAsset` = 2,500 POLYX and + `AssetRegisterTicker` = 500 POLYX are non-zero (src/chain_spec/common.rs:217-227). +- Governance: `change_coefficient` / `change_base_fee` are **root-only** (:150-181, events + attributed to `GC_DID`). +- Charging (`withdraw_from_payer` :252-258): the payer is **whoever `CurrentPayer` says** — i.e. + the transaction-fee payer, including redirected payers and subsidisers. If no payer context + (root/unsigned), the protocol fee is silently skipped. Subsidy consulted with `call=None` ⇒ + **no pallet-filter restriction for protocol fees** (:222-236; relayer lib.rs:649-652). +- Fee sink: `OnProtocolFeePayment = DealWithFees` (runtime.rs:266). +- `ProtocolOp` list (primitives/src/protocol_fee.rs:25-56): AssetRegisterTicker, AssetIssue, + AssetAddDocuments, AssetCreateAsset, CheckpointCreateSchedule, + ComplianceManagerAddComplianceRequirement, IdentityRegisterDid, IdentityAddClaim, + IdentityAddSecondaryKeysWithAuthorization, PipsPropose, ContractsPutCode, + CorporateBallotAttachBallot, CapitalDistributionDistribute, NFTCreateCollection, NFTMint + (last two never charged — doc 04 §4). +- RPC: `protocolFee_computeFee` (pallets/protocol-fee/rpc/src/lib.rs:30-32). + +## 5. Where fees go & fee sizing + +- **100% of tx fees + tips + protocol fees go to the block author**: `DealWithFees = + Author` (mainnet runtime.rs:43-44; impls.rs:42-53). No treasury split. +- Sizing (pallets/runtime/common/src/lib.rs): `TransactionByteFee` = 0.0001 POLYX/byte (:81), + target base fee 3 CENTS per `ExtrinsicBaseWeight` = 650µs (:79-88); `WeightToFee` is + revive's `BlockRatioFee<30_000, 650_000_000>` (runtime.rs:231, same ratio); + `FeeMultiplierUpdate = ConstFeeMultiplier(1)` — **no congestion-based fee adjustment** + (:233). `OperationalFeeMultiplier = 5`. +- ETH transactions: storage deposit is withdrawn up-front into the forked pallet's tx credit + pool and threaded through `storage_deposit` (runtime.rs:1014-1037); no tips (:1060-1062). + The up-front charge runs in `check()`, i.e. also during read-only pool validation — harmless + there, as validation mutations live in a discarded overlay; the charge lands exactly once from + committed state at apply time (upstream pallet-revive design, doc 21 §3). + +## 6. Invariants & review checklist + +- [ ] Tuple order: CheckWeight before ChargeTransactionPayment; StoreCallMetadata after fees; + `tx_ext.4` index coupling with the ETH path (runtime.rs:1037) — reordering breaks revive. +- [ ] `CurrentPayer` must be set on every charged path and taken exactly once in post_dispatch; + protocol fees depend on it — a path that charges protocol fees outside a signed + transaction context charges nobody. +- [ ] Subsidy reserve/settle must bracket dispatch: reserve in prepare (:481-489), settle in + post_dispatch (:541-559); protocol fees debit the same budget in between. +- [ ] Payer redirection must validate the auth *type* matches the call (fee_details + `get_payers_account` :49-89) — otherwise anyone could drain an issuer via unrelated auths. +- [ ] Tip policy (Normal ⇒ 0; Operational ⇒ GC only) is consensus-critical for fair ordering. +- [ ] `Pays::No`/zero-fee short-circuits must keep skipping payer/subsidy machinery + (:220-222, :263-266). + +## 7. Test map + +`pallets/runtime/tests/src/transaction_payment_test.rs` (lifecycle, refunds, tipping GC rules, +duplicate-vote rejection :775, auth-count decrement :854), `signed_extra.rs` (full-runtime +extension ordering & priorities), `fee_details.rs` (payer resolution matrix), +`protocol_fee.rs` (compute/batch). diff --git a/docs/spec/15-relayer.md b/docs/spec/15-relayer.md new file mode 100644 index 0000000000..61a7027520 --- /dev/null +++ b/docs/spec/15-relayer.md @@ -0,0 +1,92 @@ +# 15 — Relayer (Fee Subsidies) & relay_tx + +Sources: `pallets/relayer/src/lib.rs`, `pallets/runtime/common/src/runtime.rs` (SubsidyFilter), +`primitives/src/traits.rs` (SubsidiserTrait), `primitives/src/crypto.rs` (relay_tx signatures). +Related specs: [14-fees-and-extensions](14-fees-and-extensions.md) (how subsidies are consumed). + +## 1. Purpose + +A **paying key** can subsidise another **user key**'s transaction and protocol fees up to a POLYX +budget. Also hosts `relay_tx`: dispatching a call *as* another account using that account's +off-chain signature. + +## 2. Data model & storage (pallets/relayer/src/lib.rs) + +- `Subsidy { paying_key, remaining }` (:183-188) — `remaining` is the POLYX budget left. +- `Subsidies`: user_key → `Subsidy` (:212-214) — **one subsidy per user key**; accepting a new + one replaces the old (:483-489). +- `PendingSubsidies`: (user_key, paying_key) → initial limit (:221-230) — offered, not yet + accepted. +- `RelayTxNonces`: target account → nonce (:232-235). + +## 3. Subsidy lifecycle extrinsics + +| Extrinsic (call_index) | Who | Behavior | Ref | +|---|---|---|---| +| `approve_subsidy(0)` | paying key | create/overwrite pending offer (plain storage — **no identity authorization object**) | :245 → :441 | +| `revoke_subsidy(1)` | paying key | cancel pending offer | :259 → :459 | +| `accept_subsidy(2)` | user key | consume pending → active `Subsidy`; **the paying key pays this tx's fee** (fee_details.rs:247-255 via `has_pending_subsidy` :601-603) | :269 → :475 | +| `remove_subsidy(3)` | user key **or** paying key | end active subsidy (`NotAuthorized` otherwise :517-520) | :286 → :509 | +| `update_polyx_limit(4)` / `increase_polyx_limit(5)` / `decrease_polyx_limit(6)` | paying key | Set/Add/Sub `remaining` (checked, `Overflow`) | :305/:325/:345 | +| `relay_tx(7)` | any signed caller | §5 | :366-418 | + +## 4. Subsidy consumption (`SubsidiserTrait` impl, :630-721) + +Wired as `type Subsidiser = Relayer` for **both** transaction-payment and protocol-fee +(runtime.rs:241/268). + +- `check_subsidy(user, fee, call?)` (:635-655): no subsidy ⇒ not subsidised; insufficient + `remaining` ⇒ `InvalidTransaction::Payment` (a subsidised key with an exhausted budget can't + fall back to self-paying at the pool level for filtered calls). With `Some(call)`: + `ensure_subsidy_call` (:610-628) — the call must pass the **`SubsidyFilter`**; calls to the + `Relayer` pallet itself make the user pay their own fee (so `remove_subsidy` always works); + any other non-whitelisted call ⇒ `InvalidTransaction::Custom(PalletNotSubsidised)`. With + `None` (protocol fees): **no filter** (:649-652). +- `reserve_subsidy` (:680-695) / `settle_subsidy` (:697-720) / `debit_subsidy` (:657-678): + reserve full fee+deposit at prepare; settle (refund unspent + `SubsidyDebited` event) at + post-dispatch; protocol fees debit directly mid-dispatch (protocol-fee lib.rs:222-236). + Settle only refunds if the paying key is unchanged (:703-710). + +**SubsidyFilter whitelist** (runtime.rs:383-452): Asset, CapitalDistribution, Checkpoint, +ComplianceManager, CorporateAction, CorporateBallot, ExternalAgents, Portfolio, Settlement, +Statistics, Sto, Balances, Identity, Nft, Staking, MultiSig (:402-417). `Revive:: +eth_substrate_call` recurses into the inner call (:418-423). `Utility::{batch, batch_all, +force_batch}` allowed non-nested with **≤ 7 inner calls**, each individually filtered +(:386-398, :424-437). Everything else (PIPs, committees, treasury, utility-other, ...) is not +subsidisable. + +## 5. relay_tx (:366-418) + +Dispatch `call` as `target`, authorized by the target's off-chain signature: +1. Nonce = `RelayTxNonces[target]`, read-and-increment (:385-389). +2. Message = `ChainScopedMessage { genesis_hash, nonce, "Polymesh Relay Transaction", + expires_at, call }` (crypto.rs:83,92-99); expiry checked at construction (`ExpiredRelayTx`); + sr25519/ed25519 signature over the ``-wrapped SCALE encoding (`InvalidSignature`, + :395-398). +3. Dispatch via `pallet_utility::dispatch_call(Signed(target), false, call)` (:402-406) — + target-origin, call filters apply, **call metadata swapped** so permission checks evaluate + the inner call against the *target's* key permissions. +4. **Fees are paid by the caller** (the extrinsic signer), not the target (doc :360) — including + the caller's own subsidy if any. Event `RelayedTx { caller, target, result }`. + +Replay protection = genesis hash + per-target nonce + expiry. + +## 6. Invariants & review checklist + +- [ ] Subsidy budget accounting: reserve/settle/debit must never double-refund; `remaining` + mutations are checked/saturating and event-logged (`SubsidyDebited`). +- [ ] The filter must stay call-deep: wrappers (utility batches, revive eth calls) must recurse; + adding a new wrapper pallet requires updating `SubsidyFilter` or subsidised users can + escape the whitelist. +- [ ] `Relayer`-pallet calls must remain self-paid (`Ok(false)` path :618-620) so users can + always detach from a subsidy. +- [ ] `relay_tx` must keep nonce-increment *before* signature failure paths it guards, and the + permission context must be the target's (utility `dispatch_call` handles it). +- [ ] `accept_subsidy` fee redirection: pool-level `has_pending_subsidy` check must match + dispatch-time behavior or the paying key can be griefed. + +## 7. Test map + +`pallets/runtime/tests/src/relayer_test.rs` (subsidy lifecycle, tx+protocol fee consumption +:422, batched subsidised calls :540, reserve/settle :689, relay happy/unhappy :797/:842). +Integration: `integration/tests/relayer_negative.rs`, `offchain_signatures.rs:287-325`. diff --git a/docs/spec/16-multisig.md b/docs/spec/16-multisig.md new file mode 100644 index 0000000000..77caa41774 --- /dev/null +++ b/docs/spec/16-multisig.md @@ -0,0 +1,117 @@ +# 16 — MultiSig + +Sources: `pallets/multisig/src/lib.rs`, `primitives/src/multisig.rs`, +`pallets/runtime/common/src/fee_details.rs`. +Related specs: [01-identity-keys](01-identity-keys.md) (KeyRecords, authorizations), +[14-fees-and-extensions](14-fees-and-extensions.md) (fee redirection). + +## 1. Purpose + +An m-of-n multisig **account** whose signers approve proposals (arbitrary runtime calls) +executed with the multisig account as origin. The multisig account is itself an identity key +(secondary key of the creator's identity by default); its signers are dedicated keys that belong +to no identity. Signers never need POLYX — fees are redirected (§6). + +## 2. Data model & storage (pallets/multisig/src/lib.rs) + +| Item | Purpose | Ref | +|---|---|---| +| `MultiSigSigners` (ms, signer) → bool / `NumberOfSigners` | accepted signers | :721/:726 | +| `MultiSigSignsRequired` | threshold; doubles as existence marker | :730 | +| `Proposals` / `ProposalVoteCounts` / `ProposalStates` / `Votes` | proposal call, approvals/rejections, state, per-signer vote | :741/:776/:783/:748 | +| `NextProposalId` / `MultiSigNonce` | sequences | :735/:717 | +| `PayingDid` | identity whose primary key pays proposal fees | :762 | +| `AdminDid` | admin identity (via-admin extrinsics) | :769 | +| `AuthToProposalId` (ms, auth_id) → proposal_id | join-identity proposal mapping | :800 | +| `LastInvalidProposal` | proposals ≤ id are invalidated | :811 | +| `ExecutionReentry` | reentrancy guard | :796 | +| `TransactionVersion` | all pending proposals wiped on tx-version bump (`on_runtime_upgrade` :189-214) | :807 | + +`ProposalState = Active { until: Option } | ExecutionSuccessful | ExecutionFailed | +Rejected` (primitives/src/multisig.rs:29-42). + +## 3. Creation & identity linkage + +`create_multisig(signers, sigs_required, permissions)` (idx 0, :224-244 → :996-1029): +- Caller: permissioned key; **custom permissions require the primary key** + (`ensure_valid_origin(origin, permissions.is_some())`, :232-233). +- Multisig address = `hash(b"MULTI_SIG", nonce, caller)` (:1283-1289) — deterministic, unlinked. +- Each signer gets an `AddMultiSigSigner` authorization (:929-943); threshold bounds checked + (`ensure_sigs_in_bounds`: threshold ≥ 1, signers ≥ threshold, :900-904). +- `PayingDid` = creator DID (:1015); the multisig account **immediately joins the creator's + identity as a secondary key** via `unsafe_join_identity` with the given permissions + (default `Permissions::empty()`) (:1026, :241). +- Signer acceptance (`accept_multisig_signer`, idx 4, :317 → :1224-1272): consumes the auth; + signer key must be completely unlinked (not identity- or multisig-linked, :1243-1247); no + multisig-as-signer nesting (:1233); records `KeyRecord::MultiSigSignerKey(ms)` (:1259-1262); + **invalidates all outstanding proposals** (:1251). +- The multisig can later become another identity's key (or primary key) only through the normal + identity auth flows executed via proposals (`approve_join_identity`/`join_identity`, §5). + +## 4. Signer & threshold management + +| Extrinsic (idx) | Origin | Ref | +|---|---|---| +| `add_multisig_signers(5)` / `remove_multisig_signers(6)` | **the multisig account itself** (i.e. via an executed proposal) | :332/:343 | +| `add_multisig_signers_via_admin(7)` / `remove_multisig_signers_via_admin(8)` | **primary key of `AdminDid`** (:864-872) | :366/:385 | +| `change_sigs_required(9)` / `change_sigs_required_via_admin(10)` | multisig itself / admin | :406/:422 | +| `add_admin(11)` / `remove_admin(17)` / `remove_payer(13)` | multisig itself | :439/:552/:474 | +| `remove_admin_via_admin(12)` / `remove_payer_via_payer(14)` | admin primary key / payer primary key (:874-882) | :457/:489 | + +Rules: removing signers can't violate `signers ≥ threshold` (:973-977); threshold changes are +bounds-checked (:1304) and **invalidate pending proposals** (:1306); max 50 signers +(`MaxMultiSigSigners = 50`, `pallets/runtime/develop/src/runtime.rs:113`; checks :853-862). + +## 5. Proposal lifecycle + +- `create_proposal(ms, call, expiry)` (idx 1, :246 → :1032-1057): **signers only** (:1038); + expiry must be future; **proposer auto-approves** (:1056) — a 1-of-n multisig executes + immediately. +- `approve(ms, id, max_weight)` (idx 2, :280 → :1060-1098): signer-only; proposal Active, + unexpired, not invalidated (:906-927, `LastInvalidProposal` :1327-1335); `AlreadyVoted` guard + (:1069-1072); executes at threshold (:1081). +- Execution (:1101-1168): call taken from storage; `max_weight` must cover the call + (`MaxWeightTooLow` :1117-1122); dispatched as `Signed(multisig)` wrapped in + **`with_call_metadata`** (:1125, permission checks see the inner call — doc 02 §3) with a + reentrancy guard (`NestingNotAllowed` :1127-1135); state → `ExecutionSuccessful/Failed`; + `ProposalExecuted { result }`. +- `reject(ms, id, max_weight)` (idx 3, :301 → :1171-1221): proposer may retract while sole + approver (:1183-1190); rejection quorum `rejections > NumberOfSigners − threshold` ⇒ + `Rejected` and proposal removed (:1203-1216). +- **Join-identity flow**: `approve_join_identity(ms, auth_id)` (idx 15, :512-538) — first call + creates an internal proposal `join_identity { auth_id }` and records `AuthToProposalId`; + subsequent calls approve it. `join_identity(16)` (:541-549) is callable only by the multisig + itself (proposal execution) and delegates to `Identity::join_identity`. +- Invalidation events: signer set changes (:1251, :986) and threshold changes (:1306) bump + `LastInvalidProposal`; runtime tx-version bumps wipe everything (:189-214). + +## 6. Fee payment (signers pay nothing) + +`fee_details.rs` redirection (doc 14 §3): `create_proposal`/`approve`/`reject` → `PayingDid`'s +primary key, else the multisig account itself (:92-111, :160-183); `accept_multisig_signer` → +auth issuer's primary key (:133-140); `approve_join_identity` → JoinIdentity auth issuer +(:141-159). Duplicate votes are rejected at the transaction-pool level (`AlreadyVoted` +pre-checks, :143-147/:170-174) so griefing by re-voting doesn't drain the payer. Failed +dispatches decrement auth retry counts (doc 01 §5). + +## 7. Invariants & review checklist + +- [ ] `threshold ≥ 1 ∧ accepted_signers ≥ threshold` at every mutation point (create/remove/ + change; :900-904). +- [ ] Signer keys must be exclusively multisig-linked (`MultiSigSignerKey`); they can never be + identity keys simultaneously (identity pipeline rejects them, doc 02 §4). +- [ ] Any change to the signer set or threshold must invalidate outstanding proposals — an old + proposal must not execute under a new quorum regime. +- [ ] Proposal execution must keep `with_call_metadata` + reentrancy guard; removing either + enables permission bypass or recursive execution. +- [ ] Multisig-origin admin calls (`add_multisig_signers` etc.) must only be reachable via + executed proposals (origin = ms account). +- [ ] Fee-redirection pre-checks in fee_details must stay consistent with dispatch-time vote + logic (a mismatch enables free spam or blocks legit votes). + +## 8. Test map + +`pallets/runtime/tests/src/multisig.rs` (30 tests: creation/threshold bounds :87, join :108, +signer add/remove :261/:361, primary-key rotation :452-527, admin flows :542-984, approval +closure :712, rejections :791), `transaction_payment_test.rs:775` (AlreadyVoted at pool), +`fee_details.rs` (payer matrix). diff --git a/docs/spec/17-utility.md b/docs/spec/17-utility.md new file mode 100644 index 0000000000..c4c3c4d183 --- /dev/null +++ b/docs/spec/17-utility.md @@ -0,0 +1,59 @@ +# 17 — Utility (Batching & Call Wrappers) + +Sources: `pallets/utility/src/lib.rs` (Substrate fork "+ permissions checks"). +Related specs: [02-permissions](02-permissions.md) §3 (nested-call metadata), +[14-fees-and-extensions](14-fees-and-extensions.md) (payer context), [15-relayer](15-relayer.md) +(`relay_tx` lives in Relayer in v8; subsidised batch limits). + +## 1. Purpose + +Standard utility batching, forked to preserve Polymesh's permission model: every inner call is +dispatched under `with_call_metadata`, so secondary-key extrinsic permissions and agent-group +checks evaluate **each inner call individually**, not the outer `utility.*` wrapper. + +## 2. Extrinsics + +| Extrinsic (call_index) | Origin | Semantics | Ref | +|---|---|---|---| +| `batch(0)` | any except None (:224-226) | stop on first error but return `Ok`; events `BatchInterrupted { index, error }` / `ItemCompleted` / `BatchCompleted` | :213-259 | +| `batch_all(2)` | any except None | **atomic** — first error rolls back the whole batch (:317-324); nested `batch_all` filtered for non-root (:300-307) | :274-330 | +| `force_batch(4)` | any except None | continue on error; `ItemFailed`; `BatchCompletedWithErrors` or `BatchCompleted` | :368-414 | +| `dispatch_as(3)` | **root** (:522) | dispatch under arbitrary origin, bypasses filters; **temporary fee-payer = the as-origin account** (:526-534) | :338 → :517-544 | +| `with_weight(5)` | **root** (:429) | dispatch with caller-declared weight | :422-436 | +| `as_derivative(9)` | signed | pseudonym account = `blake2_256("modlpy/utilisuba", who, index)` (:578-585); dispatches as Signed(derivative); **payer = derivative account** (:556-562) | :444 → :546-575 | + +Batch size cap: `batched_calls_limit` extra-constant (:157-171, `TooManyCalls`). Root callers +bypass call filters inside batches (:228, :239-240). Call indices 1/6/7/8 are gaps — `relay_tx`, +`UniqueCall`, `batch_old`/`batch_atomic`/`batch_optimistic` were removed (relay_tx now in +Relayer, doc 15 §5; the old variants survive only in `previous_release` integration tests). + +The pallet is stateless (no storage, :106-107). + +## 3. Permission & payer semantics for nested calls + +- `dispatch_call` (:489-502) wraps every inner dispatch in `with_call_metadata` (:494); so does + `run_with_temporary_payer` (:589-612 at :601) used by `dispatch_as`/`as_derivative`. Multisig + proposal execution uses the same wrapper (multisig lib.rs:1125). +- Consequence: a secondary key with permission for `utility.batch` but not `asset.issue` cannot + smuggle an `asset.issue` inside a batch — the inner check fails that item (batch: interrupt; + batch_all: rollback; force_batch: item failure). +- `run_with_temporary_payer` swaps `CurrentPayer` around the inner dispatch, so protocol fees + charged by inner calls hit the derivative/as-origin account (doc 14 §2). +- Subsidised users: relayer's `SubsidyFilter` allows non-nested `batch`/`batch_all`/`force_batch` + with ≤ 7 inner calls, each individually whitelisted (runtime.rs:386-398, doc 15 §4). + +## 4. Invariants & review checklist + +- [ ] Every dispatch path in this pallet must wrap inner calls in `with_call_metadata` — new + wrapper extrinsics too. +- [ ] `batch_all` nesting restriction for non-root must stay (recursion/filter-bypass guard). +- [ ] `dispatch_as`/`with_weight` must remain root-only; `as_derivative` payer swap must restore + the previous payer (RAII pattern in `run_with_temporary_payer`). +- [ ] `batch` returns `Ok` even when interrupted — callers/tests must check events, not just + dispatch success. + +## 5. Test map + +`pallets/runtime/tests/src/utility_test.rs` (26 tests: early exit :95, secondary-key +permissions :207, batch_all revert/nesting :498/:605, size limit :657, force_batch :673, +committee origins :779-847, with_weight :881, as_derivative :908). diff --git a/docs/spec/18-confidential-assets.md b/docs/spec/18-confidential-assets.md new file mode 100644 index 0000000000..28ce361066 --- /dev/null +++ b/docs/spec/18-confidential-assets.md @@ -0,0 +1,140 @@ +# 18 — Confidential Assets (DART) + +Sources: `pallets/confidential-assets/src/{lib.rs,settlement.rs,curve_tree.rs}`, +`worker/` (proof-execution engine), external crate `polymesh-dart` +(github.com/PolymeshAssociation/polymesh-dart, pinned in root `Cargo.toml:39-41`). +Availability: **develop & testnet only** (pallet index 70); not in mainnet. +Related specs: [14-fees-and-extensions](14-fees-and-extensions.md), [README](README.md) runtime matrix. + +## 1. Purpose & privacy model + +Confidential assets provide **sender, receiver, asset-id and amount privacy** (lib.rs:4-13) via +the DART protocol (ZK proofs implemented in the external `polymesh-dart` crate): + +- No plaintext balances on chain: account/asset state exists as commitments — leaves in + **curve trees**. State transitions consume the old state with a **nullifier** and append a new + commitment (lib.rs:2477-2496, 2564-2618). +- Registration is public (accounts/keys map to DIDs: `AccountDid`, `EncryptionKeyDid` + lib.rs:773-779), but transfer extrinsics require only `ensure_signed` — the submitting origin + proves authority *inside the ZK proof*, not via the identity pipeline (e.g. `create_settlement` + lib.rs:1418, affirmations :1441/:1464). +- Membership proofs reference a historical tree **root** (`root_block`); the pallet accepts only + roots younger than configured maximums — bounding prover staleness and enabling pruning. +- Proof verification runs **outside the runtime** in the polymesh-worker engine (§6). + +## 2. Data model & storage (pallets/confidential-assets/src/lib.rs) + +- Assets: `Details` (supply/owner/data :734), `Keys` — per-asset **auditors** + (decrypt-only observers) and **mediators** (must affirm), bounded by `MaxAssetAuditors`/ + `MaxAssetMediators` = 2 (:739, :68-74); every asset needs ≥1 auditor or mediator + (`NoAuditorsOrMediators`, :2521-2525). `NextAssetId` :730, names/symbols/decimals :744-755. +- Accounts: `AccountDid`/`EncryptionKeyDid`/`DidAccounts` (:773-791), + `AccountAssetRegistrations` (per-asset init guard :814-822), `FeeAccountDid` (:795). +- **Three curve trees** (storage groups): + - Asset tree (mutable leaves; leaf index = asset id): `AssetLeaves`/`AssetInnerNodes`/ + `AssetCurveTreeCurrentRoot`/`AssetCurveTreeRoots`(historical)/last-update/last-pruned + (:828-865). + - Account tree (append-only + nullifiers): `AccountLeaves`, `NextAccountLeafIndex`, + `LastCommittedAccountLeafIndex` (batched inserts), inner nodes, roots, + `AccountStateCommitmentNullifiers` (:871-928). + - Fee-account tree (append-only + nullifiers): mirror set (:934-998). +- Settlements: `SettlementState` (:1002), `SettlementLegs` (encrypted legs :1020-1025), + `SettlementPendingAffirmations` / `SettlementPendingFinalizations` (:1035-1042), + `LegAffirmationStatus` (:1047-1056), memo (:1007). +- Worker session: `CurrentWorkerSessionId` (:1060). + +## 3. Extrinsics (selected; full list lib.rs:1153-1883) + +| Call (idx) | Who | Behavior | Ref | +|---|---|---|---| +| `register_accounts(0)` / `register_encryption_keys(1)` | permissioned DID | link account/encryption keys to DID (proof-verified) | :1153/:1210 | +| `create_asset(2)` | permissioned DID (issuer) | asset with auditor/mediator key sets | :1250 | +| `register_account_assets(3)` | account owner | init per-asset account state (batched proof) | :1282 | +| `mint_asset(4)` | account owner **and** asset owner | supply-capped (`MaxTotalSupply = polymesh_dart::MAX_BALANCE`) | :1353-1372 | +| `create_settlement(5)`, `sender/receiver/mediator_affirmation(6-8)`, `sender_update_counter(9)`, revert affirmations (10/11), `receiver_claim(12)`, `batched_settlement(13)` | **any signed account** — authority proven in ZK | §4 | :1414-1601 | +| `register_fee_accounts(14)` / `topup_fee_accounts(15)` | permissioned DID | move public POLYX into the pallet fee pool; private fee balance becomes a fee-tree commitment | :1620/:1689 | +| `submit_batched_proofs(16)` | any signed | atomic batch of ops | :1771 | +| `relayer_submit_batched_proofs(17)` | any signed **relayer** | private fee payment (§5) | :1800 | +| `execute_instant_settlement(18)`, instant affirmations (19/20) | any signed | single-tx create+affirm+execute | :1822-1883 | + +No freeze/burn extrinsics; no manual root updates (hooks maintain roots, §6). + +## 4. Settlement flow (settlement.rs) + +- Parties per leg: Sender, Receiver, 0..N Mediators (`LegAffirmParty` :36-40). Legs stored + encrypted. Creation verifies a `SettlementProof` against an **asset-tree root** (lib.rs: + 1970-1987); pending affirmations = (2 + mediators) per leg (:1998-2000). +- Sender/receiver affirmations carry an account-state update: nullifier spend + new commitment, + verified against an **account-tree root** (:240-331; lib.rs:2477-2496). Mediator affirmation + carries `accept: bool` — reject flips the settlement to Rejected (lib.rs:2246-2259). +- State machine (`SettlementStatus` :83-88): `Pending → Executed → Finalized` or + `Pending → Rejected → Finalized`. Execution is a status flip when pending affirmations hit 0 + (:484-551); actual balance effects happen through each party's own proofs — sender finalizes + with `sender_update_counter` (:361-385), receiver credits funds with `receiver_claim` + (:458-482). When pending finalizations hit 0, storage is pruned (`finalize` :554-597). +- Reverts: sender/receiver can revert affirmations while Pending/Rejected (:390-455); reverting + a Pending settlement rejects it (:416-419). Transition guards in `set_party_status` + (:135-198). + +## 5. Private fee payment (fee accounts + relayer) + +Normal confidential extrinsics pay public POLYX fees, which links the submitting key. For full +privacy: +1. Users pre-fund **fee accounts**: public POLYX is deposited into the pallet pool account + (`PalletId "pm/dartf"`, lib.rs:92-95); the user's balance becomes a private commitment in the + fee-account tree (:1620-1673, :1689-1758). +2. A third-party **relayer** submits the user's batched proofs via + `relayer_submit_batched_proofs` (:1800 → :2338-2386): the `FeePaymentWithBatchedProofs` + includes a `FeeAccountPaymentProof` bound to the batch content hash (:2356), spending the + user's private fee balance (nullifier double-spend check :2411). The pool reimburses the + relayer publicly (:2429); the fee must cover the weight-derived tx fee (:2399-2402, + commission permitted). **The batch rolls back atomically on failure but the relayer is still + paid** (:2360-2383) — relayers are compensated for wasted work; users must trust their proofs. + +This decouples the fee-paying origin from sender/receiver/mediator identities. + +## 6. Curve trees, roots & the worker engine + +- Trees are recomputed **once per block**: extrinsics only append leaves; `on_finalize` commits + batched leaves and re-roots (`finalize_block` lib.rs:2936-2944, `commit_leaves_to_tree` + :2895-2913); `on_initialize` starts the per-block worker session and prunes old roots + (:2926-2934). +- Roots are timestamped per block (`TimestampedTreeRoot`, curve_tree.rs:159-254) and kept + historically; a proof's `root_block` is accepted only if younger than + `MaxAssetCurveTreeRootAge` (24h) / `MaxAccountCurveTreeRootAge` (2d) / + `MaxFeeAccountCurveTreeRootAge` (2d) (curve_tree.rs:50-148; testnet values + `pallets/runtime/testnet/src/runtime.rs:112-116`; short values under `ci-runtime`). +- `MinCurveTreeRootUpdateInterval` (10 min): quiescent trees get their root re-stamped so provers + always find a fresh root (lib.rs:2735-2786). Pruning keeps ~100 recent blocks, ≤10 pruned per + block (lib.rs:97-105, 2788-2893). +- **Worker**: proof verification executes in `polymesh-worker` (native or chain-upgradeable + PolkaVM/WASM modules — committed blobs `worker/polymesh-worker-protocol-dart-v1.*`; + `worker/README.md`). The pallet calls it synchronously via a runtime interface + (`submit_and_wait` lib.rs:2970-2974); request enum `VerifyDartAssetRequest` + (worker/protocol/dart-v1/src/verify.rs:30-124) covers registration, minting, settlement, + affirmation, revert, claim, fee-payment and key-distribution proofs. A worker panic ⇒ proof + rejected (worker/native/src/lib.rs:48-74). + +## 7. Invariants & review checklist + +- [ ] **Nullifier uniqueness** (`NullifierAlreadyUsed`) is the double-spend defense — every + state-consuming proof path must check-and-insert atomically. +- [ ] Root age windows bound proof staleness; extending max ages or pruning windows changes the + security/liveness tradeoff — keep `RECENT_BLOCKS_TO_KEEP` ≥ age windows in blocks. +- [ ] Leaf commits are batched per block; any new leaf-writing path must go through the + `NextLeafIndex`/`LastCommitted*` machinery or root recomputation misses leaves. +- [ ] Settlement pending counters (affirmations/finalizations) must be exact — premature zero ⇒ + premature execute/finalize. +- [ ] Fee-pool solvency: pool balance must always cover Σ outstanding private fee balances + (deposits on register/topup; withdrawals only via verified payment proofs). +- [ ] Proof context binding: batched proofs bind to content hash (`fee_payment_ctx`), settlement + proofs bind to root_block — never verify a proof without its binding context. +- [ ] Worker sessions must bracket every block (`NoCurrentWorkerSession` guard); upgrading the + worker protocol blob is consensus-critical. + +## 8. Test map + +Pallet `testing.rs` (off-chain prover mirroring all trees, proof generation via the worker +testing module). Integration: `integration/tests/confidential_transfers.rs` (+`_negative.rs`), +helper `integration/src/confidential_assets_helper.rs` (client-side proof building). +Worker backends: `worker/tester/` with sample proofs in `worker/tester/data/`. diff --git a/docs/spec/19-pips.md b/docs/spec/19-pips.md new file mode 100644 index 0000000000..3916c7d491 --- /dev/null +++ b/docs/spec/19-pips.md @@ -0,0 +1,103 @@ +# 19 — PIPs (On-Chain Governance) + +Sources: `pallets/pips/src/{lib.rs,types.rs}`. +Related specs: [22-treasury-committees](22-treasury-committees.md) (committee origins, release +coordinator), [14-fees-and-extensions](14-fees-and-extensions.md) (`PipsPropose` fee). + +## 1. Purpose + +Polymesh Improvement Proposals: on-chain proposals (arbitrary root-dispatched calls) that the +community signals on with POLYX-bonded votes, and the **Governance Committee (GC)** decides on. +Community sentiment is advisory; the GC (via committee voting-majority origin) approves/rejects. +Approved PIPs execute as **root** through the scheduler. + +## 2. Data model (pallets/pips/src/types.rs) + +- `Proposer`: `Community(AccountId) | Committee(Technical | Upgrade)` (:125, :115). +- `ProposalState`: `Pending | Rejected | Scheduled | Failed | Executed | Expired` (:187-202). + Active = Pending or Scheduled (lib.rs `is_active`). +- `Vote(bool, Balance)` — aye/nay with bonded "conviction" deposit (:177); + `VotingResult { ayes_count, ayes_stake, nays_count, nays_stake }` (:163). +- `SnapshottedPip { id, weight: (bool, Balance) }` — net stake sign/magnitude (:238); + ordering `compare_spip` ranks by (sign, magnitude). +- `SnapshotResult`: `Approve | Reject | Skip` (:269). +- `DepositInfo { owner, amount }` (:207). + +### Storage (pallets/pips/src/lib.rs) + +Config values (all root-set, i.e. themselves PIP-changeable): `PruneHistoricalPips` (:400), +`MinimumProposalDeposit` (:404), `DefaultEnactmentPeriod` (:408), `PendingPipExpiry` (:413), +`MaxPipSkipCount` (:418), `ActivePipLimit` (:422). +State: `Proposals` (:457), `ProposalStates` (:512), `ProposalMetadata` (:439), `ProposalResult` +(:462), `ProposalVotes` (:467), `Deposits` (:444), `LiveQueue` (live priority queue, :483), +`SnapshotQueue`/`SnapshotMeta` (:492/:496), `PipSkipCount` (:502), `CommitteePips` (:508), +`PipToSchedule` (:472), `PendingRefunds`/`VotesToBePruned` (lazy cleanup queues, :517/:521), +`ActivePipCount` (:434). +Genesis (src/chain_spec/common.rs:185-197): min deposit 2,000 POLYX, max skip 2, prune=false. + +## 3. Extrinsics + +| Extrinsic (idx) | Who | Behavior | Ref | +|---|---|---|---| +| `set_prune_historical_pips(0)`, `set_min_proposal_deposit(1)`, `set_default_enactment_period(2)`, `set_pending_pip_expiry(3)`, `set_max_pip_skip_count(4)`, `set_active_pip_limit(5)` | **root** | governance parameters | :571-694 | +| `propose(6)` | community (signed, permissioned) **or** Technical/Upgrade committee origin | §4 | :711-826 | +| `vote(7)` | any permissioned signer | bonded, **non-additive** vote (replaces previous; deposit re-locked to the new amount, :888-895); proposer voting again must keep ≥ min deposit (:873-880); only community PIPs in Pending | :851-914 | +| `approve_committee_proposal(8)` | GC voting majority | schedule a **committee** PIP (community ones go via snapshot) | :932-951 | +| `reject_proposal(9)` | GC voting majority | reject any active PIP; unschedule/unsnapshot; refunds queued | :971-984 | +| `prune_proposal(10)` | GC voting majority | GC storage cleanup of non-active PIPs | :1001-1012 | +| `reschedule_execution(11)` | **release coordinator** of the GC | move a Scheduled PIP's execution block (min next block) | :1027-1059 | +| `clear_snapshot(12)` | any **GC member** | drop current snapshot | :1073-1093 | +| `snapshot(13)` | any **GC member** | clone `LiveQueue` into `SnapshotQueue` + meta | :1109-1142 | +| `enact_snapshot_results(14)` | GC voting majority | apply Approve/Reject/Skip to the snapshot, **lowest priority first zipped in reverse** (§5) | :1170-1253 | +| `execute_scheduled_pip(15)` / `expire_scheduled_pip(16)` | **root** (scheduler-dispatched) | execute / expire | :1268-1309 | + +## 4. Community proposal lifecycle + +1. **Propose** (:713-826): community proposer bonds `deposit ≥ MinimumProposalDeposit` (locked + under `PIPS_LOCK_ID`, :1347-1360); `ActivePipCount < ActivePipLimit` enforced (committee PIPs + exempt and deposit-free, :752-757); protocol fee `PipsPropose` charged for both. The proposal + auto-casts an aye vote at the deposit (:806) and enters `LiveQueue` (:809). If + `PendingPipExpiry` is set, an expiry task is scheduled (:788-791, root-origin + `expire_scheduled_pip`). +2. **Voting** (:853-914): each voter bonds a deposit; vote weight = deposit; changing a vote + adjusts the lock up/down. `LiveQueue` re-sorts on every vote (net stake ordering, + `aggregate_result` :1453-1466). +3. **Snapshot** (:1113-1142): a GC member freezes the queue. +4. **Enact** (:1172-1253): GC majority submits per-PIP results matched against the snapshot + queue **from the end** (highest priority first in `results`); mismatch ⇒ `SnapshotIdMismatch`. + `Skip` bumps `PipSkipCount` and fails once it would exceed `MaxPipSkipCount` + (`CannotSkipPip`, :1206-1210); skipped PIPs stay pending. Reject ⇒ refund queue; Approve ⇒ + scheduled. +5. **Scheduling** (:1502-1533): execution at `now + max(DefaultEnactmentPeriod, 1)` via the + named scheduler task; release coordinator can reschedule (:1029-1059). +6. **Execution** (:1668-1687): dispatched as **root**; result ⇒ `Executed` or `Failed`; + `maybe_prune` applies `PruneHistoricalPips`. +7. **Expiry**: Pending PIPs past `PendingPipExpiry` are expired by the scheduled task + (:1296-1309) → `Expired` state. + +**Refunds**: deposits are *not* slashed in any path; every terminal transition queues the PIP in +`PendingRefunds`, drained lazily in `on_idle` (`remove_pending_storage` :1716-1762) which +releases locks (bounded per block by `MaxRefundsAndVotesPruned`); vote records pruned similarly. + +Committee (Technical/Upgrade) PIPs: no deposit, no community voting, no snapshot — GC approves +directly via `approve_committee_proposal`. + +## 5. Invariants & review checklist + +- [ ] Deposits: sum of `Deposits` per account ≤ their `PIPS_LOCK_ID` lock; every terminal state + must enqueue refunds (no slash paths exist — introducing one is a design change). +- [ ] `LiveQueue` must stay sorted and in sync with `ProposalResult` (insert :1432, adjust + :1482); snapshot enactment relies on exact id matching in reverse order. +- [ ] Skip accounting: `PipSkipCount` monotonic, capped by `MaxPipSkipCount`. +- [ ] `ActivePipCount` increment/decrement symmetry (`decrement_count_if_active`) — a drift + bricks community proposing via `TooManyActivePips`. +- [ ] Scheduled execution/expiry tasks must be cancelled when PIPs are rejected/rescheduled + (`maybe_unschedule_pip` :1577, `unschedule_pip` :1610) or the scheduler root-dispatches a + stale task. +- [ ] PIP execution is root dispatch of arbitrary calls — the GC approval origin + (`VotingMajorityOrigin`) is the entire security boundary. + +## 6. Test map + +`pallets/runtime/tests/src/pips_test.rs` (proposal lifecycle, snapshots, enactment, skips, +expiry, refunds). Committee interplay: `committee_test.rs`. diff --git a/docs/spec/20-staking-validators.md b/docs/spec/20-staking-validators.md new file mode 100644 index 0000000000..8c8b5646c9 --- /dev/null +++ b/docs/spec/20-staking-validators.md @@ -0,0 +1,109 @@ +# 20 — Staking & Permissioned Validators + +Sources: `pallets/validators/src/{lib.rs,permissioned.rs,inflation.rs,types.rs}` (local pallet), +forked `pallet-staking` (FORK = `substrate/frame/staking` in the pinned +`PolymeshAssociation/polkadot-sdk` checkout), runtime wiring in +`pallets/runtime/common/src/runtime.rs` (RTC). +Related specs: [22-treasury-committees](22-treasury-committees.md) (governance origins), +[01-identity-keys](01-identity-keys.md) (validator identities). + +## 1. Purpose + +Polymesh runs NPoS staking (forked `pallet-staking`) with a Polymesh twist: **validators must be +pre-approved by governance** ("permissioned identities"), each approved identity has a bounded +number of validator slots, commissions are capped chain-wide, slashing is governance-switchable, +era payouts are automatic, and inflation is capped by a fixed yearly reward once total issuance +reaches a threshold. + +## 2. Validators pallet (registry + policy) + +Storage (pallets/validators/src/lib.rs): `PermissionedIdentity` (DID → +`PermissionedIdentityPrefs { intended_count, running_count }`, :127-130; types.rs:9-19), +`SlashingAllowedFor` (`SlashingSwitch: Validator | ValidatorAndNominator | None`, default +**None**, :132-135; types.rs:44-52), `ValidatorCommissionCap` (Perbill, :137-140), +`CurrentPayoutEra` / `PendingPayouts` (auto-payout queue, :142-156). + +| Extrinsic (idx) | Origin | Behavior | Ref | +|---|---|---|---| +| `add_permissioned_validator(0)` | `AdminOrigin` (= Root; GC reaches it via PIPs) | approve DID; default `intended_count = 1`, capped by `MaxValidatorPerIdentity × validator_count` | lib.rs:274 → permissioned.rs:218-256 | +| `remove_permissioned_validator(1)` | AdminOrigin | de-approve (does **not** auto-chill — see §3) | lib.rs:291 → permissioned.rs:258 | +| `change_slashing_allowed_for(3)` | root | set the slashing switch | lib.rs:301 → permissioned.rs:278 | +| `update_permissioned_validator_intended_count(4)` | AdminOrigin | adjust slots | lib.rs:311 → permissioned.rs:288 | +| `chill_from_governance(5)` | AdminOrigin | chill all given stashes **and remove the identity's permission** (permissioned.rs:356) | lib.rs:326 → permissioned.rs:333-363 | +| `set_commission_cap(6)` | AdminOrigin | set cap and **clamp every existing validator's commission** to it (permissioned.rs:320-323) | lib.rs:341 → permissioned.rs:307 | + +Automatic payouts: `end_era` snapshots session validators into `PendingPayouts` +(permissioned.rs:168-181); `on_initialize` drains them weight-metered by `MaxPayoutWeight` +(lib.rs:262-267; permissioned.rs:366-478) calling `do_payout_stakers_by_page` — stakers don't +need to claim manually. + +## 3. The `PermissionedStaking` hook (fork ↔ validators pallet) + +The fork adds `Config::Permissioned: PermissionedStaking` (FORK/src/pallet/mod.rs:342-347; +trait FORK/src/permissioned_staking.rs:11-78); runtime binds it to `Validators` (RTC:349). +Enforcement points inside forked staking: + +| Hook | Fork call site | Validators impl | +|---|---|---| +| `on_validate` — commission ≤ cap (also for **existing** validators re-calling `validate`); new validators need a DID that is permissioned with a free slot (`running_count < intended_count`) | FORK mod.rs:1359-1363 | permissioned.rs:103-129 | +| `on_chill` / `on_nominate` / `on_kill` — release the slot + key refcount | impls.rs:408-413/mod.rs:1445-1448/impls.rs:810-813 | permissioned.rs:132-145, 196-211 | +| `is_validator_compliant` — election snapshot filters: only compliant validators become targets/self-voters (DID exists, permissioned, bond ≥ `MinValidatorBond`) | impls.rs:969-990, 1060-1066 | permissioned.rs:148-154 | +| `who_to_slash` / slashing gates — offences zeroed when switch is `None`; nominators slashed only under `ValidatorAndNominator` | impls.rs:1283-1292; slashing.rs:299-305, 615-628 | permissioned.rs:163-165 | +| `reapable` — Polymesh allows reaping at `amount <= ED` | mod.rs:1848-1855, impls.rs:209-212 | permissioned.rs:99-101 | +| `add_pending_payouts` — era-end payout trigger | impls.rs:603-606 | permissioned.rs:168-181 | + +Key consequence: **de-permissioning does not force a chill** — the validator merely stops being +electable at the next election (snapshot filter). `chill_from_governance` is the forcible path. + +## 4. Inflation & rewards + +- `EraPayout = pallet_validators::PolymeshConvertCurve` (RTC:338; + permissioned.rs:21-46). `compute_total_payout` (inflation.rs:32-64): standard NPoS curve + (min 2.5%, **max 14%**, ideal stake 70%, falloff 5% — identical in all runtimes, e.g. + `pallets/runtime/mainnet/src/runtime.rs:186-195`) **until total issuance ≥ + `MaxVariableInflationTotalIssuance` (1B POLYX)**; then a fixed `FixedYearlyReward` + (140M POLYX/yr, prorated per era) applies with **zero remainder** (inflation.rs:51-60). +- `RewardRemainder = ()` — remainder dropped, not minted (RTC:329); rewards minted from void; + **slashes go to Treasury** (`Slash = Treasury`, RTC:331). + +## 5. Runtime parameters + +| Const | mainnet | testnet | develop | +|---|---|---|---| +| SessionsPerEra / BondingDuration / SlashDeferDuration | 6 / 28 / 14 (`mainnet/src/runtime.rs:158-160`) | 6 / 28 / 14 | 3 / 7 / 4 (`develop/src/runtime.rs:162-164`) | +| MaxValidatorPerIdentity | 33% (:166) | 33% | 33% | +| MaxVariableInflationTotalIssuance / FixedYearlyReward | 1B / 140M POLYX (:164-165) | same | same | +| MaxPayoutWeight | 20% of block (:167) | 10% | 5% | + +Election: `ElectionProviderMultiPhase` with **signed phase disabled** (`SignedPhase = 0`, +`pallets/runtime/common/src/lib.rs:155-157`), unsigned phase = ¼ epoch, `MaxWinners = 1000`, +SequentialPhragmen + `OffchainRandomBalancing`, on-chain fallback (RTC:762-827). +`cancel_deferred_slash` requires AdminOrigin (FORK mod.rs:1715-1726); `SlashDeferDuration` +gives governance 14 eras (mainnet) to cancel. + +## 6. Invariants & review checklist + +- [ ] `running_count ≤ intended_count` per permissioned identity; every validate/chill/nominate/ + kill path must inc/dec through the hook (slot leaks block honest validators). +- [ ] Commission cap must be enforced on *both* new and re-submitted `validate()` calls + (fork mod.rs:1359-1363 — regression-tested by + `staking_extra_tests.rs:83` `existing_validator_cannot_bypass_commission_cap`). +- [ ] Election compliance filters (targets *and* self-votes) are the only thing excluding + de-permissioned validators — keep both call sites (impls.rs:972, :1063). +- [ ] Slashing switch default None **by design**: whether and against whom slashing is enabled + is a governance (GC) decision via `change_slashing_allowed_for`; do not report the + default itself as a finding. Tests must instead cover that *turning it on* works — + offence reporting produces slash fractions and `who_to_slash` honours + `SlashingAllowedFor` once enabled — while offence handling keeps zeroing fractions + while disabled. +- [ ] Validator stashes hold identity key refcounts while validating + (`AccountKeyRefCount`, staking_extra_tests.rs:12-81) — keys can't leave their DID + mid-validation. +- [ ] Inflation cap boundary: at issuance ≥ 1B the fixed branch must return zero remainder + (treasury gets nothing from era payouts by design). + +## 7. Test map + +`pallets/validators/src/tests.rs` (ported upstream suite, 9.4k lines) + `mock.rs`; +Polymesh-specific: `pallets/runtime/tests/src/staking_extra_tests.rs` (permission lifecycle, +refcounts, commission-cap bypass). Fork-side hook tests in FORK/src/tests.rs. diff --git a/docs/spec/21-revive-evm.md b/docs/spec/21-revive-evm.md new file mode 100644 index 0000000000..b5f32d0b4d --- /dev/null +++ b/docs/spec/21-revive-evm.md @@ -0,0 +1,141 @@ +# 21 — Revive (EVM/ETH Support) & Precompiles + +Sources: `pallets/runtime/common/src/runtime.rs` (RTC — config, `EthExtraImpl`), +`pallets/precompiles/` (runtime-side precompile), `precompiles/` (Solidity interface crate), +forked `pallet-revive` (FORK = `substrate/frame/revive` in the pinned polkadot-sdk checkout). +Related specs: [14-fees-and-extensions](14-fees-and-extensions.md) (ETH fee path), +[02-permissions](02-permissions.md) (call-metadata swaps), [09-asset-transfers](09-asset-transfers.md) +(what ERC-20 ops map to). + +## 1. Purpose + +`pallet-revive` (index 80, all three runtimes) provides EVM & PolkaVM smart contracts plus an +Ethereum-compatible transaction path. Polymesh integrates it with the identity/permission system +via a **dispatch hook** that swaps call metadata, and exposes native assets to contracts through +a **fungible-asset precompile** (ERC-20/2612/3643/7943 surface). + +## 2. Runtime configuration (RTC:485-514) + +| Item | Value | +|---|---| +| `AddressMapper` | stock `AccountId32Mapper` (:497): eth address → AccountId32 = 20 bytes + 12×`0xEE` suffix (FORK/src/address.rs:130-135). **No identity linkage in the mapper** — the fallback account must be onboarded to a DID like any account before holding assets | +| `ChainId` | develop 1_641_818 / testnet 1_641_819 / mainnet 1_641_820 (`develop/src/runtime.rs:79` etc.) | +| `NativeToEthRatio` | `10^12` (:505) — POLYX 6 decimals ↔ ETH 18 decimals | +| Deposits | `DepositPerItem` 0.15 POLYX, `DepositPerByte` 0.06 POLYX, lockup 30% (`common/src/lib.rs:97-103`) | +| `UploadOrigin`/`InstantiateOrigin` | `EnsureSigned` (:500-501) — **contract deployment is open to any signed account, not identity-gated** | +| `AllowEVMBytecode` | true (:507); `DebugEnabled` false (:510) | +| **`DispatchHook`** | `pallet_precompiles::common::DispatchWithCallMetadata` (:513) — Polymesh-specific (§4) | +| `Precompiles` | `(pallet_precompiles::FungibleAssetInterface,)` (per-runtime, e.g. develop:201) | + +## 3. The ETH transaction path + +- eth-rpc sidecar (external `paritypr/eth-rpc` docker, see AGENTS.md) maps Ethereum JSON-RPC to + the runtime's `ReviveApi` and submits raw RLP as bare `eth_transact` extrinsics. +- `UncheckedExtrinsic` is revive's EVM wrapper (RTC:1073); decoding is Polymesh-customized in + `EthExtraImpl::try_into_checked_extrinsic` (RTC:946-1069): + 1. legacy/2930/1559 accepted; 7702/4844 rejected (:978-992); + 2. signer recovered → 0xEE fallback AccountId32 (:994-999); + 3. **subsidy support**: `check_subsidy_conditions(&signer, &call, storage_deposit)` — a + relayer subsidy can pay for ETH transactions too (:1014-1021, doc 15); + 4. **storage deposit pre-charged** from the fee key into the forked tx-credit pool, threaded + via `tx_ext.4.set_storage_deposit(...)` (:1023-1037, doc 14 §2). This charge lives in + `check()`, which also runs during read-only pool validation — safe because each + `validate_transaction` call mutates a throwaway overlay that is never persisted (sp-api + contract; executive docs: "Changes made to storage should be discarded"). The deposit is + charged exactly once, from committed state, at apply time, and `post_dispatch` always + settles/refunds it (doc 14 §2). Mirrors upstream pallet-revive's default impl verbatim; + 5. no tips (:1060-1062); extension tuple built with `SetOrigin::new_from_eth_transaction()` + (RTC:926-944). +- `SetOrigin` (upstream, FORK/src/evm/tx_extension.rs:48-99): a runtime-only-settable flag that + swaps the origin to `Origin::EthTransaction(signer)` — required by `eth_call`/ + `eth_instantiate_with_code`/`eth_substrate_call` and preventing their invocation from plain + signed extrinsics. It does **not** set identity context. +- Routing by `to` address: `to == RUNTIME_PALLETS_ADDR` (PalletId `py/paddr`, + FORK/src/lib.rs:2757-2762) ⇒ calldata SCALE-decodes to a `RuntimeCall` wrapped in + **`eth_substrate_call`** (FORK/src/evm/call.rs:143-158; zero value enforced) — i.e. an ETH + wallet can dispatch arbitrary Polymesh extrinsics. Otherwise ⇒ contract call / instantiate. +- Fee mapping: `WeightToFee = BlockRatioFee<30_000, 650_000_000>` shared with substrate txs + (RTC:225-231); eth gas × price splits into weight fee + storage deposit + (FORK/src/evm/call.rs:199-257). `ReviveApi` reports balances in 18-decimals + (`evm_balance`, macro at RTC:1102-1106 → FORK/src/lib.rs:2974-3235). + +## 4. Identity & permission integration (the critical part) + +Two Polymesh mechanisms close the "calls entering via revive carry `Revive.*` call metadata" +hole (doc 02 §3): + +1. **`DispatchHook`** (fork delta: `DispatchRuntimeCall` trait + `Config::DispatchHook`, + FORK/src/lib.rs:148-172, :243, wired into `eth_substrate_call` :1456-1477). + Polymesh's impl `DispatchWithCallMetadata` (pallets/precompiles/src/common.rs:86-104) + dispatches the inner call inside `with_call_metadata` (:102) — secondary-key permission + checks evaluate the **inner** extrinsic. +2. **Precompile dispatch**: `Common::call_runtime` / `with_runtime_call` + (common.rs:217-244/:250-263) also swap metadata (:227/:262), apply the `BaseCallFilter` + (:258), meter weight, and convert `DispatchError` into Solidity reverts (:64-74). + Root callers rejected, delegate-calls rejected, state changes in read-only context rejected + (:106-159). + +Fee-payer redirection (`fee_details`) and the relayer `SubsidyFilter` both **unwrap +`eth_substrate_call`** to inspect the inner call (RTC:195-205, :418-423). + +ETH-side accounts must still be onboarded to a DID before doing identity-gated things (the +integration tests onboard the 0xEE fallback account first — `integration/tests/revive_erc20.rs:92-94`). + +## 5. The fungible-asset precompile + +- Interface crate `precompiles/`: `sol!`-generated bindings + committed stub bytecode + (`FungibleAssetStub.sol`/`.bin`; regenerate with `scripts/build_precompile_stub.sh`, solc + 0.8.33 exactly). Surface = ERC-20 + ERC20Metadata + EIP-2612 permit + mint/burn + + ERC-7943 (canTransfer/forcedTransfer/frozen tokens) + ERC-3643 (pause/freeze/naming). +- Runtime side `pallets/precompiles/src/interface/mod.rs:49-138`: **address scheme** — + `asset_id (16 bytes) ‖ zeros ‖ prefix-id 8` (`AddressMatcher::VarPrefix`, :55-58; fork delta + enabling multi-address precompiles). The trailing bytes are the **precompile selector**: the + matcher validates them against the registered prefix id *before* any precompile code runs; + only then does `asset_id_from_address` decode the leading 16 bytes (:142-160). An address + whose suffix doesn't match never reaches this pallet, so each asset has exactly one valid + address per precompile interface — the suffix is not an aliasing surface. One precompile + instance serves *every* fungible asset; decimals fixed at 6 (:46). +- Call mapping (each dispatches the real extrinsic under the caller's account, with metadata + swap ⇒ full permission/compliance enforcement): + +| Solidity | Runtime call | Ref | +|---|---|---| +| `transfer`/`transferFrom` | `Settlement::transfer_funds` (doc 09 §4, incl. allowance spend) | interface/erc20.rs:66, 215 | +| `approve` | `Asset::approve` | erc20.rs:166 | +| `mint`/`burn` | `Asset::issue`/`redeem` (agent-gated) | polymesh_specific.rs:27, 58 | +| ERC-7943 forcedTransfer / setFrozenTokens | `Asset::controller_transfer` / `set_frozen_tokens` | erc7943.rs:83, 118 | +| ERC-3643 pause/unpause, setName, freeze wallet, ... | `Asset::freeze`/`unfreeze`/`rename_asset`/`set_holder_frozen`/ticker calls | erc3643.rs:40-136 | + +## 6. Fork deltas (vs upstream stable2603) — exactly four commits + +1. **DispatchHook** for `eth_substrate_call` (identity integration point) — FORK/src/lib.rs:148-172. +2. **Precompile improvements**: `AddressMatcher::VarPrefix`, block deployment at precompile + addresses, custom stub `CODE`. +3. No-account-reaping fix (`exec.rs`). +4. `NativeToEthRatio` as u64 (enables 10^12 for 6-decimal POLYX). + +`SetOrigin`, `eth_substrate_call` itself, and `BlockRatioFee` are upstream Parity features; +Polymesh customization of the fee/subsidy path lives in the runtime's `EthExtraImpl` override. + +## 7. Invariants & review checklist + +- [ ] Every path dispatching runtime calls from revive (hook, precompile, future additions) + must swap call metadata — regression-tested by `integration/tests/revive_permissions.rs` + (`erc20_mint_checks_secondary_key_permissions`, `substrate_call_checks_...`). +- [ ] `eth_substrate_call` unwrapping must stay in sync across: fee_details payer matching, + SubsidyFilter, and the dispatch hook. +- [ ] Precompile address decoding must validate the asset exists & is fungible + (interface/mod.rs:142-160) — collisions with contract addresses are prevented by the + matcher prefix + deploy-block fork delta. +- [ ] Balance conversions must use `NativeToEthRatio` consistently (18↔6 decimals); dust + handling via `new_balance_with_dust`. +- [ ] `SetOrigin` flag must remain non-codec (`#[codec(skip)]`) — a user-settable variant would + let anyone forge eth origins. +- [ ] Tuple index coupling `tx_ext.4` (doc 14 §1) on any TxExtension change. + +## 8. Test map + +Integration (need node + eth-rpc): `integration/tests/revive_erc20.rs`, `revive_erc3643.rs`, +`revive_erc7943.rs`, `revive_permissions.rs`, `revive_contracts.rs`, `revive_swap.rs`; helpers +`integration/src/{eth_helper,revive_helper}.rs`; fixtures `integration/contracts/` (regenerate +via `build.sh`). Fork unit tests: FORK/src/tests/sol.rs:525-614 (dispatch hook, eth origin). diff --git a/docs/spec/22-treasury-committees.md b/docs/spec/22-treasury-committees.md new file mode 100644 index 0000000000..9b704c7845 --- /dev/null +++ b/docs/spec/22-treasury-committees.md @@ -0,0 +1,105 @@ +# 22 — Treasury, Committees & Groups + +Sources: `pallets/treasury/src/lib.rs`, `pallets/committee/src/lib.rs`, +`pallets/group/src/lib.rs`, runtime wiring in `pallets/runtime/*/src/runtime.rs`. +Related specs: [19-pips](19-pips.md) (GC decisions), [03-claims](03-claims.md) (systematic CDD +claims for members), [20-staking-validators](20-staking-validators.md) (slashes → treasury). + +## 1. Committee pallet (instanced voting bodies) + +Instances (runtime indices 9-14): **PolymeshCommittee** (`Instance1` — the Governance +Committee/GC), **TechnicalCommittee** (`Instance3`), **UpgradeCommittee** (`Instance4`); each +paired with a `pallet_group` instance holding its membership. + +### Model (pallets/committee/src/lib.rs) + +- Members are **IdentityIds** (`Members` storage :183, mirrored from the group pallet via + `MembershipChanged`). Proposals are runtime calls stored by hash (`ProposalOf` :167, `Voting` + :173, `PolymeshVotes { index, ayes, nays, expiry }` :142-151). +- Threshold: `VoteThreshold` (n, d) — pass when `votes × d ≥ n × seats` (:509-512); GC default + is 2/3 (chain spec). Both approval and rejection use the same threshold with a + plurality requirement (`main ≥ other`, :541-548). +- `ReleaseCoordinator` (:191): a member with PIP-rescheduling power (doc 19 §3). +- Proposal expiry: `ExpiresAfter` (:196); expired proposals are pruned on touch (:611-625). +- **Execution origin**: passing proposals dispatch with `RawOrigin::Endorsed` (:627-631, + origin enum :130-134) — this is what `VMO` ("voting majority origin") matches. + Other pallets gate on it, e.g. `GCVotingMajorityOrigin = VMO` + (`pallets/runtime/develop/src/runtime.rs:250`). + +### Extrinsics + +| Extrinsic (idx) | Who | Ref | +|---|---|---| +| `set_vote_threshold(0)` | `VoteThresholdOrigin` (= the committee's own VMO) | :338 | +| `set_release_coordinator(1)` | `CommitteeOrigin` (VMO); target must be a member | :356 | +| `set_expires_after(2)` | VMO | :370 | +| `vote_or_propose(3)` | committee member | propose (auto-aye) or vote by call hash; first vote must approve (`FirstVoteReject`) | :402-415 | +| `vote(4)` | committee member | aye/nay (switch allowed, duplicate rejected); executes/rejects when threshold met (:466-471, `execute_if_passed` :536-560) | :429 | + +Single-member committees execute proposals immediately (:646-648, `seats() < 2`). **By design**: +liveness beats the single-member takeover risk — governance must keep working even if membership +collapses (e.g. mass abdication around a chain upgrade), and committee seats are only reachable +through root/GC-controlled membership in the first place. Do not report the fast path itself; +review proposals that would brick enactment instead. Members who leave mid-vote have their votes +retracted by the group hooks (`remove_vote_from` :519-534). + +## 2. Group pallet (membership registries) + +Instances: `Instance1` GC membership, `Instance2` **DidRegistrars** (formerly CDD providers), +`Instance3`/`Instance4` technical/upgrade membership. + +- Storage: `ActiveMembers` (sorted Vec, :178), `InactiveMembers` (with optional expiry — a + disabled member's past actions stay valid until `expiry`, :185), `ActiveMembersLimit` (:192, + ≤ `COMMITTEE_MEMBERS_MAX`). +- Extrinsics (:235-405): `set_active_members_limit(0)` [`LimitOrigin`], + `disable_member(1)`/`remove_member(3)` [`RemoveOrigin`] — disable keeps prior claims valid, + remove invalidates them (doc comments :250-259); `add_member(2)` [`AddOrigin`]; + `swap_member(4)` [`SwapOrigin`]; `reset_members(5)` [`ResetOrigin`]; + `abdicate_membership(6)` [the member itself]. +- Membership changes propagate via `MembershipInitialized`/`MembershipChanged`: + committees resync `Members` (and reset votes / release coordinator if needed); the + DidRegistrars instance notifies **Identity**, which maintains systematic CDD claims + (doc 03 §4). + +### Origin configuration (develop runtime, `pallets/runtime/develop/src/runtime.rs`) + +| Instance | Add/Remove/Swap | Reset | Limit | +|---|---|---|---| +| GC membership (Instance1) | **root** (:270-274) | root | root | +| Technical/Upgrade membership | own committee VMO (:295-297) | `VMO` (:299) | root | +| DidRegistrars (Instance2) | **root** (:324-329) | root | root | + +So: GC composition and DID-registrar membership change only via PIPs (root); sub-committees +manage their own membership but the GC can reset them. + +## 3. Treasury pallet + +- Account: `PalletId "pm/trsry"` (primitives/src/constants.rs:89; `account_id()` + pallets/treasury/src/lib.rs:196), associated with the Treasury systematic DID. +- Funding: staking slashes (`Slash = Treasury`, doc 20 §4) and voluntary `reimbursement(1)` + (any permissioned identity, :130 → :170-189). +- Spending: `disbursement(0)` — **root only** (i.e. via PIP/GC), pays each beneficiary + identity's **primary key** (:118 → :140-167; unknown identity ⇒ `InvalidIdentity`; + aggregate balance check). + +## 4. Invariants & review checklist + +- [ ] `Endorsed` origin must only be constructible by threshold-satisfied proposal execution — + it is the root-equivalent for many pallets via `VMO`. +- [ ] Threshold math `votes × d ≥ n × seats` with plurality (`ayes ≥ nays` / vice versa) — + changing it changes every VMO-gated pallet. +- [ ] Group→committee membership sync must retract votes of removed members and clear the + release coordinator when they leave. +- [ ] `disable_member` vs `remove_member` semantics for DidRegistrars: disable preserves + historical CDD claim validity; remove revokes systematic claims (identity `ChangeMembers` + hook). +- [ ] Single-member auto-execute (`seats() < 2`) is intentional liveness — keep it unless any + replacement still guarantees proposals can enact when a committee shrinks to one seat. +- [ ] Treasury disbursement targets primary keys — identities without a primary key + (post-unlink) must fail cleanly (`InvalidIdentity`). + +## 5. Test map + +`pallets/runtime/tests/src/committee_test.rs` (thresholds, expiry, release coordinator, +membership sync), `group_test.rs` (origins, disable/remove semantics), `treasury_test.rs` +(disbursement/reimbursement). diff --git a/docs/spec/README.md b/docs/spec/README.md new file mode 100644 index 0000000000..d5418e421b --- /dev/null +++ b/docs/spec/README.md @@ -0,0 +1,131 @@ +# Polymesh Chain Logic Specification + +Specification of the Polymesh blockchain runtime logic, written for engineers and AI agents +reviewing or modifying this codebase. Each document describes one subsystem: its data model, +extrinsics with their authorization requirements, core flows, cross-pallet interactions, and the +invariants a reviewer should check when the code changes. + +## Conventions + +- Code citations use `path:line` (`symbol_name`). Line numbers drift as code changes; the symbol + name is authoritative — re-locate with `rg` if a line number is stale. +- "GC" = Governance Council (root or committee origins). "DID" = identity (`IdentityId`). + "Primary key" / "secondary key" refer to the account keys attached to a DID. +- Extrinsic tables list the *effective* authorization: what `origin` must be and which + permission checks are applied after origin resolution. +- POLYX is the native token (6 decimals; `ONE_POLY = 1_000_000`). + +## Repository architecture + +- **Node** (`src/`): standard Substrate node (BABE/GRANDPA consensus, BEEFY/MMR). + Binary entry `src/bin/main.rs`; service wiring `src/service.rs`; chain specs `src/chain_spec/`. +- **Three runtimes** (`pallets/runtime/{develop,testnet,mainnet}`): share one identical + `spec_version` (checked by `scripts/check_spec_and_cargo_version.sh`). Most configuration and + the runtime macro scaffolding live in `pallets/runtime/common/src/runtime.rs` (macro + `misc_pallet_impls!` / common types) with per-chain constants in each runtime's + `constants.rs`/`runtime.rs`. Runtime changes usually must be wired in all three. +- **Forked polkadot-sdk**: all `sp-*`/`sc-*`/`frame-*`/`pallet-staking`/`pallet-revive` deps come + from `PolymeshAssociation/polkadot-sdk` (branch pinned in root `Cargo.toml` + `[workspace.dependencies]`). The fork mainly exists to support Polymesh's identity/permission + system (e.g. fee-payer redirection hooks, revive origin handling). +- **Shared tests**: `pallets/runtime/tests/` (`polymesh-runtime-tests`, `ExtBuilder`-based mock + runtime). `integration/` is a separate workspace driving a live chain over RPC. +- **Weights**: central in `pallets/weights/src/*.rs`, not inside pallets. + +### Runtime differences + +| Pallet | develop | testnet | mainnet | +|---|---|---|---| +| `Sudo` | yes | yes (`sudo` key) | **no** | +| `ConfidentialAssets` (index 70) | yes | yes | **no** | +| `Revive` (index 80) | yes | yes | yes | + +Everything else is identical modulo constants (e.g. settlement lock periods, CA defaults). + +### Pallet map (index → pallet, develop runtime `pallets/runtime/develop/src/runtime.rs`) + +| # | Pallet | Source | Spec doc | +|---|---|---|---| +| 0–6 | System, Babe, Timestamp, Indices, Authorship, Balances, TransactionPayment | forked SDK + `pallets/runtime/common` | [14](14-fees-and-extensions.md) | +| 51 | PolymeshTransactionPayment | `pallets/transaction-payment` | [14](14-fees-and-extensions.md) | +| 7 | Identity | `pallets/identity` | [01](01-identity-keys.md), [02](02-permissions.md), [03](03-claims.md) | +| 8 | DidRegistrars (group Instance2) | `pallets/group` | [22](22-treasury-committees.md) | +| 9–14 | Polymesh/Technical/Upgrade committees + memberships | `pallets/committee`, `pallets/group` | [22](22-treasury-committees.md) | +| 15 | MultiSig | `pallets/multisig` | [16](16-multisig.md) | +| 16 | Validators | `pallets/validators` | [20](20-staking-validators.md) | +| 17 | Staking | forked SDK `pallet-staking` | [20](20-staking-validators.md) | +| 18–23 | Offences, Session, AuthorityDiscovery, Grandpa, Historical, ImOnline | forked SDK | [20](20-staking-validators.md) | +| 25 | Sudo (develop/testnet only) | forked SDK | — | +| 26 | Asset | `pallets/asset` | [04](04-asset-lifecycle.md), [09](09-asset-transfers.md) | +| 27 | CapitalDistribution | `pallets/corporate-actions/src/distribution` | [12](12-corporate-actions.md) | +| 28 | Checkpoint | `pallets/asset/src/checkpoint` | [11](11-checkpoints.md) | +| 29 | ComplianceManager | `pallets/compliance-manager` | [06](06-compliance.md) | +| 30 | CorporateAction | `pallets/corporate-actions` | [12](12-corporate-actions.md) | +| 31 | CorporateBallot | `pallets/corporate-actions/src/ballot` | [12](12-corporate-actions.md) | +| 32 | Permissions | `pallets/permissions` | [02](02-permissions.md) | +| 33 | Pips | `pallets/pips` | [19](19-pips.md) | +| 34 | Portfolio | `pallets/portfolio` | [08](08-portfolio.md) | +| 35 | ProtocolFee | `pallets/protocol-fee` | [14](14-fees-and-extensions.md) | +| 36 | Scheduler | forked SDK | — | +| 37 | Settlement | `pallets/settlement` | [10](10-settlement.md) | +| 38 | Statistics | `pallets/statistics` | [07](07-statistics.md) | +| 39 | Sto | `pallets/sto` | [13](13-sto.md) | +| 40 | Treasury | `pallets/treasury` | [22](22-treasury-committees.md) | +| 41 | Utility | `pallets/utility` | [17](17-utility.md) | +| 42 | Base | `pallets/base` | — (length-limit helpers) | +| 43 | ExternalAgents | `pallets/external-agents` | [05](05-external-agents.md) | +| 44 | Relayer | `pallets/relayer` | [15](15-relayer.md) | +| 48 | Preimage | forked SDK | — | +| 49 | Nft | `pallets/nft` | [04](04-asset-lifecycle.md), [09](09-asset-transfers.md) | +| 50 | ElectionProviderMultiPhase | forked SDK | [20](20-staking-validators.md) | +| 52–54 | Beefy, Mmr, MmrLeaf | forked SDK | — | +| 55 | MultiBlockMigrations | forked SDK | — | +| 70 | ConfidentialAssets (develop/testnet) | `pallets/confidential-assets` | [18](18-confidential-assets.md) | +| 80 | Revive | forked SDK `pallet-revive` + `precompiles/` | [21](21-revive-evm.md) | + +## Cross-cutting design (read first) + +1. **Identity-first**: almost every extrinsic resolves the caller's account key to a DID before + doing anything. Accounts are cheap; identities carry claims, portfolios, asset roles. + One account key belongs to at most one DID (or one multisig). +2. **Layered permissions**: a call passes up to four gates — + (a) key→DID resolution + DID-not-frozen, + (b) secondary-key *extrinsic* permission (pallet/function subsets, recorded per-call by the + `StoreCallMetadata` transaction extension), + (c) secondary-key *asset* / *portfolio* subsets checked by the target pallet, + (d) asset-scoped *agent group* permission (external-agents) for asset admin calls. + Primary keys skip (b)/(c) but not (d). +3. **Transfers are settlement-centric**: every asset movement (including the direct + `Asset::transfer_asset` UX and ERC-20-style allowance spends) funnels into the settlement + engine's instruction machinery, which enforces custody, affirmations, compliance, statistics, + and venue filtering. Same-DID portfolio moves skip compliance/statistics. +4. **Authorizations**: privileged relationship changes (join identity, rotate primary key, become + agent, transfer ticker/portfolio custody...) are two-phase: issuer creates an `Authorization`, + target accepts it. The *issuer* pays the acceptance fees (see doc 14). + +## Document index (recommended reading order) + +| Doc | Subsystem | Status | +|---|---|---| +| [01-identity-keys.md](01-identity-keys.md) | DIDs, primary/secondary keys, authorizations | done | +| [02-permissions.md](02-permissions.md) | Permission data model + enforcement pipeline | done | +| [03-claims.md](03-claims.md) | Identity claims, issuers, CDD status | done | +| [04-asset-lifecycle.md](04-asset-lifecycle.md) | Fungible + NFT asset lifecycle | done | +| [05-external-agents.md](05-external-agents.md) | Asset agents & agent groups | done | +| [06-compliance.md](06-compliance.md) | Compliance requirements & evaluation | done | +| [07-statistics.md](07-statistics.md) | Transfer restrictions (statistics) | done | +| [08-portfolio.md](08-portfolio.md) | Portfolios & custodianship | done | +| [09-asset-transfers.md](09-asset-transfers.md) | All transfer code paths | done | +| [10-settlement.md](10-settlement.md) | Venues, instructions, locking | done | +| [11-checkpoints.md](11-checkpoints.md) | Balance snapshots & schedules | done | +| [12-corporate-actions.md](12-corporate-actions.md) | CAs, ballots, capital distributions | done | +| [13-sto.md](13-sto.md) | STO fundraising | done | +| [14-fees-and-extensions.md](14-fees-and-extensions.md) | TxExtension, fee payment, protocol fees | done | +| [15-relayer.md](15-relayer.md) | Fee subsidies | done | +| [16-multisig.md](16-multisig.md) | Multisig accounts & proposals | done | +| [17-utility.md](17-utility.md) | Batching & call wrappers | done | +| [18-confidential-assets.md](18-confidential-assets.md) | DART confidential assets | done | +| [19-pips.md](19-pips.md) | On-chain governance (PIPs) | done | +| [20-staking-validators.md](20-staking-validators.md) | Staking + permissioned validators | done | +| [21-revive-evm.md](21-revive-evm.md) | EVM/ETH support & precompiles | done | +| [22-treasury-committees.md](22-treasury-committees.md) | Treasury, committees, group instances | done | diff --git a/integration/AGENTS.md b/integration/AGENTS.md new file mode 100644 index 0000000000..3808bcbdfb --- /dev/null +++ b/integration/AGENTS.md @@ -0,0 +1,196 @@ +# Integration tests — agent guide + +Live-chain tests over RPC (not in-process FRAME mocks). Root overview: [`../AGENTS.md`](../AGENTS.md). CI reference: job `rust-integration-test` in `../.circleci/config.yml`. + +## Prerequisites (node + eth-rpc) + +**Do not start, restart, or kill the chain/eth-rpc unless the user asks or nothing is listening.** Prefer a node the user already started, or one background task for the whole session. Tail logs; do not block the session on the node process. + +| Service | Default | Who needs it | +| --- | --- | --- | +| Polymesh node (WS) | `ws://127.0.0.1:9944` | all tests | +| eth-rpc (HTTP) | `http://127.0.0.1:8545` | `revive_*` only | + +Suggested local startup (matches CI; run from **repo root**): + +```sh +# Binary: prefer a fresh ci-runtime build (stable under load). +cargo build --locked --release --features ci-runtime +./target/release/polymesh --bob --dev --tmp --pool-limit 100000 \ + --unsafe-force-node-key-generation --no-prometheus --no-telemetry + +# eth-rpc (docker). Start after the node is accepting WS. +docker run --rm --name parity-eth-rpc --network host \ + paritypr/eth-rpc:stable2606-73b734d9 \ + --node-rpc-url ws://127.0.0.1:9944 \ + --rpc-port 8545 --rpc-cors=all --allow-unprotected-txs +``` + +Env (export before compile **and** run): + +```sh +export POLYMESH_NODE_URL=ws://127.0.0.1:9944 # required for download_metadata codegen +export ETH_RPC_URL=http://127.0.0.1:8545 # revive_* only +# optional: WAIT_FOR_FINALIZE=1 +``` + +Sanity checks: + +```sh +# node RPC +curl -s -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"system_health","params":[]}' \ + http://127.0.0.1:9933 +# eth-rpc +curl -s -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \ + http://127.0.0.1:8545 +``` + +After **any** chain wipe/restart (new `--tmp` dir, killed process, etc.): + +```sh +cd integration && ./reset_db.sh # resets accounts.db used by PolymeshTester +``` + +Do **not** run `reset_db.sh` between ordinary test runs on a healthy chain. + +## Running tests + +Always from `integration/` (separate workspace; own lockfile/toolchain): + +```sh +cd integration +# full suite (what CI runs) +cargo nextest run --release --features current_release,timed --locked + +# one binary / one test +cargo nextest run --release --features current_release -E 'binary(asset_controls)' +cargo nextest run --release --features current_release -E 'test(make_divisible)' + +# compile only (catch warnings) +cargo nextest run --release --features current_release,timed --no-run +``` + +- Feature `current_release` (default): tests against current runtime + live metadata download. +- Feature `timed`: enables tests that sleep/poll for blocks or timestamps (`settlement_scheduling`, scheduled checkpoints, ballot windows, distribution reclaim, etc.). Gate those with `#[cfg(feature = "timed")]` (and usually `current_release`). +- Feature `previous_release`: upgrade-path suite; do not mix with `current_release` helpers blindly. +- nextest runs binaries **concurrently** — unique user names and tickers per test are mandatory. + +## Writing a new test + +1. **Find a sibling** under `tests/` for the same pallet/area and copy structure (module gate, imports, helpers). +2. **Gate the module**: + ```rust + #[cfg(feature = "current_release")] + mod my_area_tests { /* ... */ } + ``` + Timed-only files/tests: `#[cfg(all(feature = "current_release", feature = "timed"))]` or `#[cfg(feature = "timed")]` inside a `current_release` module. +3. **Scaffold**: + ```rust + #[tokio::test] + #[test_log::test] + async fn short_behavior_name() -> anyhow::Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["UniqueOwner", "UniqueInv"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + // ... + Ok(()) + } + ``` +4. **Reuse helpers** from `integration` (`AssetHelper`, extractors in `src/lib.rs`, revive/eth helpers). Prefer `AssetHelper::new` / `new_full` over hand-rolled `create_asset` unless you need indivisible assets or a specific mint destination. +5. **Assert intended product behavior**, not whatever the node currently does. If chain logic is wrong: keep the intended assertion and `#[ignore = "…"]` with a short reason. +6. **Compile clean** (no new warnings) under both `current_release` and `current_release,timed` before finishing. + +### Metadata / types + +- `current_release` enables `download_metadata`: types come from the **live** node at compile time. Node must be up **before** the first `cargo`/`nextest` invocation that builds `polymesh-api`. +- Use generated paths under `polymesh_api::types::…` (often `polymesh_primitives::…` or `pallet_*::…`). Do not invent field names — check a passing sibling test or `integration/target/doc/polymesh_api/` after a build. +- Pass `AssetHelper.asset_id` (`integration::AssetId`) straight into API calls when using live metadata. No conversion helpers. + +### Assets, balances, portfolios + +- `AssetHelper::new` / `new_full` mint **divisible** assets by default (`create_asset(..., true, EquityCommon, …)`). Amounts are `u128` in perbill-style units (1 unit of a divisible asset = `1_000_000` base units). +- Mint destination matters: + - `AssetHolderKind::Account` — needed if you later `transfer_asset` (account-based). + - `AssetHolderKind::DefaultPortfolio` — needed for settlement legs, capital distribution payment currency, portfolio balance queries. +- Dev protocol fees (rough): unique ticker registration ~500 POLYX, create asset ~2500 POLYX. Fund users via `tester.users` (pre-funded) or `balances().transfer_with_memo`. +- Existing POLYX transfer pattern: `balances().transfer_with_memo(dest.into(), amount, None)`. + +### Identity / secondary keys / claims + +- Secondary keys must be **DID-less** (`tester.new_signer_idx`); fund with POLYX before `join_identity_as_key`. +- `create_custody_portfolio` requires a prior `allow_identity_to_create_portfolios` from the portfolio owner. +- Child identities were removed in v8 — do not port those tests. +- First document id is `DocumentId(0)`. Compliance requirement ids start at `1`. +- Claim expiry is wall-clock ms from `timestamp().now()`. + +### Settlement + +- Current release **auto-affirms receivers** on incoming fungible legs (unless mandatory receiver affirmation is set). Only the sender (and mediators) usually need to `affirm_instruction`. Affirming an already-affirmed party → `UnexpectedAffirmationStatus`. +- `lock_instruction` / `unlock_instruction` require: + - `SettlementType::SettleAfterLock` + - caller is an instruction **mediator** + - all required affirmations received + - generous `Weight` limit (e.g. `Weight::from_parts(10_000_000_000, 10_000_000)`) +- After lock, execution is mediator-driven `execute_manual_instruction` while still locked (unlock returns to `Pending`). See `pallets/runtime/tests/src/settlement_pallet/`. +- Oversize legs fail at **affirm** (asset lock), not only at execute — for all-or-nothing failure tests prefer freezing an asset after affirm rather than an impossible lock amount. + +### Capital distribution / corporate actions + +- Payment asset for `distribute` must sit in the issuer **default portfolio**. +- Ballot attach needs a record date (`RecordDateSpec::Existing(cp_id)`). +- CA types: `pallet_corporate_actions::{CAKind, CADetails, RecordDateSpec, TargetIdentities}`; ballot types under `pallet_corporate_actions::ballot`. +- `remove_distribution` only works **before** `payment_at`; after expiry use `reclaim`, not remove. +- NFT: `NFTCollectionKeys(pub Vec<…>)`, `NFTs { asset_id, ids }`. + +### Relayer / subsidy + +- Relayer nonce storage is keyed by the **target** account (`RelayTxNonces`). +- `SubsidyFilter` (runtime) allows Asset, Balances, Identity, Settlement, etc. — **not** `System.remark`. Use a filtered call (e.g. tiny `balances.transfer_with_memo`) when asserting subsidy debits. +- Count-stat exemptions apply to the **sender** DID, not the receiver. + +### Statistics / compliance + +- Call `set_active_asset_stats` before `batch_update_asset_stats`. +- Investor-count tests: seed stats so the issuer’s own holding is counted when the limit is tight. + +### Utility / economics + +- Prefer `force_batch` when asserting per-item success/failure without aborting the batch. +- Sudo and dev-chain privileges are allowed on `--dev`. +- Chain committees on dev are seeded with `IdentityId(1)`, threshold often `(1, 2)`. + +### Contracts / revive + +- `revive_*` tests need eth-rpc. Use `EthNode` / revive helpers in `src/eth_helper.rs`, `src/revive_helper.rs`. +- Solidity artifacts: edit `contracts/`, run `contracts/build.sh` (solc **0.8.33**), commit `contracts/artifacts/*`. + +## Extractors / shared helpers + +Prefer existing event extractors in `src/lib.rs` (and helpers modules) over ad-hoc event scans: e.g. `get_instruction_id`, `get_checkpoint_id`, `get_ca_id`, `get_distribution_id`, `get_ballot_id`, `get_batch_results`. Add a new extractor next to them if a pallet event is reused. + +## Failure triage + +| Symptom | Likely cause | +| --- | --- | +| Compile: connection refused in proc-macro | Node not up before build; export `POLYMESH_NODE_URL` | +| `Invalid Transaction` / ancient birth block | Stale era under load, or chain restarted without client reconnect — retry; avoid restarting node mid-run | +| `Custom error: 4` on subsidised call | Call not in `SubsidyFilter` (`PalletNotSubsidised`) | +| `CallerIsNotAMediator` / lock fails | Wrong settlement type or non-mediator signer | +| `UnexpectedAffirmationStatus` | Double-affirm; receiver already auto-affirmed | +| Portfolio `Insufficient balance for a transaction` | Tokens minted to Account but spent from DefaultPortfolio (or reverse) | +| nextest name collisions / random fails | Reused user or ticker names across concurrent tests | +| eth-rpc empty code / lag | eth-rpc started before node, or not synced — wait / restart eth-rpc only | + +Chain-logic defects (not test bugs): keep the failing intended assertion and `#[ignore = "…"]` with a short reason; call them out to the user. + +## Checklist before finishing a new test + +- [ ] Unique user names + tickers +- [ ] Correct mint destination (Account vs DefaultPortfolio) +- [ ] Feature gates (`current_release` / `timed`) match CI +- [ ] No unused `mut`/imports (clean `--no-run` build) +- [ ] Ran the new test binary against the live node +- [ ] Timed paths exercised with `--features current_release,timed` if applicable +- [ ] No chain/eth-rpc restart left the session broken; `reset_db.sh` only if chain was wiped diff --git a/integration/Cargo.toml b/integration/Cargo.toml index c00ef883d3..18216ec305 100644 --- a/integration/Cargo.toml +++ b/integration/Cargo.toml @@ -75,6 +75,10 @@ default = ["current_release"] debug = [] +# Enable tests that wait on chain timers (scheduled instructions/checkpoints, +# ballot windows, authorization/proposal expiry). Only enabled in CI. +timed = [] + previous_release = [ "polymesh-api/polymesh_v7", "polymesh-api-tester/polymesh_v7", @@ -113,8 +117,8 @@ polymesh-api-client-extras = { version = "3.7.0", default-features = false, feat ] } polymesh-api-tester = { version = "0.11.0", default-features = false, features = [ "download_metadata", - # Slow down signers for the ci-runtime. - "signer_delay", + # Slow down signers for the ci-runtime. + "signer_delay", ] } polymesh-precompiles = { path = "../precompiles", default-features = false, features = [ diff --git a/integration/src/erc20_helper.rs b/integration/src/erc20_helper.rs index 2fd29cbc7f..58955310c0 100644 --- a/integration/src/erc20_helper.rs +++ b/integration/src/erc20_helper.rs @@ -59,6 +59,12 @@ pub fn unique_ticker(prefix: &str) -> Ticker { Ticker(ticker) } +/// A random symbol (unique ticker converted to a string), so that repeated test runs against the same chain don't collide on the global ticker registry. +pub fn unique_symbol(prefix: &str) -> String { + let ticker = unique_ticker(prefix); + String::from_utf8(ticker.0.to_vec()).expect("Ticker is valid UTF-8; qed") +} + /// Registers `ticker` and links it to `asset_id`, so that the ERC-20 `symbol()` /// method returns it. pub async fn link_ticker( diff --git a/integration/src/lib.rs b/integration/src/lib.rs index 3282dc4b6a..0f76f2fdfc 100644 --- a/integration/src/lib.rs +++ b/integration/src/lib.rs @@ -363,6 +363,67 @@ pub async fn get_ca_id( Ok(None) } +#[cfg(feature = "current_release")] +pub async fn get_checkpoint_id( + res: &mut TransactionResults, +) -> Result> { + if let Some(events) = res.events().await? { + for rec in &events.0 { + match &rec.event { + RuntimeEvent::Checkpoint(CheckpointEvent::CheckpointCreated( + _, + _, + cp_id, + _, + _, + )) => { + return Ok(Some(cp_id.clone())); + } + _ => (), + } + } + } + Ok(None) +} + +#[cfg(feature = "current_release")] +pub async fn get_distribution_id( + res: &mut TransactionResults, +) -> Result> { + if let Some(events) = res.events().await? { + for rec in &events.0 { + match &rec.event { + RuntimeEvent::CapitalDistribution(CapitalDistributionEvent::Created( + _, + ca_id, + _, + )) => { + return Ok(Some(ca_id.clone())); + } + _ => (), + } + } + } + Ok(None) +} + +#[cfg(feature = "current_release")] +pub async fn get_ballot_id( + res: &mut TransactionResults, +) -> Result> { + if let Some(events) = res.events().await? { + for rec in &events.0 { + match &rec.event { + RuntimeEvent::CorporateBallot(CorporateBallotEvent::Created(_, ca_id, ..)) => { + return Ok(Some(ca_id.clone())); + } + _ => (), + } + } + } + Ok(None) +} + /// Helper trait to add methods to `User` #[async_trait::async_trait] pub trait IntegrationUser: Signer { diff --git a/integration/tests/asset_controls.rs b/integration/tests/asset_controls.rs new file mode 100644 index 0000000000..3ae2a2cc3d --- /dev/null +++ b/integration/tests/asset_controls.rs @@ -0,0 +1,513 @@ +//! Asset lifecycle controls: freeze, redeem, divisibility, rename, type, docs, identifiers. +#[cfg(feature = "current_release")] +mod asset_controls_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::{AssetHolder, AssetHolderKind, AssetName, AssetType}, + asset_identifier::AssetIdentifier, + document::{Document, DocumentId, DocumentName, DocumentUri}, + identity_id::PortfolioId, + }; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + kind: Option, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + kind, + ) + .await?; + Ok(helper.asset_id) + } + + async fn account_balance( + tester: &PolymeshTester, + who: &AccountId, + asset_id: &AssetId, + ) -> Result { + Ok(tester + .api + .query() + .asset() + .asset_balance(who.clone(), asset_id.clone()) + .await?) + } + + /// Freezing an asset blocks transfers until unfrozen. + #[tokio::test] + #[test_log::test] + async fn freeze_blocks_transfers() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["AFzOwner", "AFzInv1"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + + let asset_id = create_asset( + &mut tester, + &mut owner, + "AFREEZE", + 1_000_000, + Some(AssetHolderKind::Account), + ) + .await?; + + // Sanity transfer works. + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Freeze -> transfer fails. + tester + .api + .call() + .asset() + .freeze(asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "frozen asset must not transfer"); + + // Unfreeze -> transfers resume. + tester + .api + .call() + .asset() + .unfreeze(asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Redeem burns tokens from the caller and reduces total supply. + #[tokio::test] + #[test_log::test] + async fn redeem_reduces_supply() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["ARdOwner", "ARdInv1"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let _inv1 = users.next().unwrap(); + + let asset_id = create_asset( + &mut tester, + &mut owner, + "AREDEEM", + 1_000_000, + Some(AssetHolderKind::Account), + ) + .await?; + + let before = account_balance(&tester, &owner.account(), &asset_id).await?; + + // Redeem 500 from the owner's account balance. + tester + .api + .call() + .asset() + .redeem( + asset_id.clone(), + 500, + AssetHolderKind::Account, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let after = account_balance(&tester, &owner.account(), &asset_id).await?; + assert_eq!(after + 500, before, "redeem should burn exactly 500"); + + Ok(()) + } + + /// make_divisible flips a whole (indivisible) asset to divisible. + #[tokio::test] + #[test_log::test] + async fn make_divisible_updates_details() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["ADvOwner", "ADvInv1"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + + // AssetHelper mints divisible tokens; create an indivisible one ourselves. + let mut res = tester + .api + .call() + .asset() + .create_asset( + AssetName(b"ADIVIS".to_vec()), + false, + AssetType::EquityCommon, + vec![], + None, + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let asset_id = get_asset_id(&mut res).await?.expect("asset id"); + tester + .api + .call() + .asset() + .issue(asset_id.clone(), 10_000_000_000, AssetHolderKind::Account)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let details_before = tester + .api + .query() + .asset() + .assets(asset_id.clone()) + .await? + .expect("asset exists"); + assert!(!details_before.divisible, "fresh asset should be indivisible"); + + // Whole-coin fractional transfer is rejected while indivisible. + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv1.account(), 1_500_000, None)? // 1.5 units + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "fractional transfer on indivisible asset should fail"); + + tester + .api + .call() + .asset() + .make_divisible(asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let details_after = tester + .api + .query() + .asset() + .assets(asset_id.clone()) + .await? + .expect("asset exists"); + assert!(details_after.divisible, "asset should now be divisible"); + + // Fractional transfer works after divisibility change. + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv1.account(), 1500000, None)? // 1.5 coins + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Rename + update type reflect in asset details. + #[tokio::test] + #[test_log::test] + async fn rename_and_update_type() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["ARnOwner", "ARnInv1"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let _inv1 = users.next().unwrap(); + + let asset_id = create_asset( + &mut tester, + &mut owner, + "ARENAMX", + 1_000_000, + Some(AssetHolderKind::Account), + ) + .await?; + + tester + .api + .call() + .asset() + .rename_asset(asset_id.clone(), AssetName(b"Renamed Asset".to_vec()))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .asset() + .update_asset_type(asset_id.clone(), AssetType::FixedIncome)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let details = tester + .api + .query() + .asset() + .assets(asset_id.clone()) + .await? + .expect("asset exists"); + assert_eq!(details.asset_type, AssetType::FixedIncome); + let name = tester + .api + .query() + .asset() + .asset_names(asset_id.clone()) + .await? + .expect("asset name"); + assert_eq!(name, AssetName(b"Renamed Asset".to_vec())); + + Ok(()) + } + + /// Documents can be added and removed by name. + #[tokio::test] + #[test_log::test] + async fn add_remove_documents() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["ADcOwner", "ADcInv1"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let _inv1 = users.next().unwrap(); + + let asset_id = create_asset( + &mut tester, + &mut owner, + "ADOCSXX", + 1_000_000, + Some(AssetHolderKind::Account), + ) + .await?; + + let doc = Document { + uri: DocumentUri(b"ipfs://QmTest".to_vec()), + content_hash: polymesh_api::types::polymesh_primitives::document_hash::DocumentHash::None, + name: DocumentName(b"Prospectus".to_vec()), + doc_type: None, + filing_date: None, + }; + + tester + .api + .call() + .asset() + .add_documents(vec![doc.clone()], asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let stored = tester + .api + .query() + .asset() + .asset_documents(asset_id.clone(), DocumentId(0)) + .await? + .expect("document 1 should exist"); + assert_eq!(stored.name, doc.name); + + tester + .api + .call() + .asset() + .remove_documents(vec![DocumentId(0)], asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let gone = tester + .api + .query() + .asset() + .asset_documents(asset_id.clone(), DocumentId(0)) + .await?; + assert!(gone.is_none(), "document removed"); + + Ok(()) + } + + /// Funding round name settable; identifiers update per-type storage. + #[tokio::test] + #[test_log::test] + async fn funding_round_and_identifiers() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["AIdOwner", "AIdInv1"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let _inv1 = users.next().unwrap(); + + let asset_id = create_asset( + &mut tester, + &mut owner, + "AIDENTS", + 1_000_000, + Some(AssetHolderKind::Account), + ) + .await?; + + // Funding round. + tester + .api + .call() + .asset() + .set_funding_round(asset_id.clone(), polymesh_api::types::polymesh_primitives::asset::FundingRoundName(b"Series A".to_vec()))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + let round = tester + .api + .query() + .asset() + .funding_round(asset_id.clone()) + .await?; + assert_eq!(round.0, b"Series A".to_vec()); + + // Identifiers (ISIN + CUSIP). + tester + .api + .call() + .asset() + .update_identifiers( + asset_id.clone(), + vec![ + AssetIdentifier::ISIN(*b"US0378331005"), + AssetIdentifier::CUSIP(*b"037833100"), + ], + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// controller_transfer force-moves tokens between holders. + #[tokio::test] + #[test_log::test] + async fn controller_transfer_moves_funds() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["ACtOwner", "ACtInv1", "ACtInv2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + let inv2 = users.next().unwrap(); + + let asset_id = create_asset( + &mut tester, + &mut owner, + "ACTRLTX", + 1_000_000, + Some(AssetHolderKind::Account), + ) + .await?; + + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv1.account(), 1000, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Owner forces 400 from inv1's account to their own default portfolio. + let issuer_did = owner.did.unwrap(); + tester + .api + .call() + .asset() + .controller_transfer( + asset_id.clone(), + 400, + AssetHolder::Account(inv1.account()), + AssetHolderKind::DefaultPortfolio, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let inv1_bal = account_balance(&tester, &inv1.account(), &asset_id).await?; + assert_eq!(inv1_bal, 600); + + let owner_pf = PortfolioId { + did: issuer_did, + kind: polymesh_api::types::polymesh_primitives::identity_id::PortfolioKind::Default, + }; + let pf_bal = tester + .api + .query() + .portfolio() + .portfolio_asset_balances(owner_pf, asset_id.clone()) + .await?; + assert_eq!(pf_bal, 400); + + // A second controller transfer pulls another 100 into the caller's account. + tester + .api + .call() + .asset() + .controller_transfer( + asset_id.clone(), + 100, + AssetHolder::Account(inv1.account()), + AssetHolderKind::Account, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + let inv1_bal = account_balance(&tester, &inv1.account(), &asset_id).await?; + assert_eq!(inv1_bal, 500); + let _ = inv2; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/ca_extended.rs b/integration/tests/ca_extended.rs new file mode 100644 index 0000000000..c67584f5a2 --- /dev/null +++ b/integration/tests/ca_extended.rs @@ -0,0 +1,204 @@ +//! Extra corporate-action configuration: record dates, docs, withholding tax, targets. +#[cfg(feature = "current_release")] +mod ca_extended_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::pallet_corporate_actions::{ + CADetails, CAKind, RecordDateSpec, TargetIdentities, TargetTreatment, + }; + use polymesh_api::types::polymesh_primitives::{ + asset::AssetHolderKind, + document::{Document, DocumentId, DocumentName, DocumentUri}, + }; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::DefaultPortfolio), + ) + .await?; + Ok(helper.asset_id) + } + + /// change_record_date can bind a CA to an existing checkpoint. + #[tokio::test] + #[test_log::test] + async fn change_record_date_to_checkpoint() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["CaeOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CAERD", 1_000_000).await?; + let now = tester.api.query().timestamp().now().await?; + let mut ca_res = tester + .api + .call() + .corporate_action() + .initiate_corporate_action( + asset_id.clone(), + CAKind::IssuerNotice, + now, + None, + CADetails(b"rd".to_vec()), + None, + None, + None, + )? + .submit_and_watch(&mut owner) + .await?; + ca_res.ok().await?; + let ca_id = get_ca_id(&mut ca_res).await?.expect("ca id"); + + let mut cp_res = tester + .api + .call() + .checkpoint() + .create_checkpoint(asset_id)? + .submit_and_watch(&mut owner) + .await?; + cp_res.ok().await?; + let cp_id = get_checkpoint_id(&mut cp_res).await?.expect("cp id"); + + tester + .api + .call() + .corporate_action() + .change_record_date(ca_id, Some(RecordDateSpec::Existing(cp_id)))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Documents can be attached to a CA. + #[tokio::test] + #[test_log::test] + async fn link_ca_document() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["CaeDocOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CAEDOC", 1_000_000).await?; + let doc = Document { + uri: DocumentUri(b"ipfs://ca-doc".to_vec()), + content_hash: polymesh_api::types::polymesh_primitives::document_hash::DocumentHash::None, + name: DocumentName(b"Notice".to_vec()), + doc_type: None, + filing_date: None, + }; + tester + .api + .call() + .asset() + .add_documents(vec![doc], asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let now = tester.api.query().timestamp().now().await?; + let mut ca_res = tester + .api + .call() + .corporate_action() + .initiate_corporate_action( + asset_id, + CAKind::IssuerNotice, + now, + None, + CADetails(b"docs".to_vec()), + None, + None, + None, + )? + .submit_and_watch(&mut owner) + .await?; + ca_res.ok().await?; + let ca_id = get_ca_id(&mut ca_res).await?.expect("ca id"); + + tester + .api + .call() + .corporate_action() + .link_ca_doc(ca_id, vec![DocumentId(0)])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Default and per-DID withholding tax plus default targets. + #[tokio::test] + #[test_log::test] + async fn withholding_tax_and_targets() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CaeTaxOwner", "CaeHolder"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let holder = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CAETAX", 1_000_000).await?; + let permill = sp_arithmetic::per_things::Permill::from_percent(10); + tester + .api + .call() + .corporate_action() + .set_default_withholding_tax(asset_id.clone(), permill.into())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let higher = sp_arithmetic::per_things::Permill::from_percent(25); + tester + .api + .call() + .corporate_action() + .set_did_withholding_tax( + asset_id.clone(), + holder.did.expect("holder did"), + Some(higher.into()), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .corporate_action() + .set_default_targets( + asset_id, + TargetIdentities { + identities: vec![holder.did.expect("holder did")], + treatment: TargetTreatment::Include, + }, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/capital_distribution.rs b/integration/tests/capital_distribution.rs new file mode 100644 index 0000000000..6441c01043 --- /dev/null +++ b/integration/tests/capital_distribution.rs @@ -0,0 +1,242 @@ +//! Capital distribution: initiate a benefit CA, distribute, claim. +#[cfg(feature = "current_release")] +mod capital_distribution_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::pallet_corporate_actions::{CADetails, CAKind, RecordDateSpec}; + use polymesh_api::types::polymesh_primitives::asset::AssetHolderKind; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::Account), + ) + .await?; + Ok(helper.asset_id) + } + + /// Full cycle: checkpoint → benefit CA → distribute payment asset → holder claims. + #[tokio::test] + #[test_log::test] + async fn distribute_and_claim() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CdOwner", "CdHolder"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut holder = users.next().unwrap(); + + // Equity held by owner + holder. + let equity = create_asset(&mut tester, &mut owner, "CDEQTY", 1_000_000).await?; + tester + .api + .call() + .asset() + .transfer_asset(equity.clone(), holder.account(), 100_000, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Snapshot holders. + let mut cp_res = tester + .api + .call() + .checkpoint() + .create_checkpoint(equity.clone())? + .submit_and_watch(&mut owner) + .await?; + cp_res.ok().await?; + let cp_id = get_checkpoint_id(&mut cp_res) + .await? + .expect("checkpoint id"); + + let now = tester.api.query().timestamp().now().await?; + let mut ca_res = tester + .api + .call() + .corporate_action() + .initiate_corporate_action( + equity.clone(), + CAKind::UnpredictableBenefit, + now, + Some(RecordDateSpec::Existing(cp_id)), + CADetails(b"dividend".to_vec()), + None, + None, + None, + )? + .submit_and_watch(&mut owner) + .await?; + ca_res.ok().await?; + let ca_id = get_ca_id(&mut ca_res).await?.expect("ca id"); + + // Payment currency (another asset in the issuer's default portfolio). + // Payment tokens must sit in the issuer's default portfolio. + let pay_helper = AssetHelper::new_full( + &tester.api, + &mut owner, + "CDPAY", + 1_000_000, + BTreeSet::new(), + false, + Some(AssetHolderKind::DefaultPortfolio), + ) + .await?; + let pay = pay_helper.asset_id; + + // 1 payment unit per equity unit (per_share is 1e6-scaled). + tester + .api + .call() + .capital_distribution() + .distribute( + ca_id.clone(), + None, + pay.clone(), + 1_000_000, + 1_000_000, + now, + None, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .capital_distribution() + .claim(ca_id.clone())? + .submit_and_watch(&mut holder) + .await? + .ok() + .await?; + + Ok(()) + } + + /// push_benefit pays a non-claiming holder; reclaim returns leftovers after expiry. + #[cfg(feature = "timed")] + #[tokio::test] + #[test_log::test] + async fn push_benefit_and_reclaim() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CdOwner2", "CdHolder2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let holder = users.next().unwrap(); + + let equity = create_asset(&mut tester, &mut owner, "CDEQT2", 1_000_000).await?; + tester + .api + .call() + .asset() + .transfer_asset(equity.clone(), holder.account(), 50_000, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let mut cp_res = tester + .api + .call() + .checkpoint() + .create_checkpoint(equity.clone())? + .submit_and_watch(&mut owner) + .await?; + cp_res.ok().await?; + let cp_id = get_checkpoint_id(&mut cp_res).await?.expect("checkpoint"); + + let now = tester.api.query().timestamp().now().await?; + let mut ca_res = tester + .api + .call() + .corporate_action() + .initiate_corporate_action( + equity.clone(), + CAKind::UnpredictableBenefit, + now, + Some(RecordDateSpec::Existing(cp_id)), + CADetails(b"push".to_vec()), + None, + None, + None, + )? + .submit_and_watch(&mut owner) + .await?; + ca_res.ok().await?; + let ca_id = get_ca_id(&mut ca_res).await?.expect("ca id"); + + let pay = AssetHelper::new_full( + &tester.api, + &mut owner, + "CDPAY2", + 1_000_000, + BTreeSet::new(), + false, + Some(AssetHolderKind::DefaultPortfolio), + ) + .await? + .asset_id; + let now = tester.api.query().timestamp().now().await?; + tester + .api + .call() + .capital_distribution() + .distribute( + ca_id.clone(), + None, + pay, + 1_000_000, + 1_000_000, + now, + Some(now + 12_000), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .capital_distribution() + .push_benefit(ca_id.clone(), holder.did.expect("holder did"))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tokio::time::sleep(std::time::Duration::from_secs(13)).await; + + tester + .api + .call() + .capital_distribution() + .reclaim(ca_id)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/checkpoints.rs b/integration/tests/checkpoints.rs new file mode 100644 index 0000000000..1bcbbafd87 --- /dev/null +++ b/integration/tests/checkpoints.rs @@ -0,0 +1,125 @@ +//! Checkpoint pallet: manual + scheduled checkpoints. +#[cfg(feature = "current_release")] +mod checkpoints_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::AssetHolderKind, + checkpoint::ScheduleCheckpoints, + }; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::Account), + ) + .await?; + Ok(helper.asset_id) + } + + /// Manual checkpoint creation records a snapshot id. + #[tokio::test] + #[test_log::test] + async fn manual_checkpoint() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["CpOwner", "CpInv"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let inv = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CPMAN", 1_000_000).await?; + + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let mut res = tester + .api + .call() + .checkpoint() + .create_checkpoint(asset_id.clone())? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let cp_id = get_checkpoint_id(&mut res) + .await? + .expect("CheckpointCreated event"); + assert!(cp_id.0 >= 1, "checkpoint id should be allocated"); + + Ok(()) + } + + /// A near-term scheduled checkpoint is accepted and stored. + #[cfg(feature = "timed")] + #[tokio::test] + #[test_log::test] + async fn scheduled_checkpoint() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["CpSchedOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CPSCHD", 1_000_000).await?; + let now = tester.api.query().timestamp().now().await?; + // A few seconds in the future so the schedule is pending. + let schedule = ScheduleCheckpoints { + pending: BTreeSet::from([now + 8_000]), + }; + + tester + .api + .call() + .checkpoint() + .create_schedule(asset_id.clone(), schedule)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Creating a schedule with an empty pending set is rejected. + #[tokio::test] + #[test_log::test] + async fn empty_schedule_rejected() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["CpEmptyOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CPEMPT", 1_000_000).await?; + let empty = ScheduleCheckpoints { + pending: BTreeSet::new(), + }; + let mut res = tester + .api + .call() + .checkpoint() + .create_schedule(asset_id, empty)? + .submit_and_watch(&mut owner) + .await?; + assert!( + res.ok().await.is_err(), + "empty checkpoint schedule should be rejected" + ); + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/compliance_enforcement.rs b/integration/tests/compliance_enforcement.rs new file mode 100644 index 0000000000..267517033e --- /dev/null +++ b/integration/tests/compliance_enforcement.rs @@ -0,0 +1,510 @@ +//! End-to-end compliance enforcement: transfers blocked/allowed by claims. +#[cfg(feature = "current_release")] +mod compliance_enforcement_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::AssetHolderKind, + condition::{Condition, ConditionType, TrustedFor, TrustedIssuer}, + identity_claim::{Claim, Scope}, + }; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::Account), + ) + .await?; + Ok(helper.asset_id) + } + + /// Transfer blocked until both sender & receiver hold the required claim. + #[tokio::test] + #[test_log::test] + async fn transfer_blocked_until_claims_added() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CEOwner", "CEIssuer", "CEInvestor1", "CEInvestor2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut issuer = users.next().unwrap(); + let investor1 = users.next().unwrap(); + let _investor2 = users.next().unwrap(); + + let issuer_did = issuer.did.expect("issuer did"); + let owner_did = owner.did.expect("owner did"); + let inv1_did = investor1.did.expect("investor1 did"); + + let asset_id = create_asset(&mut tester, &mut owner, "CEBLOCK", 1_000_000).await?; + + // Requirement: sender AND receiver must have Accredited (asset-scoped) claim. + let cond = Condition { + condition_type: ConditionType::IsPresent(Claim::Accredited(Scope::Asset(asset_id))), + issuers: vec![TrustedIssuer { + issuer: issuer_did, + trusted_for: TrustedFor::Any, + }], + }; + tester + .api + .call() + .compliance_manager() + .add_compliance_requirement(asset_id.clone(), vec![cond.clone()], vec![cond])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // No claims yet -> transfer must fail. + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "transfer without claims should fail"); + + // Sender-only claim is not enough. + tester + .api + .call() + .identity() + .add_claim(owner_did, Claim::Accredited(Scope::Asset(asset_id.clone())), None)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "transfer should still fail (receiver lacks claim)"); + + // Receiver claim added -> transfer succeeds. + tester + .api + .call() + .identity() + .add_claim(inv1_did, Claim::Accredited(Scope::Asset(asset_id.clone())), None)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Expired claims no longer satisfy requirements. + #[tokio::test] + #[test_log::test] + async fn expired_claims_block_transfer() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CExOwner", "CExIssuer", "CExInvestor1", "CExInvestor2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut issuer = users.next().unwrap(); + let investor1 = users.next().unwrap(); + let investor2 = users.next().unwrap(); + + let issuer_did = issuer.did.expect("issuer did"); + let owner_did = owner.did.expect("owner did"); + let inv1_did = investor1.did.expect("investor1 did"); + + let asset_id = create_asset(&mut tester, &mut owner, "CEXPIRY", 1_000_000).await?; + + let cond = Condition { + condition_type: ConditionType::IsPresent(Claim::Affiliate(Scope::Asset(asset_id))), + issuers: vec![TrustedIssuer { + issuer: issuer_did, + trusted_for: TrustedFor::Any, + }], + }; + tester + .api + .call() + .compliance_manager() + .add_compliance_requirement(asset_id.clone(), vec![cond.clone()], vec![cond])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Claims valid for ~3 seconds only. + let now = tester.api.query().timestamp().now().await?; + let expiry = now + 20_000; + for did in [owner_did, inv1_did] { + tester + .api + .call() + .identity() + .add_claim(did, Claim::Affiliate(Scope::Asset(asset_id.clone())), Some(expiry))? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + } + + // Valid while unexpired. + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Wait out the expiry. + tokio::time::sleep(std::time::Duration::from_secs(22)).await; + + // New transfer to a fresh receiver must fail now. + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor2.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "transfer after claim expiry should fail"); + + Ok(()) + } + + /// Pausing compliance lets transfers through; resume re-enables checks. + #[tokio::test] + #[test_log::test] + async fn pause_resume_allows_and_blocks() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CPaOwner", "CPaIssuer", "CPaInvestor1"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut issuer = users.next().unwrap(); + let investor1 = users.next().unwrap(); + + let issuer_did = issuer.did.expect("issuer did"); + let owner_did = owner.did.expect("owner did"); + let inv1_did = investor1.did.expect("investor1 did"); + + let asset_id = create_asset(&mut tester, &mut owner, "CPAUSE", 1_000_000).await?; + + let cond = Condition { + condition_type: ConditionType::IsPresent(Claim::KnowYourCustomer(Scope::Asset( + asset_id.clone(), + ))), + issuers: vec![TrustedIssuer { + issuer: issuer_did, + trusted_for: TrustedFor::Any, + }], + }; + tester + .api + .call() + .compliance_manager() + .add_compliance_requirement(asset_id.clone(), vec![cond.clone()], vec![cond])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // No KYC claims yet -> blocked. + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err()); + + // Pause compliance -> transfer allowed despite missing claims. + tester + .api + .call() + .compliance_manager() + .pause_asset_compliance(asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Resume -> blocked again (still no claims). + tester + .api + .call() + .compliance_manager() + .resume_asset_compliance(asset_id.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err()); + + // Add KYC claims for both sender and receiver and verify transfer succeeds. + for did in [owner_did, inv1_did] { + tester + .api + .call() + .identity() + .add_claim(did, Claim::KnowYourCustomer(Scope::Asset(asset_id.clone())), None)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + } + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// change/remove/replace requirement updates affect subsequent transfers. + #[tokio::test] + #[test_log::test] + async fn change_remove_replace_requirements() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CRROwner", "CRRIssuer", "CRRInvestor1"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut issuer = users.next().unwrap(); + let investor1 = users.next().unwrap(); + + let issuer_did = issuer.did.expect("issuer did"); + + let asset_id = create_asset(&mut tester, &mut owner, "CRRMOD", 1_000_000).await?; + + let accredited_cond = || Condition { + condition_type: ConditionType::IsPresent(Claim::Accredited(Scope::Asset(asset_id))), + issuers: vec![TrustedIssuer { + issuer: issuer_did, + trusted_for: TrustedFor::Any, + }], + }; + + tester + .api + .call() + .compliance_manager() + .add_compliance_requirement(asset_id.clone(), vec![], vec![accredited_cond()])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Receiver lacks Accredited -> fails. + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err()); + + // Remove the only requirement (id 1) -> succeeds. + tester + .api + .call() + .compliance_manager() + .remove_compliance_requirement(asset_id.clone(), 1)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Replace whole compliance with a receiver-Accredited requirement again. + let req = polymesh_api::types::polymesh_primitives::compliance_manager::ComplianceRequirement { + id: 5, + sender_conditions: vec![], + receiver_conditions: vec![accredited_cond()], + }; + tester + .api + .call() + .compliance_manager() + .replace_asset_compliance(asset_id.clone(), vec![req])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "replaced compliance should block again"); + + // Satisfy it via a claim from the trusted issuer. + tester + .api + .call() + .identity() + .add_claim( + investor1.did.unwrap(), + Claim::Accredited(Scope::Asset(asset_id.clone())), + None, + )? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Claims from issuers that are not trusted do not satisfy conditions. + #[tokio::test] + #[test_log::test] + async fn untrusted_issuer_claim_does_not_satisfy() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CTIOwner", "CTIIssuerA", "CTIIssuerB", "CTIInvestor1"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut issuer_a = users.next().unwrap(); + let mut issuer_b = users.next().unwrap(); + let investor1 = users.next().unwrap(); + + let issuer_a_did = issuer_a.did.expect("issuer A did"); + let inv1_did = investor1.did.expect("investor1 did"); + + let asset_id = create_asset(&mut tester, &mut owner, "CTISCOP", 1_000_000).await?; + + // Requirement trusts ONLY IssuerA. + let cond = Condition { + condition_type: ConditionType::IsPresent(Claim::Accredited(Scope::Asset(asset_id))), + issuers: vec![TrustedIssuer { + issuer: issuer_a_did, + trusted_for: TrustedFor::Any, + }], + }; + tester + .api + .call() + .compliance_manager() + .add_compliance_requirement(asset_id.clone(), vec![], vec![cond])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Claim from untrusted IssuerB doesn't help. + tester + .api + .call() + .identity() + .add_claim(inv1_did, Claim::Accredited(Scope::Asset(asset_id.clone())), None)? + .submit_and_watch(&mut issuer_b) + .await? + .ok() + .await?; + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "untrusted issuer's claim should not satisfy"); + + // Claim from trusted IssuerA works. + tester + .api + .call() + .identity() + .add_claim(inv1_did, Claim::Accredited(Scope::Asset(asset_id.clone())), None)? + .submit_and_watch(&mut issuer_a) + .await? + .ok() + .await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), investor1.account(), 10, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/corporate_ballot.rs b/integration/tests/corporate_ballot.rs new file mode 100644 index 0000000000..8eb50b77a5 --- /dev/null +++ b/integration/tests/corporate_ballot.rs @@ -0,0 +1,233 @@ +//! Corporate ballot: attach, vote, window changes. +#[cfg(feature = "current_release")] +mod corporate_ballot_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::pallet_corporate_actions::{CADetails, CAKind, RecordDateSpec}; + use polymesh_api::types::pallet_corporate_actions::ballot::{ + BallotMeta, BallotTimeRange, BallotTitle, BallotVote, ChoiceTitle, Motion, MotionInfoLink, + MotionTitle, + }; + use polymesh_api::types::polymesh_primitives::asset::AssetHolderKind; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::Account), + ) + .await?; + Ok(helper.asset_id) + } + + fn ballot_meta(title: &str) -> BallotMeta { + BallotMeta { + title: BallotTitle(title.as_bytes().to_vec()), + motions: vec![Motion { + title: MotionTitle(b"Approve?".to_vec()), + info_link: MotionInfoLink(b"https://example.test".to_vec()), + choices: vec![ + ChoiceTitle(b"Yes".to_vec()), + ChoiceTitle(b"No".to_vec()), + ], + }], + } + } + + /// Attach a ballot to an IssuerNotice CA and cast a vote. + #[tokio::test] + #[test_log::test] + async fn attach_and_vote() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["CbOwner", "CbVoter"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut voter = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CBVOTE", 1_000_000).await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), voter.account(), 100_000, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let mut cp_res = tester + .api + .call() + .checkpoint() + .create_checkpoint(asset_id.clone())? + .submit_and_watch(&mut owner) + .await?; + cp_res.ok().await?; + let cp_id = get_checkpoint_id(&mut cp_res).await?.expect("checkpoint"); + + let now = tester.api.query().timestamp().now().await?; + let mut ca_res = tester + .api + .call() + .corporate_action() + .initiate_corporate_action( + asset_id.clone(), + CAKind::IssuerNotice, + now, + Some(RecordDateSpec::Existing(cp_id)), + CADetails(b"agm".to_vec()), + None, + None, + None, + )? + .submit_and_watch(&mut owner) + .await?; + ca_res.ok().await?; + let ca_id = get_ca_id(&mut ca_res).await?.expect("ca id"); + + let range = BallotTimeRange { + start: now, + end: now + 60_000, + }; + tester + .api + .call() + .corporate_ballot() + .attach_ballot(ca_id.clone(), range, ballot_meta("AGM"), false)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .corporate_ballot() + .vote( + ca_id.clone(), + vec![ + BallotVote { + power: 100_000, + fallback: None, + }, + BallotVote { + power: 0, + fallback: None, + }, + ], + )? + .submit_and_watch(&mut voter) + .await? + .ok() + .await?; + + Ok(()) + } + + /// change_end / change_meta / change_rcv / remove_ballot. + #[tokio::test] + #[test_log::test] + async fn ballot_admin_updates() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["CbAdminOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "CBADM", 1_000_000).await?; + let mut cp_res = tester + .api + .call() + .checkpoint() + .create_checkpoint(asset_id.clone())? + .submit_and_watch(&mut owner) + .await?; + cp_res.ok().await?; + let cp_id = get_checkpoint_id(&mut cp_res).await?.expect("checkpoint"); + let now = tester.api.query().timestamp().now().await?; + let mut ca_res = tester + .api + .call() + .corporate_action() + .initiate_corporate_action( + asset_id, + CAKind::IssuerNotice, + now, + Some(RecordDateSpec::Existing(cp_id)), + CADetails(b"notice".to_vec()), + None, + None, + None, + )? + .submit_and_watch(&mut owner) + .await?; + ca_res.ok().await?; + let ca_id = get_ca_id(&mut ca_res).await?.expect("ca id"); + + // Start in the future so admin updates are still allowed. + let range = BallotTimeRange { + start: now + 30_000, + end: now + 90_000, + }; + tester + .api + .call() + .corporate_ballot() + .attach_ballot(ca_id.clone(), range, ballot_meta("Draft"), false)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .corporate_ballot() + .change_end(ca_id.clone(), now + 120_000)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .corporate_ballot() + .change_meta(ca_id.clone(), ballot_meta("Updated"))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .corporate_ballot() + .change_rcv(ca_id.clone(), true)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .corporate_ballot() + .remove_ballot(ca_id)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/economics.rs b/integration/tests/economics.rs new file mode 100644 index 0000000000..92f040034b --- /dev/null +++ b/integration/tests/economics.rs @@ -0,0 +1,171 @@ +//! Protocol fees and treasury disbursement/reimbursement. +#[cfg(feature = "current_release")] +mod economics_tests { + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + protocol_fee::ProtocolOp, + Beneficiary, + }; + + async fn free_balance(tester: &PolymeshTester, who: &AccountId) -> Result { + let info = tester.api.query().system().account(who.clone()).await?; + Ok(info.data.free) + } + + /// Ticker registration charges a protocol fee (balance decreases by more than just the tx fee). + #[tokio::test] + #[test_log::test] + async fn ticker_registration_charges_protocol_fee() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["EcoOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + + let before = free_balance(&tester, &owner.account()).await?; + let ticker = unique_ticker("ECO"); + tester + .api + .call() + .asset() + .register_unique_ticker(ticker)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + let after = free_balance(&tester, &owner.account()).await?; + assert!(after < before, "ticker registration should cost POLYX"); + // Protocol fee for ticker registration is 500 POLYX on the develop chain spec. + assert!( + before - after >= 500 * ONE_POLYX, + "expected at least the 500 POLYX protocol fee, delta={}", + before - after + ); + + Ok(()) + } + + /// Root can change the base protocol fee; subsequent registrations pick it up. + /// NOTE: This test mutates global chain state (protocol fee) and can race + /// with other tests when nextest runs binaries concurrently (e.g., the + /// `ticker_registration_charges_protocol_fee` test asserts on fee amount). + /// It is marked `ignore` to be run in isolation. + #[tokio::test] + #[test_log::test] + #[ignore = "mutates global chain fee; run in isolation with --ignored"] + async fn sudo_changes_base_fee() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["EcoFeeOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let mut sudo = tester.sudo.clone().expect("dev chain has sudo"); + + // Raise ticker registration fee to 1_000 POLYX. + let set_fee = tester + .api + .call() + .protocol_fee() + .change_base_fee(ProtocolOp::AssetRegisterTicker, 1_000 * ONE_POLYX)? + .into_runtime_call(); + tester + .api + .call() + .sudo() + .sudo(set_fee)? + .submit_and_watch(&mut sudo) + .await? + .ok() + .await?; + + let before = free_balance(&tester, &owner.account()).await?; + tester + .api + .call() + .asset() + .register_unique_ticker(unique_ticker("EC2"))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + let after = free_balance(&tester, &owner.account()).await?; + assert!( + before - after >= 1_000 * ONE_POLYX, + "new base fee should apply, delta={}", + before - after + ); + + // Restore original fee so later tests are not affected. + let restore = tester + .api + .call() + .protocol_fee() + .change_base_fee(ProtocolOp::AssetRegisterTicker, 500 * ONE_POLYX)? + .into_runtime_call(); + tester + .api + .call() + .sudo() + .sudo(restore)? + .submit_and_watch(&mut sudo) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Treasury reimbursement (donate in) and root disbursement (pay out). + #[tokio::test] + #[test_log::test] + async fn treasury_reimburse_and_disburse() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["EcoDonor", "EcoBene1"]) + .await? + .into_iter(); + let mut donor = users.next().unwrap(); + let bene = users.next().unwrap(); + let mut sudo = tester.sudo.clone().expect("dev chain has sudo"); + + let donate = 200 * ONE_POLYX; + let donor_before = free_balance(&tester, &donor.account()).await?; + tester + .api + .call() + .treasury() + .reimbursement(donate)? + .submit_and_watch(&mut donor) + .await? + .ok() + .await?; + let donor_after = free_balance(&tester, &donor.account()).await?; + assert!(donor_after + donate <= donor_before); + + let bene_before = free_balance(&tester, &bene.account()).await?; + let payout = 50 * ONE_POLYX; + let call = tester + .api + .call() + .treasury() + .disbursement(vec![Beneficiary { + id: bene.did.expect("bene did"), + amount: payout, + }])? + .into_runtime_call(); + tester + .api + .call() + .sudo() + .sudo(call)? + .submit_and_watch(&mut sudo) + .await? + .ok() + .await?; + let bene_after = free_balance(&tester, &bene.account()).await?; + assert!( + bene_after >= bene_before + payout, + "beneficiary should receive the disbursement" + ); + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/identity_lifecycle.rs b/integration/tests/identity_lifecycle.rs new file mode 100644 index 0000000000..58d721e35c --- /dev/null +++ b/integration/tests/identity_lifecycle.rs @@ -0,0 +1,454 @@ +//! Identity lifecycle: primary-key rotation, SK freeze/remove, claim revocation, authorizations. +#[cfg(feature = "current_release")] +mod identity_lifecycle_tests { + use anyhow::Result; + + use integration::*; + use polymesh_api::types::pallet_identity::types::{Claim1stKey, Claim2ndKey}; + use polymesh_api::types::polymesh_primitives::{ + authorization::AuthorizationData, + identity_claim::{Claim, Scope}, + secondary_key::Signatory, + settlement::{VenueDetails, VenueType}, + }; + + /// Join a key that has no DID into `owner`'s identity as a secondary key. + async fn add_secondary_key( + tester: &PolymeshTester, + owner: &mut User, + new_key: &mut AccountSigner, + perms: Permissions, + ) -> Result<()> { + // The new key needs POLYX to pay for join_identity_as_key. + tester + .api + .call() + .balances() + .transfer_with_memo(new_key.account().into(), 100 * ONE_POLYX, None)? + .submit_and_watch(owner) + .await? + .ok() + .await?; + + let mut res = tester + .api + .call() + .identity() + .add_authorization( + Signatory::Account(new_key.account()), + AuthorizationData::JoinIdentity(perms), + None, + )? + .submit_and_watch(owner) + .await?; + res.ok().await?; + let auth_id = get_auth_id(&mut res) + .await? + .expect("AuthorizationAdded event"); + tester + .api + .call() + .identity() + .join_identity_as_key(auth_id)? + .submit_and_watch(new_key) + .await? + .ok() + .await?; + Ok(()) + } + + /// Primary key rotation to a secondary key via RotatePrimaryKeyToSecondary auth. + #[tokio::test] + #[test_log::test] + async fn primary_key_rotation_to_secondary() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["ILROwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let mut new_primary = tester.new_signer_idx("ILROwner", 1)?; + + add_secondary_key( + &tester, + &mut owner, + &mut new_primary, + PermissionsBuilder::whole().build(), + ) + .await?; + + // Owner authorizes rotating the primary key to the SK. + let mut res = tester + .api + .call() + .identity() + .add_authorization( + Signatory::Account(new_primary.account()), + AuthorizationData::RotatePrimaryKeyToSecondary(PermissionsBuilder::whole().build()), + None, + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let auth_id = get_auth_id(&mut res).await?.expect("auth id"); + + // The new key accepts and becomes the primary; old key remains as SK. + tester + .api + .call() + .identity() + .rotate_primary_key_to_secondary(auth_id)? + .submit_and_watch(&mut new_primary) + .await? + .ok() + .await?; + + // Verify: DID's primary key is now the new account. + let did = owner.did.unwrap(); + let did_records = tester + .api + .query() + .identity() + .did_records(did) + .await? + .expect("did records"); + assert_eq!( + did_records.primary_key, + Some(new_primary.account()), + "new primary key should be active" + ); + + Ok(()) + } + + /// Freezing all secondary keys blocks them until unfrozen. + #[tokio::test] + #[test_log::test] + async fn freeze_unfreeze_secondary_keys() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["ILFOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let mut sk = tester.new_signer_idx("ILFOwner", 1)?; + + add_secondary_key(&tester, &mut owner, &mut sk, PermissionsBuilder::whole().build()).await?; + + // SK can act (create venue). + tester + .api + .call() + .settlement() + .create_venue(VenueDetails(b"SKVenue1".to_vec()), Default::default(), VenueType::Other)? + .execute(&mut sk) + .await? + .ok() + .await?; + + // Primary freezes ALL secondary keys (no per-call args). + tester + .api + .call() + .identity() + .freeze_secondary_keys()? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // SK calls are now rejected. + let res = tester + .api + .call() + .settlement() + .create_venue(VenueDetails(b"SKVenue2".to_vec()), Default::default(), VenueType::Other)? + .execute(&mut sk) + .await; + match res { + Ok(mut r) => assert!(r.ok().await.is_err(), "frozen SK should be blocked"), + Err(_) => {} // Rejected by node also acceptable. + } + + // Unfreeze restores access. + tester + .api + .call() + .identity() + .unfreeze_secondary_keys()? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .settlement() + .create_venue(VenueDetails(b"SKVenue3".to_vec()), Default::default(), VenueType::Other)? + .execute(&mut sk) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Removing a secondary key revokes its identity linkage. + #[tokio::test] + #[test_log::test] + async fn remove_secondary_keys_revokes_access() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["ILROwner2"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + let mut sk = tester.new_signer_idx("ILROwner2", 1)?; + + add_secondary_key(&tester, &mut owner, &mut sk, PermissionsBuilder::whole().build()).await?; + + // Works before removal. + tester + .api + .call() + .settlement() + .create_venue(VenueDetails(b"RSKVenue1".to_vec()), Default::default(), VenueType::Other)? + .execute(&mut sk) + .await? + .ok() + .await?; + + // Owner removes the SK. + tester + .api + .call() + .identity() + .remove_secondary_keys(vec![sk.account()])? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // SK no longer linked -> permissioned calls fail. + let res = tester + .api + .call() + .settlement() + .create_venue(VenueDetails(b"RSKVenue2".to_vec()), Default::default(), VenueType::Other)? + .execute(&mut sk) + .await; + match res { + Ok(mut r) => assert!(r.ok().await.is_err(), "removed SK should be blocked"), + Err(_) => {} + } + + Ok(()) + } + + /// Issuer can add and then revoke a claim on a target identity. + #[tokio::test] + #[test_log::test] + async fn revoke_claim_removes_it() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["ILCIssuer", "ILCTarget"]) + .await? + .into_iter(); + let mut issuer = users.next().unwrap(); + let target = users.next().unwrap(); + + let issuer_did = issuer.did.expect("issuer did"); + let target_did = target.did.expect("target did"); + + // Add claim scoped to the issuer's own identity. + tester + .api + .call() + .identity() + .add_claim(target_did, Claim::Accredited(Scope::Identity(issuer_did)), None)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + let first_key = Claim1stKey { + target: target_did, + claim_type: + polymesh_api::types::polymesh_primitives::identity_claim::ClaimType::Accredited, + }; + let second_key = Claim2ndKey { + issuer: issuer_did, + scope: Some(Scope::Identity(issuer_did)), + }; + let stored = tester + .api + .query() + .identity() + .claims(first_key.clone(), second_key.clone()) + .await?; + assert!(stored.is_some(), "claim should exist after add_claim"); + + // Issuer revokes it by full claim value. + tester + .api + .call() + .identity() + .revoke_claim( + target_did, + Claim::Accredited(Scope::Identity(issuer_did)), + )? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + let stored = tester + .api + .query() + .identity() + .claims(first_key, second_key) + .await?; + assert!(stored.is_none(), "claim should be gone after revoke_claim"); + + Ok(()) + } + + /// register_custom_claim_type allocates incrementing type IDs usable in claims. + #[tokio::test] + #[test_log::test] + async fn custom_claim_types() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["ILCCIssuer", "ILCCTarget"]) + .await? + .into_iter(); + let mut issuer = users.next().unwrap(); + let target = users.next().unwrap(); + + let target_did = target.did.unwrap(); + + // Register two custom types. + let suffix = format!("{:?}", std::time::SystemTime::now()); + for ty_name in [ + format!("CustomKYC{suffix}").into_bytes(), + format!("Audited{suffix}").into_bytes(), + ] { + tester + .api + .call() + .identity() + .register_custom_claim_type(ty_name)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + } + + let mut last = tester + .api + .call() + .identity() + .register_custom_claim_type(format!("Extra{suffix}").into_bytes())? + .submit_and_watch(&mut issuer) + .await?; + last.ok().await?; + let ty_id = { + let events = last.events().await?.expect("events"); + let mut found = None; + for rec in &events.0 { + if let RuntimeEvent::Identity(IdentityEvent::CustomClaimTypeAdded(_, id, _)) = + &rec.event + { + found = Some(id.clone()); + } + } + found.expect("CustomClaimTypeAdded") + }; + tester + .api + .call() + .identity() + .add_claim(target_did, Claim::Custom(ty_id, None), None)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Target rejects a pending authorization; issuer can also cancel it. + #[tokio::test] + #[test_log::test] + async fn authorization_reject_and_cancel() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["ILAOwner", "ILATarget", "ILATarget2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut target = users.next().unwrap(); + let mut target2 = users.next().unwrap(); + + let perms = PermissionsBuilder::whole().build(); + + // Auth #1: target rejects it. + let mut res = tester + .api + .call() + .identity() + .add_authorization( + Signatory::Account(target.account()), + AuthorizationData::JoinIdentity(perms.clone()), + None, + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let auth1 = get_auth_id(&mut res).await?.expect("auth id"); + + // remove_authorization(target, auth_id, reject=true) executed BY the target == rejection. + tester + .api + .call() + .identity() + .remove_authorization(Signatory::Account(target.account()), auth1, true)? + .submit_and_watch(&mut target) + .await? + .ok() + .await?; + + // Auth #2: issuer cancels it before acceptance. + let mut res = tester + .api + .call() + .identity() + .add_authorization( + Signatory::Account(target2.account()), + AuthorizationData::JoinIdentity(perms), + None, + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let auth2 = get_auth_id(&mut res).await?.expect("auth id"); + + // remove_authorization(...) executed BY the issuer == cancellation. + tester + .api + .call() + .identity() + .remove_authorization(Signatory::Account(target2.account()), auth2, false)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Accepting either auth now fails. + let res = tester + .api + .call() + .identity() + .join_identity_as_key(auth2)? + .submit_and_watch(&mut target2) + .await; + match res { + Ok(mut r) => assert!(r.ok().await.is_err(), "cancelled auth must not be acceptable"), + Err(_) => {} + } + let _ = auth1; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/nft.rs b/integration/tests/nft.rs new file mode 100644 index 0000000000..07b62192f8 --- /dev/null +++ b/integration/tests/nft.rs @@ -0,0 +1,151 @@ +//! NFT collection, issue, transfer, redeem. +#[cfg(feature = "current_release")] +mod nft_tests { + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::{AssetName, AssetType, NonFungibleType}, + asset_metadata::{ + AssetMetadataKey, AssetMetadataLocalKey, AssetMetadataName, AssetMetadataSpec, + AssetMetadataValue, + }, + nft::{NFTCollectionKeys, NFTId, NFTMetadataAttribute, NFTs}, + }; + + async fn create_nft_asset( + tester: &PolymeshTester, + owner: &mut User, + name: &str, + ) -> Result { + let mut res = tester + .api + .call() + .asset() + .create_asset( + AssetName(name.as_bytes().to_vec()), + false, + AssetType::NonFungible(NonFungibleType::Derivative), + vec![], + None, + )? + .submit_and_watch(owner) + .await?; + res.ok().await?; + get_asset_id(&mut res) + .await? + .ok_or_else(|| anyhow::anyhow!("AssetCreated event missing")) + } + + async fn register_local_key( + tester: &PolymeshTester, + owner: &mut User, + asset_id: AssetId, + name: &str, + ) -> Result<()> { + tester + .api + .call() + .asset() + .register_asset_metadata_local_type( + asset_id, + AssetMetadataName(name.as_bytes().to_vec()), + AssetMetadataSpec { + url: None, + description: None, + type_def: None, + }, + )? + .submit_and_watch(owner) + .await? + .ok() + .await?; + Ok(()) + } + + /// Create a collection, issue an NFT, transfer it, then redeem it. + #[tokio::test] + #[test_log::test] + async fn collection_issue_transfer_redeem() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["NftIssuer", "NftHolder"]) + .await? + .into_iter(); + let mut issuer = users.next().unwrap(); + let holder = users.next().unwrap(); + + let asset_id = create_nft_asset(&tester, &mut issuer, "NftCol").await?; + register_local_key(&tester, &mut issuer, asset_id.clone(), "image").await?; + + let keys = NFTCollectionKeys(vec![AssetMetadataKey::Local(AssetMetadataLocalKey(1))]); + tester + .api + .call() + .nft() + .create_nft_collection(Some(asset_id.clone()), None, keys)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + use polymesh_api::types::polymesh_primitives::asset::{AssetHolder, AssetHolderKind}; + + tester + .api + .call() + .nft() + .issue_nft( + asset_id.clone(), + vec![NFTMetadataAttribute { + key: AssetMetadataKey::Local(AssetMetadataLocalKey(1)), + value: AssetMetadataValue(b"ipfs://img".to_vec()), + }], + AssetHolderKind::Account, + )? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + let nfts = NFTs { + asset_id: asset_id.clone(), + ids: vec![NFTId(1)], + }; + tester + .api + .call() + .nft() + .transfer_nft(nfts.clone(), holder.account(), None)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + tester + .api + .call() + .nft() + .controller_transfer( + nfts, + AssetHolder::Account(holder.account()), + AssetHolderKind::Account, + )? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + tester + .api + .call() + .nft() + .redeem_nft(asset_id, NFTId(1), AssetHolderKind::Account, None)? + .submit_and_watch(&mut issuer) + .await? + .ok() + .await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/portfolio_custody.rs b/integration/tests/portfolio_custody.rs new file mode 100644 index 0000000000..15011643d1 --- /dev/null +++ b/integration/tests/portfolio_custody.rs @@ -0,0 +1,292 @@ +//! Portfolio custody, creation permissions and pre-approvals. +#[cfg(feature = "current_release")] +mod portfolio_custody_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::AssetHolderKind, + identity_id::{PortfolioId, PortfolioKind, PortfolioName, PortfolioNumber}, + }; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::Account), + ) + .await?; + Ok(helper.asset_id) + } + + fn user_pf(did: IdentityId, n: u64) -> PortfolioId { + PortfolioId { + did, + kind: PortfolioKind::User(PortfolioNumber(n)), + } + } + + /// create_custody_portfolio hands custody to the creator until accepted/quit. + #[tokio::test] + #[test_log::test] + async fn custody_portfolio_lifecycle() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["PCOwner", "PCCustodian"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut custodian = users.next().unwrap(); + + let owner_did = owner.did.unwrap(); + let custodian_did = custodian.did.unwrap(); + + tester + .api + .call() + .portfolio() + .allow_identity_to_create_portfolios(custodian_did)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Custodian creates a portfolio owned by `owner` but custodied by itself. + tester + .api + .call() + .portfolio() + .create_custody_portfolio(owner_did, PortfolioName(b"Custodied".to_vec()))? + .submit_and_watch(&mut custodian) + .await? + .ok() + .await?; + + // The first custodied portfolio under the owner gets number 1. + let pid = user_pf(owner_did, 1); + let custodian_of = tester + .api + .query() + .portfolio() + .portfolios_in_custody(custodian_did, pid.clone()) + .await?; + assert!(custodian_of, "custodian should hold custody of the new portfolio"); + + // Custodian quits; ownership returns fully to the owner. + tester + .api + .call() + .portfolio() + .quit_portfolio_custody(pid.clone())? + .submit_and_watch(&mut custodian) + .await? + .ok() + .await?; + + let still_custodied = tester + .api + .query() + .portfolio() + .portfolios_in_custody(custodian_did, pid) + .await?; + assert!(!still_custodied, "custody should be released after quit"); + + Ok(()) + } + + /// Only identities granted permission can create custodied portfolios. + #[tokio::test] + #[test_log::test] + async fn create_portfolios_permission() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["PCPOwner", "PCPDelegate"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut delegate = users.next().unwrap(); + + let owner_did = owner.did.unwrap(); + let delegate_did = delegate.did.unwrap(); + + // Grant permission... + tester + .api + .call() + .portfolio() + .allow_identity_to_create_portfolios(delegate_did)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // ...delegate can now create a custodied portfolio under owner's DID. + tester + .api + .call() + .portfolio() + .create_custody_portfolio(owner_did, PortfolioName(b"Delegated".to_vec()))? + .submit_and_watch(&mut delegate) + .await? + .ok() + .await?; + + // Revoke permission... + tester + .api + .call() + .portfolio() + .revoke_create_portfolios_permission(delegate_did)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // ...further custodied creations fail. + let mut res = tester + .api + .call() + .portfolio() + .create_custody_portfolio(owner_did, PortfolioName(b"Delegated2".to_vec()))? + .submit_and_watch(&mut delegate) + .await?; + assert!(res.ok().await.is_err(), "revoked delegate must not create portfolios"); + + Ok(()) + } + + /// Pre-approving an asset for a portfolio skips receiver affirmation friction. + #[tokio::test] + #[test_log::test] + async fn portfolio_pre_approval() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["PPAOwner", "PPAInv1"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let mut inv1 = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "PPAAPPR", 10_000).await?; + let inv_did = inv1.did.unwrap(); + let pf1 = PortfolioId { + did: inv_did, + kind: PortfolioKind::Default, + }; + + // Pre-approve the asset for the investor's default portfolio. + tester + .api + .call() + .portfolio() + .pre_approve_portfolio(asset_id.clone(), pf1.clone())? + .execute(&mut inv1) + .await? + .ok() + .await?; + + let approved = tester + .api + .query() + .portfolio() + .pre_approved_portfolios(pf1.clone(), asset_id.clone()) + .await?; + assert!(approved, "portfolio should be pre-approved"); + + // Remove it again. + tester + .api + .call() + .portfolio() + .remove_portfolio_pre_approval(asset_id.clone(), pf1.clone())? + .execute(&mut inv1) + .await? + .ok() + .await?; + + let approved = tester + .api + .query() + .portfolio() + .pre_approved_portfolios(pf1, asset_id) + .await?; + assert!(!approved, "pre-approval removed"); + + Ok(()) + } + + /// Rename then delete (empty) custom portfolios. + #[tokio::test] + #[test_log::test] + async fn rename_then_delete_portfolio() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["PRDUser"]).await?.into_iter(); + let mut user = users.next().unwrap(); + let _ = create_asset(&mut tester, &mut user, "PRDUMMY", 100).await?; // warm up fees + + // Create custom portfolio #1. + tester + .api + .call() + .portfolio() + .create_portfolio(PortfolioName(b"Original".to_vec()))? + .submit_and_watch(&mut user) + .await? + .ok() + .await?; + + let did = user.did.unwrap(); + + // Rename. + tester + .api + .call() + .portfolio() + .rename_portfolio(PortfolioNumber(1), PortfolioName(b"Renamed".to_vec()))? + .execute(&mut user) + .await? + .ok() + .await?; + + let name = tester + .api + .query() + .portfolio() + .portfolios(did, PortfolioNumber(1)) + .await? + .expect("portfolio name"); + assert_eq!(name, PortfolioName(b"Renamed".to_vec())); + + // Delete (must be empty). + tester + .api + .call() + .portfolio() + .delete_portfolio(PortfolioNumber(1))? + .execute(&mut user) + .await? + .ok() + .await?; + + let name = tester + .api + .query() + .portfolio() + .portfolios(did, PortfolioNumber(1)) + .await?; + assert!(name.is_none(), "deleted portfolio should have no name"); + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/relayer_negative.rs b/integration/tests/relayer_negative.rs new file mode 100644 index 0000000000..d0fecc7c8f --- /dev/null +++ b/integration/tests/relayer_negative.rs @@ -0,0 +1,341 @@ +//! Relayer negative paths: expired signatures, nonce reuse, filtered calls, subsidy debits. +#[cfg(feature = "current_release")] +mod relayer_negative_tests { + use anyhow::Result; + + use integration::*; + + /// Setup an accepted subsidy: payer approves for `target`, target accepts. + async fn setup_subsidy( + tester: &PolymeshTester, + payer: &mut User, + target: &mut User, + amount: u128, + ) -> Result<()> { + tester + .api + .call() + .relayer() + .approve_subsidy(target.account(), amount)? + .submit_and_watch(payer) + .await? + .ok() + .await?; + tester + .api + .call() + .relayer() + .accept_subsidy(payer.account())? + .submit_and_watch(target) + .await? + .ok() + .await?; + Ok(()) + } + + async fn current_nonce(tester: &PolymeshTester, target: &User) -> Result { + Ok(tester + .api + .query() + .relayer() + .relay_tx_nonces(target.account()) + .await?) + } + + /// Relay with a stale `expires_at` must fail signature validation. + #[tokio::test] + #[test_log::test] + async fn expired_signature_rejected() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["RNPayer", "RNTarget"]).await?; + let mut payer = users.remove(0); + let mut target = users.remove(0); + + setup_subsidy(&tester, &mut payer, &mut target, 1000 * ONE_POLYX).await?; + + let call = tester + .api + .call() + .system() + .remark(b"expired".to_vec())? + .into_runtime_call(); + + // Craft message then override expiry into the past. + let nonce = current_nonce(&tester, &target).await?; + let now = tester.api.query().timestamp().now().await?; + let past_expiry = now.saturating_sub(10_000); + let msg_call = call.clone(); + let message = + ChainScopedMessage::new(&tester.api, nonce, RELAY_TX_LABEL, Some(past_expiry), &msg_call) + .await?; + + // Target signs; payer submits. + let sig = sign_with_key(&target, &message).await?; + let mut res = tester + .api + .call() + .relayer() + .relay_tx( + target.account(), + sig.into(), + call, + message.expires_at, // in the past + )? + .submit_and_watch(&mut payer) + .await?; + assert!(res.ok().await.is_err(), "relay with expired signature should fail"); + + Ok(()) + } + + /// The same signed message (nonce) cannot be replayed. + #[tokio::test] + #[test_log::test] + async fn nonce_reuse_rejected() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["RNPayer2", "RNTarget2"]).await?; + let mut payer = users.remove(0); + let target = users.remove(0); + + setup_subsidy(&tester, &mut payer, &mut target.clone(), 1000 * ONE_POLYX).await?; + + let call = tester + .api + .call() + .system() + .remark(b"replay-me".to_vec())? + .into_runtime_call(); + + let nonce = current_nonce(&tester, &target).await?; + let msg_call = call.clone(); + let message = + ChainScopedMessage::new(&tester.api, nonce, RELAY_TX_LABEL, None, &msg_call).await?; + let sig = sign_with_key(&target, &message).await?; + let expires_at = message.expires_at; + + // First relay succeeds and consumes the nonce. + tester + .api + .call() + .relayer() + .relay_tx(target.account(), sig.clone().into(), call.clone(), expires_at)? + .submit_and_watch(&mut payer) + .await? + .ok() + .await?; + + // Nonce advanced by exactly one. + let next = current_nonce(&tester, &target).await?; + assert_eq!(next, nonce + 1, "successful relay must bump the nonce"); + + // Replaying the same signed payload fails. + let mut res = tester + .api + .call() + .relayer() + .relay_tx(target.account(), sig.into(), call, expires_at)? + .submit_and_watch(&mut payer) + .await?; + assert!(res.ok().await.is_err(), "replayed relay must be rejected"); + + Ok(()) + } + + /// Subsidy remaining is debited when the subsidized user pays fees. + #[tokio::test] + #[test_log::test] + async fn subsidy_debits_until_exhausted() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["RNPayer3", "RNTarget3"]).await?; + let mut payer = users.remove(0); + let mut target = users.remove(0); + + let small = 50 * ONE_POLYX; + setup_subsidy(&tester, &mut payer, &mut target, small).await?; + let target_account = target.account(); + + let remaining = || async { + Ok::<_, anyhow::Error>( + tester + .api + .query() + .relayer() + .subsidies(target_account.clone()) + .await? + .map(|s| s.remaining) + .unwrap_or(0), + ) + }; + + let before = remaining().await?; + // Balances is in SubsidyFilter; System.remark is not. + tester + .api + .call() + .balances() + .transfer_with_memo(payer.account().into(), 1, None)? + .submit_and_watch(&mut target) + .await? + .ok() + .await?; + let after = remaining().await?; + assert!(after < before, "user-submitted tx should debit the subsidy"); + + Ok(()) + } + + /// Limit management: increase/decrease/set on an active subsidy. + #[tokio::test] + #[test_log::test] + async fn limit_management() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["RNPayer4", "RNTarget4"]).await?; + let mut payer = users.remove(0); + let target = users.remove(0); + + setup_subsidy(&tester, &mut payer, &mut target.clone(), 100 * ONE_POLYX).await?; + + let get_remaining = || async { + Ok::<_, anyhow::Error>( + tester + .api + .query() + .relayer() + .subsidies(target.account()) + .await? + .map(|s| s.remaining) + .unwrap_or(0), + ) + }; + + // Increase. + tester + .api + .call() + .relayer() + .increase_polyx_limit(target.account(), 50 * ONE_POLYX)? + .submit_and_watch(&mut payer) + .await? + .ok() + .await?; + assert!(get_remaining().await? >= 150 * ONE_POLYX); + + // Decrease. + tester + .api + .call() + .relayer() + .decrease_polyx_limit(target.account(), 30 * ONE_POLYX)? + .submit_and_watch(&mut payer) + .await? + .ok() + .await?; + assert!( + get_remaining().await? <= 120 * ONE_POLYX, + "decrease should reduce remaining" + ); + + // Set absolute value. + tester + .api + .call() + .relayer() + .update_polyx_limit(target.account(), 200 * ONE_POLYX)? + .submit_and_watch(&mut payer) + .await? + .ok() + .await?; + assert_eq!( + get_remaining().await?, + 200 * ONE_POLYX, + "update_polyx_limit sets remaining exactly" + ); + + Ok(()) + } + + /// Pending subsidies can be revoked before acceptance. + #[tokio::test] + #[test_log::test] + async fn revoke_pending_subsidy() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["RNPayer5", "RNTarget5"]).await?; + let mut payer = users.remove(0); + let mut target = users.remove(0); + + // Approve but don't accept yet. + tester + .api + .call() + .relayer() + .approve_subsidy(target.account(), 1000 * ONE_POLYX)? + .submit_and_watch(&mut payer) + .await? + .ok() + .await?; + + // Payer revokes the pending authorization. + tester + .api + .call() + .relayer() + .revoke_subsidy(target.account())? + .submit_and_watch(&mut payer) + .await? + .ok() + .await?; + + // Accepting now fails - nothing pending. + let mut res = tester + .api + .call() + .relayer() + .accept_subsidy(payer.account())? + .submit_and_watch(&mut target) + .await?; + assert!(res.ok().await.is_err(), "accept after revoke should fail"); + + Ok(()) + } + + /// Either party can remove an active subsidy. + #[tokio::test] + #[test_log::test] + async fn remove_active_subsidy() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["RNPayer6", "RNTarget6"]).await?; + let mut payer = users.remove(0); + let mut target = users.remove(0); + + setup_subsidy(&tester, &mut payer, &mut target, 1000 * ONE_POLYX).await?; + assert!( + tester.api.query().relayer().subsidies(target.account()).await?.map(|s| s.remaining).unwrap_or(0) > 0, + "subsidy should exist after acceptance" + ); + + // Target removes it. + tester + .api + .call() + .relayer() + .remove_subsidy(target.account(), payer.account())? + .submit_and_watch(&mut target) + .await? + .ok() + .await?; + + assert_eq!( + tester + .api + .query() + .relayer() + .subsidies(target.account()) + .await? + .map(|s| s.remaining), + None, + "subsidy should be gone" + ); + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/revive_erc3643.rs b/integration/tests/revive_erc3643.rs index 9b891d7e4a..d4119f4c33 100644 --- a/integration/tests/revive_erc3643.rs +++ b/integration/tests/revive_erc3643.rs @@ -52,11 +52,10 @@ async fn erc3643_set_symbol() -> Result<()> { assert_eq!(erc3643.symbol().await.unwrap(), "".to_string()); - erc3643 - .set_symbol(&mut caller, "NEW_SYMBOL".to_string()) - .await?; + let symbol = unique_symbol("ERC3643"); + erc3643.set_symbol(&mut caller, symbol.clone()).await?; - assert_eq!(erc3643.symbol().await.unwrap(), "NEW_SYMBOL".to_string()); + assert_eq!(erc3643.symbol().await.unwrap(), symbol); Ok(()) } diff --git a/integration/tests/settlement_mediators.rs b/integration/tests/settlement_mediators.rs new file mode 100644 index 0000000000..468b7f2c7e --- /dev/null +++ b/integration/tests/settlement_mediators.rs @@ -0,0 +1,299 @@ +//! Settlement mediators: mediated instructions, mediator affirm/reject, instruction locking. +#[cfg(feature = "current_release")] +mod settlement_mediators_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::{AssetHolder, AssetHolderKind}, + identity_id::{PortfolioId, PortfolioKind}, + settlement::{Leg, SettlementType}, + }; + + async fn create_asset_in_portfolio( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::DefaultPortfolio), + ) + .await?; + Ok(helper.asset_id) + } + + fn pf(user: &User) -> PortfolioId { + PortfolioId { + did: user.did.expect("did"), + kind: PortfolioKind::Default, + } + } + + /// A mediated instruction requires the mediator's affirmation. + #[tokio::test] + #[test_log::test] + async fn mediated_instruction_affirm_flow() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SMOwner", "SMInv1", "SMMed1"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + let mut mediator = users.next().unwrap(); + + let asset_id = + create_asset_in_portfolio(&mut tester, &mut owner, "SMEDIA", 10_000).await?; + let mediator_did = mediator.did.unwrap(); + + // Instruction with one leg + mediator attached at creation. + let leg = Leg::Fungible { + sender: AssetHolder::Portfolio(pf(&owner)), + receiver: AssetHolder::Portfolio(pf(&inv1)), + asset_id: asset_id.clone(), + amount: 100, + }; + let mut res = tester + .api + .call() + .settlement() + .add_instruction_with_mediators( + None, + SettlementType::SettleOnAffirmation, + None, + None, + vec![leg], + None, + BTreeSet::from([mediator_did]), + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let inst_id = get_instruction_id(&mut res).await?.expect("instruction id"); + + // Mediator affirms (with expiry). + tester + .api + .call() + .settlement() + .affirm_instruction_as_mediator(inst_id, None)? + .execute(&mut mediator) + .await? + .ok() + .await?; + + use polymesh_api::types::polymesh_primitives::settlement::MediatorAffirmationStatus; + let status = tester + .api + .query() + .settlement() + .instruction_mediators_affirmations(inst_id, mediator_did) + .await?; + assert!( + matches!(status, MediatorAffirmationStatus::Affirmed { .. }), + "mediator affirmation should be recorded" + ); + + Ok(()) + } + + /// A mediator can reject a pending instruction outright. + #[tokio::test] + #[test_log::test] + async fn mediator_rejection_rejects_instruction() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SMROwner", "SMRInv1", "SMRMed1"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + let mut mediator = users.next().unwrap(); + + let asset_id = + create_asset_in_portfolio(&mut tester, &mut owner, "SMREJX", 10_000).await?; + let mediator_did = mediator.did.unwrap(); + + let leg = Leg::Fungible { + sender: AssetHolder::Portfolio(pf(&owner)), + receiver: AssetHolder::Portfolio(pf(&inv1)), + asset_id: asset_id.clone(), + amount: 50, + }; + let mut res = tester + .api + .call() + .settlement() + .add_instruction_with_mediators( + None, + SettlementType::SettleOnAffirmation, + None, + None, + vec![leg], + None, + BTreeSet::from([mediator_did]), + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let inst_id = get_instruction_id(&mut res).await?.expect("instruction id"); + + // Mediator rejects with an upper bound on asset count. + tester + .api + .call() + .settlement() + .reject_instruction_as_mediator( + inst_id, + Some(polymesh_api::types::polymesh_primitives::settlement::AssetCount { + fungible: 4, + non_fungible: 0, + off_chain: 0, + }), + )? + .execute(&mut mediator) + .await? + .ok() + .await?; + + // Instruction status becomes rejected/failed. + let status = tester + .api + .query() + .settlement() + .instruction_statuses(inst_id) + .await?; + assert!( + matches!( + status, + polymesh_api::types::polymesh_primitives::settlement::InstructionStatus::Rejected(_) + | polymesh_api::types::polymesh_primitives::settlement::InstructionStatus::Failed + ), + "mediator rejection must move instruction out of Pending" + ); + + Ok(()) + } + + /// lock/unlock is a mediator-only path on SettleAfterLock instructions. + #[tokio::test] + #[test_log::test] + async fn lock_and_unlock_instruction() -> Result<()> { + use polymesh_api::types::polymesh_primitives::settlement::InstructionStatus; + + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SMLOwner", "SMLInv1", "SMLMed"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + let mut mediator = users.next().unwrap(); + + let asset_id = + create_asset_in_portfolio(&mut tester, &mut owner, "SMLOCK", 10_000).await?; + let mediator_did = mediator.did.unwrap(); + + let sender = AssetHolder::Portfolio(pf(&owner)); + let leg = Leg::Fungible { + sender: sender.clone(), + receiver: AssetHolder::Portfolio(pf(&inv1)), + asset_id: asset_id.clone(), + amount: 25, + }; + let mut res = tester + .api + .call() + .settlement() + .add_instruction_with_mediators( + None, + SettlementType::SettleAfterLock, + None, + None, + vec![leg], + None, + BTreeSet::from([mediator_did]), + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let inst_id = get_instruction_id(&mut res).await?.expect("instruction id"); + + tester + .api + .call() + .settlement() + .affirm_instruction(inst_id, BTreeSet::from([sender]))? + .execute(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .settlement() + .affirm_instruction_as_mediator(inst_id, None)? + .execute(&mut mediator) + .await? + .ok() + .await?; + + tester + .api + .call() + .settlement() + .lock_instruction( + inst_id, + sp_weights::Weight::from_parts(10_000_000_000, 10_000_000), + )? + .execute(&mut mediator) + .await? + .ok() + .await?; + assert!( + matches!( + tester + .api + .query() + .settlement() + .instruction_statuses(inst_id) + .await?, + InstructionStatus::LockedForExecution + ), + "mediator lock must move instruction to LockedForExecution" + ); + + tester + .api + .call() + .settlement() + .unlock_instruction(inst_id)? + .execute(&mut mediator) + .await? + .ok() + .await?; + assert!( + matches!( + tester + .api + .query() + .settlement() + .instruction_statuses(inst_id) + .await?, + InstructionStatus::Pending + ), + "unlock must return the instruction to Pending" + ); + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/settlement_scheduling.rs b/integration/tests/settlement_scheduling.rs new file mode 100644 index 0000000000..b002fc0cd1 --- /dev/null +++ b/integration/tests/settlement_scheduling.rs @@ -0,0 +1,301 @@ +//! Scheduled & automatic settlement execution (requires `timed` feature: waits on blocks). +#[cfg(all(feature = "current_release", feature = "timed"))] +mod settlement_scheduling_tests { + use std::collections::BTreeSet; + + use anyhow::{bail, Result}; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::{AssetHolder, AssetHolderKind}, + identity_id::{PortfolioId, PortfolioKind}, + settlement::{InstructionStatus, Leg, SettlementType}, + }; + + const MAX_BLOCK_WAIT: u32 = 40; + + async fn create_asset_in_portfolio( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::DefaultPortfolio), + ) + .await?; + Ok(helper.asset_id) + } + + fn pf(user: &User) -> PortfolioId { + PortfolioId { + did: user.did.expect("did"), + kind: PortfolioKind::Default, + } + } + + fn holder(user: &User) -> AssetHolder { + AssetHolder::Portfolio(pf(user)) + } + + async fn block_number(tester: &PolymeshTester) -> Result { + Ok(tester.api.query().system().number().await?) + } + + /// Poll until the instruction leaves Pending or we run out of blocks. + async fn wait_for_completion( + tester: &PolymeshTester, + inst_id: polymesh_api::types::polymesh_primitives::settlement::InstructionId, + start_block: u32, + ) -> Result> { + loop { + let now = block_number(tester).await?; + if now > start_block + MAX_BLOCK_WAIT { + bail!("instruction did not complete within {MAX_BLOCK_WAIT} blocks"); + } + let status = tester + .api + .query() + .settlement() + .instruction_statuses(inst_id) + .await?; + match status { + InstructionStatus::Pending | InstructionStatus::Unknown => { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + other => return Ok(other), + } + } + } + + /// SettleOnAffirmation instructions execute automatically once all parties affirm. + #[tokio::test] + #[test_log::test] + async fn settle_on_affirmation_auto_executes() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SSOwner", "SSInv1"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + + let asset_id = + create_asset_in_portfolio(&mut tester, &mut owner, "SSAFFRM", 10_000).await?; + + let leg = Leg::Fungible { + sender: holder(&owner), + receiver: holder(&inv1), + asset_id: asset_id.clone(), + amount: 100, + }; + let mut res = tester + .api + .call() + .settlement() + .add_instruction(None, SettlementType::SettleOnAffirmation, None, None, vec![leg], None)? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let inst_id = get_instruction_id(&mut res).await?.expect("instruction id"); + let start = block_number(&tester).await?; + + // Current release auto-affirms the receiver; only the sender must affirm. + tester + .api + .call() + .settlement() + .affirm_instruction(inst_id, BTreeSet::from([holder(&owner)]))? + .execute(&mut owner) + .await? + .ok() + .await?; + + // No manual execution needed. + let status = wait_for_completion(&tester, inst_id, start).await?; + assert!(matches!(status, InstructionStatus::Success(_)), "auto-settlement should succeed"); + + let bal = tester + .api + .query() + .portfolio() + .portfolio_asset_balances(pf(&inv1), asset_id) + .await?; + assert_eq!(bal, 100); + + Ok(()) + } + + /// SettleOnBlock instructions auto-execute at/after the target block. + #[tokio::test] + #[test_log::test] + async fn settle_on_block_auto_executes() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SSOwner2", "SSInv2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + + let asset_id = + create_asset_in_portfolio(&mut tester, &mut owner, "SSBLOCK", 10_000).await?; + + // Target ~4 blocks out. + let target_block = block_number(&tester).await? + 4; + + let leg = Leg::Fungible { + sender: holder(&owner), + receiver: holder(&inv1), + asset_id: asset_id.clone(), + amount: 200, + }; + let mut res = tester + .api + .call() + .settlement() + .add_instruction( + None, + SettlementType::SettleOnBlock(target_block), + None, + None, + vec![leg], + None, + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let inst_id = get_instruction_id(&mut res).await?.expect("instruction id"); + + tester + .api + .call() + .settlement() + .affirm_instruction(inst_id, BTreeSet::from([holder(&owner)]))? + .execute(&mut owner) + .await? + .ok() + .await?; + + // The scheduler executes it around `target_block`. + let status = wait_for_completion(&tester, inst_id, target_block.saturating_sub(4)).await?; + assert!(matches!(status, InstructionStatus::Success(_))); + + let bal = tester + .api + .query() + .portfolio() + .portfolio_asset_balances(pf(&inv1), asset_id) + .await?; + assert_eq!(bal, 200); + + Ok(()) + } + + /// A single failing leg aborts the whole instruction (all-or-nothing legs). + #[tokio::test] + #[test_log::test] + async fn failing_leg_aborts_instruction() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SSOwner3", "SSInv3"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let inv1 = users.next().unwrap(); + + let asset_ok = + create_asset_in_portfolio(&mut tester, &mut owner, "SSFAILOK", 10_000).await?; + let asset_bad = + create_asset_in_portfolio(&mut tester, &mut owner, "SSFAILBD", 10_000).await?; + + // Two legs of the same instruction; freezing the second asset aborts the whole thing. + let legs = vec![ + Leg::Fungible { + sender: holder(&owner), + receiver: holder(&inv1), + asset_id: asset_ok.clone(), + amount: 100, + }, + Leg::Fungible { + sender: holder(&owner), + receiver: holder(&inv1), + asset_id: asset_bad.clone(), + amount: 100, + }, + ]; + let mut res = tester + .api + .call() + .settlement() + .add_instruction( + None, + SettlementType::SettleManual(0), + None, + None, + legs, + None, + )? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + let inst_id = get_instruction_id(&mut res).await?.expect("instruction id"); + + tester + .api + .call() + .settlement() + .affirm_instruction(inst_id, BTreeSet::from([holder(&owner)]))? + .execute(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .asset() + .freeze(asset_bad.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Manual execution fails because one asset is frozen. + let res = tester + .api + .call() + .settlement() + .execute_manual_instruction(inst_id, None, 5, 0, 0, None)? + .execute(&mut owner) + .await; + match res { + Ok(mut r) => assert!(r.ok().await.is_err(), "execution must fail on bad leg"), + Err(_) => {} + } + + // Atomicity: nothing moved. + let bal_ok = tester + .api + .query() + .portfolio() + .portfolio_asset_balances(pf(&inv1), asset_ok) + .await?; + let bal_bad = tester + .api + .query() + .portfolio() + .portfolio_asset_balances(pf(&inv1), asset_bad) + .await?; + assert_eq!(bal_ok, 0, "no tokens may move when any leg fails"); + assert_eq!(bal_bad, 0, "no tokens may move when any leg fails"); + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/settlement_venues.rs b/integration/tests/settlement_venues.rs new file mode 100644 index 0000000000..a43d5fa760 --- /dev/null +++ b/integration/tests/settlement_venues.rs @@ -0,0 +1,247 @@ +//! Settlement venues: CRUD + per-asset venue filtering. +#[cfg(feature = "current_release")] +mod settlement_venues_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::AssetHolderKind, + settlement::{VenueDetails, VenueId, VenueType}, + }; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::Account), + ) + .await?; + Ok(helper.asset_id) + } + + async fn create_venue( + tester: &PolymeshTester, + user: &mut User, + name: &str, + ) -> Result { + let mut res = tester + .api + .call() + .settlement() + .create_venue(VenueDetails(name.as_bytes().to_vec()), Default::default(), VenueType::Other)? + .submit_and_watch(user) + .await?; + res.ok().await?; + get_venue_id(&mut res) + .await? + .ok_or_else(|| anyhow::anyhow!("VenueCreated event not found")) + } + + /// Venue details and type are updatable by the owner. + #[tokio::test] + #[test_log::test] + async fn update_details_and_type() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["SVOwner"]).await?.into_iter(); + let mut owner = users.next().unwrap(); + + let venue_id = create_venue(&tester, &mut owner, "SVOriginal").await?; + + // Update details. + let new_details = VenueDetails(b"SVUpdated".to_vec()); + tester + .api + .call() + .settlement() + .update_venue_details(venue_id, new_details.clone())? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let details = tester.api.query().settlement().details(venue_id).await?; + assert_eq!(details, new_details, "venue details should be updated"); + + // Update type to Exchange. + tester + .api + .call() + .settlement() + .update_venue_type(venue_id, VenueType::Exchange)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let venue = tester + .api + .query() + .settlement() + .venue_info(venue_id) + .await? + .expect("venue info"); + assert_eq!(venue.venue_type, VenueType::Exchange); + + Ok(()) + } + + /// Venue signers can be added & removed; only signers may affirm for the venue. + #[tokio::test] + #[test_log::test] + async fn update_signers() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SVOwner2", "SVSigner1", "SVSigner2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let signer1 = users.next().unwrap(); + let signer2 = users.next().unwrap(); + + let venue_id = create_venue(&tester, &mut owner, "SVSigners").await?; + + // Add both signers. + tester + .api + .call() + .settlement() + .update_venue_signers( + venue_id, + BTreeSet::from([signer1.account(), signer2.account()]), + true, // add + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let count1 = tester + .api + .query() + .settlement() + .number_of_venue_signers(venue_id) + .await?; + assert_eq!(count1, 2, "two added signers"); + + // Remove signer2 again. + tester + .api + .call() + .settlement() + .update_venue_signers(venue_id, BTreeSet::from([signer2.account()]), false)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + let count2 = tester + .api + .query() + .settlement() + .number_of_venue_signers(venue_id) + .await?; + assert_eq!(count2, 1, "signer2 removed"); + + Ok(()) + } + + /// Per-asset venue allow-listing gates which venues may settle its instructions. + #[tokio::test] + #[test_log::test] + async fn venue_filtering_allow_disallow() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SVOwner3", "SVVenueUser"]) + .await? + .into_iter(); + let mut asset_owner = users.next().unwrap(); + let mut venue_user = users.next().unwrap(); + + let asset_id = create_asset(&mut tester, &mut asset_owner, "SVFILTER", 1_000).await?; + + let v1 = create_venue(&tester, &mut venue_user, "SVAllowed").await?; + let v2 = create_venue(&tester, &mut venue_user, "SVBlocked").await?; + + // Enable allow-list filtering for the asset. + tester + .api + .call() + .settlement() + .set_venue_filtering(asset_id.clone(), true)? + .submit_and_watch(&mut asset_owner) + .await? + .ok() + .await?; + + // Nothing allowed yet: v1 not in the list. + let allowed_v1 = tester + .api + .query() + .settlement() + .venue_allow_list(asset_id.clone(), v1) + .await?; + assert!(!allowed_v1, "freshly filtered asset allows nobody"); + + // Allow v1. + tester + .api + .call() + .settlement() + .allow_venues(asset_id.clone(), vec![v1])? + .submit_and_watch(&mut asset_owner) + .await? + .ok() + .await?; + assert!( + tester + .api + .query() + .settlement() + .venue_allow_list(asset_id.clone(), v1) + .await?, + "v1 should now be allowed" + ); + assert!( + !tester + .api + .query() + .settlement() + .venue_allow_list(asset_id.clone(), v2) + .await?, + "v2 must remain blocked" + ); + + // Disallow v1 again. + tester + .api + .call() + .settlement() + .disallow_venues(asset_id.clone(), vec![v1])? + .submit_and_watch(&mut asset_owner) + .await? + .ok() + .await?; + assert!( + !tester + .api + .query() + .settlement() + .venue_allow_list(asset_id, v1) + .await?, + "v1 disallowed" + ); + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/statistics_enforcement.rs b/integration/tests/statistics_enforcement.rs new file mode 100644 index 0000000000..b3122adf58 --- /dev/null +++ b/integration/tests/statistics_enforcement.rs @@ -0,0 +1,414 @@ +//! Statistics transfer-manager enforcement: investor count limits & exemptions. +#[cfg(feature = "current_release")] +mod statistics_enforcement_tests { + use std::collections::BTreeSet; + + use anyhow::Result; + + use integration::*; + use polymesh_api::types::polymesh_primitives::{ + asset::AssetHolderKind, + condition::{TrustedFor, TrustedIssuer}, + statistics::{Stat2ndKey, StatOpType, StatType, StatUpdate}, + transfer_compliance::{TransferCondition, TransferConditionExemptKey}, + }; + + async fn create_asset( + tester: &mut PolymeshTester, + owner: &mut User, + ticker: &str, + amount: u128, + ) -> Result { + let helper = AssetHelper::new_full( + &tester.api, + owner, + ticker, + amount, + BTreeSet::new(), + false, + Some(AssetHolderKind::Account), + ) + .await?; + Ok(helper.asset_id) + } + + fn count_stat() -> StatType { + StatType { + operation_type: StatOpType::Count, + claim_issuer: None, + } + } + + /// MaxInvestorCount blocks the Nth+1 transfer until the limit is raised. + #[tokio::test] + #[test_log::test] + async fn max_investor_count_enforcement() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SMIOwner", "SMIIssuer", "SMIInv1", "SMIInv2", "SMIInv3"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let issuer = users.next().unwrap(); + let mut inv1 = users.next().unwrap(); + let mut inv2 = users.next().unwrap(); + let inv3 = users.next().unwrap(); + + let issuer_did = issuer.did.unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "SMICNT", 1_000_000).await?; + + // Trusted issuer so claims satisfy compliance. + tester + .api + .call() + .compliance_manager() + .add_default_trusted_claim_issuer( + asset_id.clone(), + TrustedIssuer { + issuer: issuer_did, + trusted_for: TrustedFor::Any, + }, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Track investor Count and cap at 2. + tester + .api + .call() + .statistics() + .set_active_asset_stats(asset_id.clone(), BTreeSet::from([count_stat()]))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .statistics() + .set_asset_transfer_compliance( + asset_id.clone(), + BTreeSet::from([TransferCondition::MaxInvestorCount(2)]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // 1st & 2nd investor OK. + for inv in [&mut inv1, &mut inv2] { + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + res.ok().await?; + } + + // 3rd investor blocked. + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv3.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err(), "3rd investor must be blocked by MaxInvestorCount=2"); + + // Raise the cap to 3. + tester + .api + .call() + .statistics() + .set_asset_transfer_compliance( + asset_id.clone(), + BTreeSet::from([TransferCondition::MaxInvestorCount(3)]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv3.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } + + /// Exempted entities bypass the investor-count restriction. + #[tokio::test] + #[test_log::test] + async fn exempt_entity_bypasses_limit() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&[ + "SEEOwner", "SEEIssuer", "SEEInv1", "SEEInv2", "SEEDealer", "SEEExtra", + ]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let issuer = users.next().unwrap(); + let mut inv1 = users.next().unwrap(); + let mut inv2 = users.next().unwrap(); + let dealer = users.next().unwrap(); + let extra = users.next().unwrap(); + + let issuer_did = issuer.did.unwrap(); + let owner_did = owner.did.expect("owner did"); + + let asset_id = create_asset(&mut tester, &mut owner, "SEEXMP", 1_000_000).await?; + + tester + .api + .call() + .compliance_manager() + .add_default_trusted_claim_issuer( + asset_id.clone(), + TrustedIssuer { + issuer: issuer_did, + trusted_for: TrustedFor::Any, + }, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .statistics() + .set_active_asset_stats(asset_id.clone(), BTreeSet::from([count_stat()]))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .statistics() + .set_asset_transfer_compliance( + asset_id.clone(), + BTreeSet::from([TransferCondition::MaxInvestorCount(2)]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Fill up both slots. + for inv in [&mut inv1, &mut inv2] { + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + } + + // Count restrictions exempt the *sender*. Exempt the owner (sender) so they can + // still transfer to a new investor once the cap is reached. + tester + .api + .call() + .statistics() + .set_entities_exempt( + true, + TransferConditionExemptKey { + asset_id: asset_id.clone(), + op: StatOpType::Count, + claim_type: None, + }, + BTreeSet::from([owner_did]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), dealer.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Remove exemption for the sender (owner) -> further transfers beyond the cap are blocked again. + tester + .api + .call() + .statistics() + .set_entities_exempt( + false, + TransferConditionExemptKey { + asset_id: asset_id.clone(), + op: StatOpType::Count, + claim_type: None, + }, + BTreeSet::from([owner_did]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // After exemption removed, transfer to a fresh investor should be blocked again. + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), extra.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + assert!( + res.ok().await.is_err(), + "transfer should be blocked again after exemption removed" + ); + + Ok(()) + } + + /// batch_update_asset_stats can adjust tracked stats (investor count). + #[tokio::test] + #[test_log::test] + async fn batch_update_asset_stats_adjusts_count() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["SBIOwner", "SBIIssuer", "SBIInv1", "SBIInv2"]) + .await? + .into_iter(); + let mut owner = users.next().unwrap(); + let issuer = users.next().unwrap(); + let inv1 = users.next().unwrap(); + let inv2 = users.next().unwrap(); + + let issuer_did = issuer.did.unwrap(); + + let asset_id = create_asset(&mut tester, &mut owner, "SBIBAT", 1_000_000).await?; + + tester + .api + .call() + .compliance_manager() + .add_default_trusted_claim_issuer( + asset_id.clone(), + TrustedIssuer { + issuer: issuer_did, + trusted_for: TrustedFor::Any, + }, + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + tester + .api + .call() + .statistics() + .set_active_asset_stats(asset_id.clone(), BTreeSet::from([count_stat()]))? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Enable Count tracking and seed investor count to 0, then cap at 1. + tester + .api + .call() + .statistics() + .batch_update_asset_stats( + asset_id.clone(), + count_stat(), + BTreeSet::from([StatUpdate { + key2: Stat2ndKey::NoClaimStat, + value: Some(0), // seed investor count + }]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + tester + .api + .call() + .statistics() + .set_asset_transfer_compliance( + asset_id.clone(), + BTreeSet::from([TransferCondition::MaxInvestorCount(1)]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // First investor fine. + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv1.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Second blocked at cap=1... + let mut res = tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv2.account(), 100, None)? + .submit_and_watch(&mut owner) + .await?; + assert!(res.ok().await.is_err()); + + // Correct the stored count downwards so the second investor can be added. + tester + .api + .call() + .statistics() + .batch_update_asset_stats( + asset_id.clone(), + count_stat(), + BTreeSet::from([StatUpdate { + key2: Stat2ndKey::NoClaimStat, + value: Some(0), + }]), + )? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + // Now the previously blocked transfer succeeds. + tester + .api + .call() + .asset() + .transfer_asset(asset_id.clone(), inv2.account(), 100, None)? + .submit_and_watch(&mut owner) + .await? + .ok() + .await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/integration/tests/sto.rs b/integration/tests/sto.rs index 1e1105ad41..60e989200b 100644 --- a/integration/tests/sto.rs +++ b/integration/tests/sto.rs @@ -582,3 +582,311 @@ mod sto_v8_tests { Ok(()) } } + +// >=v8.0 - Additional STO tests for freeze/unfreeze/modify/stop +#[cfg(feature = "current_release")] +mod sto_extended_tests { + use super::*; + + struct StoSetup { + offering: AssetId, + fundraiser_id: polymesh_api::types::polymesh_primitives::sto::FundraiserId, + investor_portfolio: PortfolioId, + } + + async fn setup_sto( + tester: &mut PolymeshTester, + venue_user: &mut User, + investor: &mut User, + offering_name: &str, + funding_name: &str, + venue_name: &str, + ) -> Result { + let mut venue_res = tester + .api + .call() + .settlement() + .create_venue( + VenueDetails(venue_name.as_bytes().to_vec()), + Default::default(), + VenueType::Sto, + )? + .submit_and_watch(venue_user) + .await?; + venue_res.ok().await?; + let venue_id = get_venue_id(&mut venue_res).await?.expect("venue"); + + let mut v = venue_user.clone(); + let api = tester.api.clone(); + let offering_name = offering_name.to_string(); + let offering = tokio::spawn(async move { + AssetHelper::new(&api, &mut v, &offering_name, 20_000_000_000, BTreeSet::new()).await + }); + let mut v = venue_user.clone(); + let api = tester.api.clone(); + let mut inv = investor.clone(); + let funding_name = funding_name.to_string(); + let funding = tokio::spawn(async move { + let mut a = + AssetHelper::new(&api, &mut v, &funding_name, 1_000_000, BTreeSet::new()).await?; + a.fund_investors(&mut [&mut inv], 1_000_000_000).await?; + Ok::<_, anyhow::Error>(a) + }); + let offering = offering.await??; + let funding = funding.await??; + let venue_did = offering.issuer_did; + let investor_did = investor.did.expect("investor did"); + let fundraiser_portfolio = PortfolioId { + did: venue_did, + kind: PortfolioKind::Default, + }; + let investor_portfolio = PortfolioId { + did: investor_did, + kind: PortfolioKind::Default, + }; + + let mut fr = tester + .api + .call() + .sto() + .create_fundraiser( + fundraiser_portfolio, + offering.asset_id.clone(), + fundraiser_portfolio, + funding.asset_id, + vec![PriceTier { + total: 3_000_000_000, + price: 800_000, + }], + venue_id, + None, + None, + 1_000_000u128, + FundraiserName(b"ExtFundraiser".to_vec()), + )? + .submit_and_watch(venue_user) + .await?; + fr.ok().await?; + let (_, fundraiser_id) = get_fundraiser_id(&mut fr).await?.expect("fundraiser"); + Ok(StoSetup { + offering: offering.asset_id, + fundraiser_id, + investor_portfolio, + }) + } + + #[tokio::test] + #[test_log::test] + async fn sto_freeze_unfreeze() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["StoFrzVenue", "StoFrzInv"]) + .await? + .into_iter(); + let mut venue = users.next().unwrap(); + let mut investor = users.next().unwrap(); + let setup = setup_sto( + &mut tester, + &mut venue, + &mut investor, + "StoFrzOff", + "StoFrzFund", + "StoFrzVenue", + ) + .await?; + + tester + .api + .call() + .sto() + .freeze_fundraiser(setup.offering.clone(), setup.fundraiser_id.clone())? + .submit_and_watch(&mut venue) + .await? + .ok() + .await?; + + let mut invest = tester + .api + .call() + .sto() + .invest( + setup.offering.clone(), + setup.fundraiser_id.clone(), + setup.investor_portfolio, + FundingMethod::OnChain(setup.investor_portfolio), + 1_050_000_000, + Some(900_000), + )? + .submit_and_watch(&mut investor) + .await?; + assert!(invest.ok().await.is_err(), "invest while frozen must fail"); + + tester + .api + .call() + .sto() + .unfreeze_fundraiser(setup.offering.clone(), setup.fundraiser_id.clone())? + .submit_and_watch(&mut venue) + .await? + .ok() + .await?; + + tester + .api + .call() + .sto() + .invest( + setup.offering, + setup.fundraiser_id, + setup.investor_portfolio, + FundingMethod::OnChain(setup.investor_portfolio), + 1_050_000_000, + Some(900_000), + )? + .submit_and_watch(&mut investor) + .await? + .ok() + .await?; + + Ok(()) + } + + #[tokio::test] + #[test_log::test] + async fn sto_modify_window() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["StoWinVenue", "StoWinInv"]) + .await? + .into_iter(); + let mut venue = users.next().unwrap(); + let mut investor = users.next().unwrap(); + let setup = setup_sto( + &mut tester, + &mut venue, + &mut investor, + "StoWinOff", + "StoWinFund", + "StoWinVenue", + ) + .await?; + + let now = tester.api.query().timestamp().now().await?; + tester + .api + .call() + .sto() + .modify_fundraiser_window( + setup.offering.clone(), + setup.fundraiser_id.clone(), + now + 60_000, + Some(now + 120_000), + )? + .submit_and_watch(&mut venue) + .await? + .ok() + .await?; + + let mut invest = tester + .api + .call() + .sto() + .invest( + setup.offering.clone(), + setup.fundraiser_id.clone(), + setup.investor_portfolio, + FundingMethod::OnChain(setup.investor_portfolio), + 1_050_000_000, + Some(900_000), + )? + .submit_and_watch(&mut investor) + .await?; + assert!( + invest.ok().await.is_err(), + "invest before the new window must fail" + ); + + tester + .api + .call() + .sto() + .modify_fundraiser_window( + setup.offering.clone(), + setup.fundraiser_id.clone(), + now.saturating_sub(1_000), + None, + )? + .submit_and_watch(&mut venue) + .await? + .ok() + .await?; + + tester + .api + .call() + .sto() + .invest( + setup.offering, + setup.fundraiser_id, + setup.investor_portfolio, + FundingMethod::OnChain(setup.investor_portfolio), + 1_050_000_000, + Some(900_000), + )? + .submit_and_watch(&mut investor) + .await? + .ok() + .await?; + + Ok(()) + } + + #[tokio::test] + #[test_log::test] + async fn sto_stop() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["StoStpVenue", "StoStpInv"]) + .await? + .into_iter(); + let mut venue = users.next().unwrap(); + let mut investor = users.next().unwrap(); + let setup = setup_sto( + &mut tester, + &mut venue, + &mut investor, + "StoStpOff", + "StoStpFund", + "StoStpVenue", + ) + .await?; + + tester + .api + .call() + .sto() + .stop(setup.offering.clone(), setup.fundraiser_id.clone())? + .submit_and_watch(&mut venue) + .await? + .ok() + .await?; + + let mut invest = tester + .api + .call() + .sto() + .invest( + setup.offering, + setup.fundraiser_id, + setup.investor_portfolio, + FundingMethod::OnChain(setup.investor_portfolio), + 1_050_000_000, + Some(900_000), + )? + .submit_and_watch(&mut investor) + .await?; + assert!(invest.ok().await.is_err(), "invest after stop must fail"); + + Ok(()) + } +} diff --git a/integration/tests/utility_calls.rs b/integration/tests/utility_calls.rs new file mode 100644 index 0000000000..d342be9582 --- /dev/null +++ b/integration/tests/utility_calls.rs @@ -0,0 +1,85 @@ +//! Utility pallet: batch vs batch_all, as_derivative. +#[cfg(feature = "current_release")] +mod utility_calls_tests { + use anyhow::Result; + + use integration::*; + + /// `batch` continues after an item failure; `batch_all` is atomic. + #[tokio::test] + #[test_log::test] + async fn batch_vs_batch_all() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["UtilUser"]).await?.into_iter(); + let mut user = users.next().unwrap(); + + let ok_call = tester + .api + .call() + .system() + .remark(b"ok".to_vec())? + .into_runtime_call(); + + // Freeze a non-existent asset so this item fails at dispatch. + let fail_call = tester + .api + .call() + .asset() + .freeze(AssetId([0u8; 16]))? + .into_runtime_call(); + + // force_batch records per-item success/failure without aborting. + let mut batch_res = tester + .api + .call() + .utility() + .force_batch(vec![ok_call.clone(), fail_call.clone()])? + .submit_and_watch(&mut user) + .await?; + let results = get_batch_results(&mut batch_res).await?; + assert_eq!(results, vec![true, false], "force_batch continues after a failure"); + + // batch_all: atomic — the whole call fails. + let mut all_res = tester + .api + .call() + .utility() + .batch_all(vec![ok_call, fail_call])? + .submit_and_watch(&mut user) + .await?; + assert!( + all_res.ok().await.is_err(), + "batch_all must fail if any item fails" + ); + + Ok(()) + } + + /// `as_derivative` dispatches as a derived sub-account. + #[tokio::test] + #[test_log::test] + async fn as_derivative_remark() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester.users(&["UtilDeriv"]).await?.into_iter(); + let mut user = users.next().unwrap(); + + let call = tester + .api + .call() + .system() + .remark(b"derivative".to_vec())? + .into_runtime_call(); + + tester + .api + .call() + .utility() + .as_derivative(0, call)? + .submit_and_watch(&mut user) + .await? + .ok() + .await?; + + Ok(()) + } +} \ No newline at end of file