diff --git a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md index 5ea2ed791e..75d63312f9 100644 --- a/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md +++ b/packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md @@ -133,9 +133,21 @@ Source citations for the "Wallet API exists" column are listed inline per case | ID-006b | Identity-key derivation index boundary (`0` and `DEFAULT_GAP_LIMIT - 1`) | P2 | M | | TK-001 | Token transfer between two identities | P1 | L | | TK-001b | Token transfer of amount 0 | P2 | S | -| TK-002 | Token claim (perpetual / pre-programmed distribution) | P2 | L | -| TK-003 | Token mint (authorised identity) | P2 | M | -| TK-004 | Token burn | P2 | M | +| TK-001c | Token transfer across re-issued identity (signer rotation) | P2 | M | +| TK-002 | Token claim (perpetual — long-runtime nightly) | P2 | L | +| TK-003 | Register token contract (deploy via `create_data_contract_with_signer`) | P0 | L | +| TK-004 | Token transfer fee accounting & balance round-trip | P0 | M | +| TK-005 | Token mint + total-supply assertion | P1 | M | +| TK-005b | Mint with `recipient_id != self` | P2 | S | +| TK-006 | Token burn + total-supply decrement | P1 | M | +| TK-007 | Freeze identity for token (admin action) | P1 | M | +| TK-008 | Unfreeze identity for token | P1 | S | +| TK-009 | Destroy frozen funds | P1 | M | +| TK-010 | Pause and resume token (emergency action) | P1 | M | +| TK-011 | Set price + direct purchase round-trip | P1 | L | +| TK-012 | Update token config (single ChangeItem mutation) | P2 | M | +| TK-013 | Token claim from pre-programmed distribution | P2 | L | +| TK-014 | Group-action gateway: queue a mint, list pending, co-sign | P2 | L | | CR-001 | SPV mn-list sync readiness | P1 | M | | CR-002 | Core wallet receive address derivation | P1 | M | | CR-003 | Asset-lock-funded identity registration (full path) | P2 | L | @@ -181,7 +193,7 @@ Source citations for the "Wallet API exists" column are listed inline per case | Found-017 | `register_wallet` registers wallet in memory even when persister `store` returns `Err` — vanishes on next launch | P2 | S | | Found-018 | `PlatformAddressChangeSet::merge` documents fee semantics as "fee paid by the transfer that produced this changeset" but actually accumulates fees across merged changesets | P2 | S | -Counts by priority: **P0: 8**, **P1: 17** (incl. 2 post-Task #15), **P2: 53** (incl. 1 post-Task #15, 1 gated, 18 Found-bug pins), **DEFERRED: 1** (79 total entries; 60 baseline + 18 Found-bug pins + 1 deferred placeholder). +Counts by priority: **P0: 10**, **P1: 24** (incl. 2 post-Task #15), **P2: 56** (incl. 1 post-Task #15, 1 gated, 18 Found-bug pins), **DEFERRED: 1** (91 total index entries; 72 baseline + 18 Found-bug pins + 1 deferred placeholder). ### Platform Addresses (PA) @@ -883,22 +895,32 @@ Counts by priority: **P0: 8**, **P1: 17** (incl. 2 post-Task #15), **P2: 53** (i ### Tokens (TK) The wallet has token operations on the API surface -(`wallet/tokens/wallet.rs` + `wallet/identity/network/tokens/*`). They all -require an existing on-testnet token contract and an authorised identity. -Without a contract-registry strategy, only TK-001/TK-002 (operations on -existing balances) are achievable in P0/P1. +(`wallet/tokens/wallet.rs` + `wallet/identity/network/tokens/*`). The earlier +plan rested on an operator-pre-funded testnet token contract; that approach +is superseded. The current plan deploys a fresh token contract per CI run via +`create_data_contract_with_signer` (the wallet already accepts a +`tokens_schema_json` argument — `wallet/identity/network/contract.rs:124`), +shared across most TK cases via a OnceCell fixture and re-built fresh only +where a non-default contract config is required (pre-programmed distribution, +groups, paused-on-create). Every TK entry below is `Status: BLOCKED` until +both Wave A (Identity signer harness, currently on PR #3578) and Wave G +(token-contract bootstrap helpers, see §4) land. What were previously tracked +as `Gap-T1..Gap-T6` (wallet-API surface gaps) are now resolved: Wave G +delivers framework-level SDK-wrapper helpers for each, living in +`packages/rs-platform-wallet/tests/e2e/framework/tokens.rs`. No new wallet +public API is required; tests compose the SDK directly through those helpers. #### TK-001 — Token transfer between two identities - **Priority**: P1 -- **Status**: STUB — placeholder for follow-up PR (Wave A + Wave D — token contract operator config). +- **Status**: STUB — `tests/e2e/cases/tk_001_token_transfer.rs` (full body landed Wave 2-α; `#[ignore]`-tagged, runs on demand against testnet). - **Wallet feature exercised**: `wallet/identity/network/tokens/transfer.rs:21` (`token_transfer_with_signer`). - **DET parallel**: `dash-evo-tool/tests/backend-e2e/token_tasks.rs:359` (`step_transfer`). -- **Preconditions**: ID-001 helper; **a known testnet token contract** (env-driven `PLATFORM_WALLET_E2E_TOKEN_CONTRACT_ID` + `_TOKEN_POSITION`); the registered identity must already hold a non-zero balance of that token (operator pre-funds via the same flow used to fund the bank). +- **Preconditions**: Wave A signer + Wave G token-contract bootstrap (TK-003 helper); two registered identities (`identity_a`, `identity_b`); `identity_a` holds a non-zero token balance from an in-test mint (TK-005 helper). - **Scenario**: - 1. Register `identity_a` and `identity_b` per ID-001. - 2. Pre-condition: operator pre-funds `identity_a` with `≥ 100` tokens of the configured contract (one-time setup, similar to bank funding). - 3. Call `token_transfer_with_signer(identity_a, contract_id, token_position, identity_b, amount=50)`. - 4. Sync token balances on both. + 1. `setup_with_token_and_two_identities()` returns `(token_fixture, identity_a, identity_b)` (the shared OnceCell-cached contract). + 2. `identity_a` mints `≥ 100` tokens to self via the harness `mint_to` shortcut. + 3. Call `token_transfer_with_signer(identity_a, contract_id, token_position=0, identity_b, amount=50, …)`. + 4. Sync token balances on both via `token_balance_of`. - **Assertions**: - `identity_a` token balance decreased by exactly `50`. - `identity_b` token balance increased by exactly `50`. @@ -906,21 +928,17 @@ existing balances) are achievable in P0/P1. - **Negative variants**: - Transfer amount exceeds sender token balance → typed error. - Transfer with wrong `token_position` → contract-validation error. -- **Harness extensions required**: - - Wave A (Identity signer). - - `Config::token_contract_id` + `token_position` env vars. - - `TestWallet::token_balance(identity_id, contract_id, token_pos)` helper. - - Operator documentation: how to pre-fund tokens (one-time, sibling of bank pre-funding). +- **Harness extensions required**: Wave A; Wave G's `setup_with_token_and_two_identities`, `mint_to`, `token_balance_of`. - **Estimated complexity**: L -- **Rationale**: Most-used token op. Catches token-amount underflow bugs and credit-fee accounting bugs in one shot. +- **Rationale**: Most-used token op. Catches token-amount underflow bugs and credit-fee accounting bugs in one shot. TK-004 is the upgraded round-trip variant with explicit fee separation; TK-001 stays as the canonical happy path. #### TK-001b — Token transfer of amount 0 - **Priority**: P2 -- **Status**: STUB — placeholder for follow-up PR (Wave A + Wave D). +- **Status**: STUB — `tests/e2e/cases/tk_001b_token_transfer_zero.rs` (full body landed Wave 2-α; `#[ignore]`-tagged, runs on demand). - **Wallet feature exercised**: `wallet/identity/network/tokens/transfer.rs:21` zero-amount boundary. - **DET parallel**: none. -- **Preconditions**: TK-001 setup (two identities with non-zero token balance on `identity_a`). -- **Scenario**: call `token_transfer_with_signer(identity_a, contract_id, token_position, identity_b, amount=0)`. +- **Preconditions**: TK-001 setup (in-test deployed token + two identities with non-zero balance on `identity_a` via in-test mint). +- **Scenario**: call `token_transfer_with_signer(identity_a, contract_id, token_position=0, identity_b, amount=0, …)`. - **Assertions**: pin one contract: - **(a) Reject**: typed validation error of "amount must be positive" shape; no broadcast; balances unchanged. - **(b) Accept**: broadcast succeeds; both token balances unchanged; only `identity_a` credit balance decreased by `transfer_fee`. @@ -929,49 +947,334 @@ existing balances) are achievable in P0/P1. - **Estimated complexity**: S - **Rationale**: Zero-amount transfers may be valid no-ops or invalid per contract. Either contract needs an asserted test. -#### TK-002 — Token claim (perpetual / pre-programmed distribution) +#### TK-001c — Token transfer across re-issued identity (signer rotation) +- **Status**: STUB — `tests/e2e/cases/tk_001c_token_transfer_after_reissue.rs` (Wave 2-α; `#[ignore]`-tagged. Body panics-with-todo on the key-rotation step until ID-004 signer-cache injection helper lands — Wave 4 will surface this at runtime). +- **Priority**: P2 +- **Wallet feature exercised**: `wallet/identity/network/tokens/transfer.rs:21` after the sender's signing key has been rotated (add new key, disable old key, transfer with new key). +- **DET parallel**: none direct. +- **Preconditions**: TK-003 helper + ID-004 helpers; identity with a minted token balance from an in-test mint. +- **Scenario**: + 1. Setup token + identity with mint balance. + 2. Add a fresh AUTHENTICATION key via `update_identity` (ID-004 path), disable the old one. + 3. Transfer tokens using the **new** key as the signer. +- **Assertions**: + - Transfer succeeds with the new key. + - Transfer with the disabled key would fail with a typed "key not found / disabled" error (sub-case). +- **Negative variants**: covered above. +- **Harness extensions required**: depends on Wave A + ID-004 chain; TK-003 helper. +- **Estimated complexity**: M +- **Rationale**: Token operations don't hard-code a signing key — they accept a `signing_key: &IdentityPublicKey` parameter and rely on the identity's current key set. Pinning that "the wallet picks the right active key after rotation" prevents a quiet "still uses the old key" regression. + +#### TK-002 — Token claim (live perpetual distribution — long-runtime, nightly only) - **Priority**: P2 -- **Status**: STUB — placeholder for follow-up PR (Wave A + Wave D). +- **Status**: STUB — `tests/e2e/cases/tk_002_token_claim_perpetual.rs` (Wave 2-α; `#[ignore]`-tagged, nightly only). Body panics-with-todo on the perpetual-distribution helper override in `framework/tokens.rs` until that knob lands — Wave 4 will surface this at runtime. Demoted to nightly because perpetual intervals run on testnet block time (~3 s) and a meaningful claim window is 30–60 s of wall clock; the synchronous CI tier covers the same surface via TK-013's pre-programmed-distribution variant. - **Wallet feature exercised**: `wallet/identity/network/tokens/claim.rs:18` (`token_claim_with_signer`). -- **DET parallel**: `dash-evo-tool/tests/backend-e2e/token_tasks.rs:702` (`tc_064_estimate_perpetual_rewards`) and `step_*` token lifecycle. -- **Preconditions**: TK-001 setup + a token contract that grants the registered identity claim rights. +- **DET parallel**: `dash-evo-tool/tests/backend-e2e/token_tasks.rs:702` (`tc_064_estimate_perpetual_rewards`) and `step_*` token lifecycle (DET tests only the *estimate* path). +- **Preconditions**: TK-003 helper extended to deploy a token with live perpetual distribution; identity holding claim rights. - **Scenario**: - 1. Register identity per ID-001. - 2. Wait for the perpetual-distribution interval to advance. + 1. Deploy the token with perpetual distribution rules (interval = block-based, minimum testnet interval). + 2. Wait for the perpetual-distribution interval to advance (~30–60 s wall clock). 3. Call `token_claim_with_signer`. - **Assertions**: - - Token balance increases by the documented per-interval claim amount (operator-supplied env `PLATFORM_WALLET_E2E_TOKEN_CLAIM_AMOUNT`). - - Second claim within the same interval returns a typed "already claimed" error. + - Token balance increases by the per-interval claim amount documented in the contract. + - Second claim within the same interval returns a typed "already claimed" / "no claimable amount" error. - **Negative variants**: claim with no rights → typed error. -- **Harness extensions required**: TK-001 extensions + interval-aware sleep helper (10–60 s). +- **Harness extensions required**: TK-003 extensions + interval-aware sleep helper (30–60 s). - **Estimated complexity**: L -- **Rationale**: Perpetual-distribution bugs are silent — balance just doesn't increase. Adding claim coverage is the only way to surface those. - -#### TK-003 — Token mint (authorised identity) -- **Priority**: P2 (gated) -- **Status**: STUB — placeholder for follow-up PR (Wave A + Wave D; gated on a token contract whose mint authorisation can be assigned to a test identity). -- **Wallet feature exercised**: `wallet/identity/network/tokens/mint.rs:19`. -- **DET parallel**: `dash-evo-tool/tests/backend-e2e/token_tasks.rs:305` (`step_mint`). -- **Preconditions**: TK-001 setup + the registered identity is on the contract's mint allow-list. -- **Scenario**: mint `100` of token to self; sync. -- **Assertions**: identity token balance increased by `100`; total supply increased. -- **Negative variants**: mint without authority (TK-001's `identity_b`) → unauthorised error (DET parallel: `tc_065_mint_unauthorized` at `token_tasks.rs:756`). -- **Harness extensions required**: TK-001 extensions. +- **Rationale**: Perpetual-distribution bugs are silent — balance just doesn't increase. TK-013 covers the synchronous path; TK-002 keeps the live-time variant in scope behind a `slow-tests` cargo feature (cf. §6 Q3). Without it, a regression that breaks perpetual-distribution event scheduling never surfaces. + +#### TK-003 — Register token contract (deploy via `create_data_contract_with_signer`) +- **Status**: STUB — `tests/e2e/cases/tk_003_register_token_contract.rs` (Wave 2-β; `#[ignore]`-tagged). Body panics-with-todo on the MASTER signing path; a CRITICAL signing-key-class upgrade for `DataContractCreate` may be required — Wave 4 will surface the exact `InvalidSignatureError` rollup at runtime. +- **Priority**: P0 (gateway for every other TK-NNN entry) +- **Wallet feature exercised**: `wallet/identity/network/contract.rs:124` (`create_data_contract_with_signer`) with non-empty `tokens_schema_json`. +- **DET parallel**: `dash-evo-tool/tests/backend-e2e/token_tasks.rs:78` (`tc_045_register_token_contract`); fixture at `tests/backend-e2e/framework/fixtures.rs:111`; helper at `tests/backend-e2e/framework/token_helpers.rs:33`. +- **Preconditions**: ID-001 helper; identity has ≥ `1_000_000_000` credits (contract-create fee + headroom). +- **Scenario**: + 1. Register identity via ID-001. + 2. Build a permissive owner-only token-config JSON (mirror DET's `build_register_token_task`: 8 decimals, max supply 1e15, no perpetual distribution, owner-only ChangeControlRules across mint/burn/freeze/unfreeze/destroy/emergency/max-supply/conventions/marketplace, `start_paused = false`, `allow_transfers_to_frozen_identities = false`, `marketplace_trade_mode = 1`). + 3. Call `create_data_contract_with_signer(owner, documents="{}", tokens=Some(config), …)`. + 4. `sdk.fetch::(returned.id())`. +- **Assertions**: + - Returned contract id matches the on-chain fetch. + - `contract.tokens()` is non-empty; token at position 0 has the configured name / decimals / max supply. + - Identity credit balance decreased by `> 0` (contract-create fee). +- **Negative variants**: + - Re-deploy with same id (contrived — id is owner+nonce-derived) → `AlreadyExists` SDK error class. + - Token config with `max_supply < base_supply` → typed validation error. +- **Harness extensions required**: `setup_with_token_contract(...)` helper (§4 Wave G); contract fixture JSON template at `tests/fixtures/contracts/permissive_token.json`. The TK-003 happy path runs against the shared OnceCell-cached contract; the negative variants opt into a fresh deploy. +- **Estimated complexity**: L (the JSON template assembly is the long pole; per-test harness orchestration is M) +- **Rationale**: Without an asserted register-side case, every other TK-NNN entry rests on an unasserted assumption. This case exercises the `register_token_contract_via_sdk` helper from Wave G (previously tracked as Gap-T1). + +#### TK-004 — Token transfer fee accounting & balance round-trip +- **Status**: STUB — `tests/e2e/cases/tk_004_token_transfer_round_trip.rs` (Wave 2-β; `#[ignore]`-tagged, runs on demand against testnet). +- **Priority**: P0 +- **Wallet feature exercised**: `wallet/identity/network/tokens/transfer.rs:21` (`token_transfer_with_signer`). +- **DET parallel**: `token_tasks.rs:359` (`step_transfer`). +- **Preconditions**: TK-003 + a minted balance on `identity_a` (mint via `token_mint_with_signer` — itself covered in TK-005). Two identities (`identity_a`, `identity_b`). +- **Scenario**: + 1. `setup_with_token_and_two_identities()` returns `(token, owner=A, peer=B)` (shared OnceCell-cached contract). + 2. Owner mints `100_000` to self. + 3. Owner transfers `40_000` to B with `public_note = Some("e2e-tk006")`. + 4. Wait for sync; read both balances; read owner's credit balance. +- **Assertions**: + - `token_balance(A, contract, 0) == 60_000` exactly (mint − transfer). + - `token_balance(B, contract, 0) == 40_000` exactly. + - `A.credit_balance` decreased by `transfer_fee > 0` only (token transfer pays fees in credits, not in tokens). + - Returned `TransferResult` carries `actual_fee > 0`. +- **Negative variants**: + - Transfer amount exceeds balance → typed insufficient-tokens error. + - Transfer to self (A → A) → pin contract: either accepted as a no-op (still pays fee) or rejected as "self-transfer disallowed". + - Wrong `token_position` (e.g. position 7 on a single-token contract) → typed contract-validation error. +- **Harness extensions required**: `setup_with_token_and_two_identities`, `token_balance_of` helper (Wave G SDK-wrapper). - **Estimated complexity**: M -- **Rationale**: Mint-without-authority is the canonical token authz failure mode. +- **Rationale**: Most-used token op. Pins the credit-fee vs. token-amount accounting separation that any refactor of the fee model would silently break. -#### TK-004 — Token burn +#### TK-005 — Token mint + total-supply assertion +- **Status**: STUB — `tests/e2e/cases/tk_005_token_mint.rs` (Wave 2-γ; `#[ignore]`-tagged, runs on demand). +- **Priority**: P1 +- **Wallet feature exercised**: `wallet/identity/network/tokens/mint.rs:19` (`token_mint_with_signer`). +- **DET parallel**: `token_tasks.rs:305` (`step_mint`). +- **Preconditions**: TK-003; owner identity with ≥ `100_000_000` credits. +- **Scenario**: + 1. `setup_with_token()` returns `(token, owner)` (shared OnceCell-cached contract). + 2. Read pre-mint `token_supply(contract, 0)` (== 0 for a base-supply-zero token). + 3. Owner mints `500_000` to self with `recipient_id: None`. + 4. Owner mints `50_000` to self with `recipient_id: Some(owner_id)` (explicit-recipient sub-case). + 5. Read post-mint supply and owner balance. +- **Assertions**: + - `token_supply(contract, 0) == 550_000` after both mints. + - `token_balance(owner, contract, 0) == 550_000`. + - Both `MintResult.actual_fee > 0`. +- **Negative variants**: + - Unauthorised mint (non-owner identity attempts) → typed authorisation error. **DET parallel: `token_tasks.rs:756` (`tc_065_mint_unauthorized`).** + - Mint with `amount = 0` → pin contract (reject with "amount must be positive" vs. accept as fee-only no-op). + - Mint that would exceed `max_supply` → typed error. + - Mint to a non-existent identity (`recipient_id: Some(garbage)`) → typed error. +- **Harness extensions required**: TK-003 helpers; `register_extra_identity` for the unauthorised sub-case; supply accessor. +- **Estimated complexity**: M +- **Rationale**: Pins both the supply bookkeeping and the authorisation gate (TC-065 in DET is one of the few negative tests that already exists; we mirror it). + +#### TK-005b — Mint with `recipient_id != self` +- **Status**: STUB — `tests/e2e/cases/tk_005b_token_mint_to_other.rs` (Wave 2-γ; `#[ignore]`-tagged, runs on demand). - **Priority**: P2 -- **Status**: STUB — placeholder for follow-up PR (Wave A + Wave D). -- **Wallet feature exercised**: `wallet/identity/network/tokens/burn.rs` (mod-level fn at `tokens/mod.rs`). +- **Wallet feature exercised**: `wallet/identity/network/tokens/mint.rs:19` `recipient_id: Some(other)` branch. +- **DET parallel**: tested implicitly in DET via `mint_to: Some(identity.id)`; the cross-identity case isn't exercised explicitly. +- **Preconditions**: TK-003 helper with `minting_allow_choosing_destination = true`; owner + second identity. +- **Scenario**: + 1. Setup token (`allow_choose_destination = true`); register second identity. + 2. Owner mints `100` with `recipient_id: Some(second.id)`. +- **Assertions**: + - `token_balance(second, contract, 0) == 100`. + - `token_balance(owner, contract, 0) == 0` (mint went to the recipient, not owner). + - Total supply == `100`. +- **Negative variants**: + - Mint with `recipient_id` on a contract that has `allow_choose_destination = false` → typed validation error (build a separate token contract with this rule for the negative — fresh contract, opt out of the shared OnceCell). +- **Harness extensions required**: TK-003 helpers; `register_extra_identity`; supply accessor. +- **Estimated complexity**: S +- **Rationale**: Pins the cross-identity destination contract (an Option-branch the DET tests don't split). + +#### TK-006 — Token burn + total-supply decrement +- **Status**: STUB — `tests/e2e/cases/tk_006_token_burn.rs` (Wave 2-γ; `#[ignore]`-tagged, runs on demand). +- **Priority**: P1 +- **Wallet feature exercised**: `wallet/identity/network/tokens/burn.rs:19` (`token_burn_with_signer`). - **DET parallel**: `token_tasks.rs:330` (`step_burn`). -- **Preconditions**: TK-001 setup with non-zero balance. -- **Scenario**: burn `25` tokens; sync. -- **Assertions**: identity token balance decreased by `25`; total supply decreased. -- **Negative variants**: burn more than balance → typed error. -- **Harness extensions required**: TK-001 extensions. +- **Preconditions**: TK-003; owner with `≥ 1_000` token balance (mint inside the test). +- **Scenario**: + 1. `setup_with_token()`; owner mints `1_000`. + 2. Read pre-burn supply. + 3. Owner burns `100`. + 4. Read post-burn supply and balance. +- **Assertions**: + - Owner balance: `1_000 → 900`. + - Total supply: `1_000 → 900`. + - `BurnResult.actual_fee > 0`. +- **Negative variants**: + - Burn more than balance → typed insufficient-tokens error. + - Burn `amount = 0` → pin contract. + - Burn without authority (when ChangeControlRules disallow caller) → typed error. (Note: DET's permissive contract has `manual_burning_rules: ContractOwner` — non-owner burn fails. This sub-case uses the second identity.) +- **Harness extensions required**: TK-003 helpers. +- **Estimated complexity**: M +- **Rationale**: Symmetric partner of TK-005. Together they validate supply conservation across mint+burn pairs. + +#### TK-007 — Freeze identity for token (admin action) +- **Status**: STUB — `tests/e2e/cases/tk_007_token_freeze.rs` (Wave 2-δ; `#[ignore]`-tagged, runs on demand). +- **Priority**: P1 +- **Wallet feature exercised**: `wallet/identity/network/tokens/freeze.rs:18` (`token_freeze_with_signer`). +- **DET parallel**: `token_tasks.rs:389` (`step_freeze`). +- **Preconditions**: TK-003 with two identities (owner = admin, target = peer); peer has a non-zero token balance (transfer some over before freeze). +- **Scenario**: + 1. Setup token + two identities; mint to owner; owner transfers `200` to peer. + 2. Owner calls `token_freeze_with_signer(contract, 0, owner_id, peer_id, …)`. + 3. Wait for sync. + 4. Peer attempts `token_transfer_with_signer(contract, 0, peer, owner, 50, …)`. +- **Assertions**: + - Step 4 fails with a typed "frozen balance / cannot transfer" error class. + - Peer's token balance unchanged after the failed transfer. + - `token_frozen_balance_of(peer, fixture) == Some(200)` (via Wave G helper). + - `FreezeResult.actual_fee > 0`. +- **Negative variants**: + - Non-admin attempts to freeze → typed authorisation error. + - Freeze an already-frozen identity → pin contract (idempotent vs. typed "already frozen" error). +- **Harness extensions required**: TK-003 helpers; `register_extra_identity`. - **Estimated complexity**: M -- **Rationale**: Symmetric partner of TK-003; together they validate supply bookkeeping. +- **Rationale**: Freeze is the canonical regulatory primitive. Without explicit coverage, a regression that turns freeze into a no-op would only surface as "users complain transfers work after we froze them". + +#### TK-008 — Unfreeze identity for token +- **Status**: STUB — `tests/e2e/cases/tk_008_token_unfreeze.rs` (Wave 2-δ; `#[ignore]`-tagged, composes with TK-007). +- **Priority**: P1 +- **Wallet feature exercised**: `wallet/identity/network/tokens/unfreeze.rs:18` (`token_unfreeze_with_signer`). +- **DET parallel**: `token_tasks.rs:419` (`step_unfreeze`). +- **Preconditions**: TK-007 setup, post-freeze state. +- **Scenario**: + 1. Re-use TK-007's frozen state. + 2. Owner calls `token_unfreeze_with_signer(contract, 0, owner_id, peer_id, …)`. + 3. Peer retries the transfer that was rejected in TK-007. +- **Assertions**: + - Step 3 succeeds; peer balance decremented; owner balance incremented. + - `UnfreezeResult.actual_fee > 0`. + - `token_frozen_balance_of(peer, fixture)` is `None` or `0` (via Wave G helper). +- **Negative variants**: + - Unfreeze an identity that was never frozen → pin contract (idempotent vs. typed error). + - Non-admin unfreeze → typed auth error. +- **Harness extensions required**: same as TK-007. +- **Estimated complexity**: S (composes with TK-007) +- **Rationale**: Round-trip pin: freeze + unfreeze must restore exactly the pre-freeze state. + +#### TK-009 — Destroy frozen funds +- **Status**: STUB — `tests/e2e/cases/tk_009_token_destroy_frozen.rs` (Wave 2-δ; `#[ignore]`-tagged, composes with TK-007). +- **Priority**: P1 +- **Wallet feature exercised**: `wallet/identity/network/tokens/destroy_frozen_funds.rs:20` (`token_destroy_frozen_funds_with_signer`). +- **DET parallel**: `token_tasks.rs:452` (`step_destroy_frozen`). +- **Preconditions**: TK-007 frozen state; total supply recorded. +- **Scenario**: + 1. Compose with TK-007: peer has frozen balance `200`. + 2. Owner calls `token_destroy_frozen_funds_with_signer(contract, 0, owner_id, peer_id, …)` — note no `amount` parameter; the call destroys the full frozen balance. + 3. Read post-destroy supply, peer balance, and frozen balance. +- **Assertions**: + - Peer balance == `0`. + - Total supply decreased by exactly `200`. + - `DestroyFrozenFundsResult.actual_fee > 0`. + - Subsequent unfreeze would have nothing to unfreeze (`token_frozen_balance_of` returns `None`). +- **Negative variants**: + - Destroy on a not-frozen identity → typed error. + - Non-admin destroy → typed auth error. +- **Harness extensions required**: TK-003 + TK-007 chain. +- **Estimated complexity**: M +- **Rationale**: Destroy-frozen-funds is the irreversible "burn the rule-breaker's bag" action — the negative-supply consequence must be pinned. + +#### TK-010 — Pause and resume token (emergency action) +- **Status**: STUB — `tests/e2e/cases/tk_010_token_pause_resume.rs` (Wave 2-ε; `#[ignore]`-tagged, runs on demand). Uses the shared OnceCell-cached contract; the `start_paused = true` variant (TK-paused-on-create) remains deferred. +- **Priority**: P1 +- **Wallet feature exercised**: `wallet/identity/network/tokens/pause.rs:19`, `wallet/identity/network/tokens/resume.rs:18`. +- **DET parallel**: `token_tasks.rs:501` (`step_pause`), `token_tasks.rs:529` (`step_resume`). +- **Preconditions**: TK-003 with two identities; both have a non-zero token balance. +- **Scenario**: + 1. Setup token + two identities; mint to owner; transfer some to peer. + 2. Owner calls `token_pause_with_signer(contract, 0, owner_id, …)`. + 3. Owner attempts `token_transfer_with_signer(...)` — should be rejected. + 4. Owner calls `token_resume_with_signer(contract, 0, owner_id, …)`. + 5. Owner retries the transfer. +- **Assertions**: + - Step 3 fails with typed "token paused" error class. + - Step 5 succeeds. + - Both `EmergencyActionResult.actual_fee > 0`. + - `token_is_paused_of(fixture) == true` after pause, `false` after resume (via Wave G helper). +- **Negative variants**: + - Pause an already-paused token → pin contract (idempotent vs. typed error). + - Non-admin pause → typed auth error. +- **Harness extensions required**: TK-003 helpers; second identity. +- **Estimated complexity**: M +- **Rationale**: Pause is the kill switch. Pinning both directions (pause-blocks, resume-restores) catches the "resume forgot to clear the flag" regression class. + +#### TK-011 — Set price + direct purchase round-trip +- **Status**: STUB — `tests/e2e/cases/tk_011_token_price_purchase.rs` (Wave 2-ε; `#[ignore]`-tagged, runs on demand). +- **Priority**: P1 +- **Wallet feature exercised**: `wallet/identity/network/tokens/set_price.rs:26` (`token_set_price_with_signer`); `wallet/identity/network/tokens/purchase.rs:25` (`token_purchase_with_signer`). +- **DET parallel**: `token_tasks.rs:557` (`step_set_price`); `token_tasks.rs:588` (`step_purchase`). +- **Preconditions**: TK-003; owner with mintable supply; buyer identity (= second identity) with `≥ 50_000_000` credits. +- **Scenario**: + 1. Setup token; owner mints `1_000` to self. + 2. Owner sets pricing schedule to `Some(SinglePrice(1_000))` (1 000 credits per token). + 3. Buyer calls `token_purchase_with_signer(contract, 0, buyer_id, amount=10, total_agreed_price=10_000, …)`. + 4. Read post-purchase balances on owner and buyer. +- **Assertions**: + - Buyer's token balance: `0 → 10`. + - Owner's token balance: `1_000 → 990` (purchase reduces seller stock). + - Buyer's credit balance decreased by `10_000 + purchase_fee`. + - Owner's credit balance increased by `10_000` (purchase price arrives as credits, minus protocol fees per the pricing-schedule spec). + - `SetPriceResult.actual_fee > 0`; `DirectPurchaseResult.actual_fee > 0`. +- **Negative variants**: + - Buyer submits `total_agreed_price` lower than chain pricing → typed price-mismatch / over-budget error (this is the on-chain race-protection contract). + - Purchase before any price is set → typed "no pricing schedule" error. + - Set price to `None` (clear schedule) then buyer attempts purchase → typed "no pricing schedule" error. +- **Harness extensions required**: TK-003 helpers; second identity with credits. +- **Estimated complexity**: L (two related transitions, two-side balance bookkeeping, on-chain price race assertion). +- **Rationale**: Direct purchase is the only money-flow primitive on the wallet that crosses two identities AND moves both credits and tokens in one transition. Pricing-race protection (`total_agreed_price` mismatch) is the headline correctness property. + +#### TK-012 — Update token config (single ChangeItem mutation) +- **Status**: STUB — `tests/e2e/cases/tk_012_token_update_config.rs` (Wave 2-ε; `#[ignore]`-tagged, runs on demand). Single-ChangeItem mutation against a fresh deploy to keep the shared OnceCell fixture immutable. +- **Priority**: P2 +- **Wallet feature exercised**: `wallet/identity/network/tokens/update_config.rs:20` (`token_update_config_with_signer`). +- **DET parallel**: `token_tasks.rs:617` (`step_update_config`). +- **Preconditions**: TK-003; owner identity. Note the shared OnceCell contract caches `max_supply` for cross-test reads — this case uses a fresh deploy to avoid mutating the shared fixture under other tests. +- **Scenario**: + 1. Setup token (fresh deploy) with `max_supply = Some(1_000_000_000_000_000)`. + 2. Owner calls `token_update_config_with_signer(contract, 0, owner, ChangeItem::MaxSupply(Some(2_000_000_000_000_000)), …)`. + 3. Re-fetch the contract; read the token's `max_supply`. +- **Assertions**: + - Returned contract reflects the new `max_supply`. + - Contract version (or token-config version, whichever DPP increments) advanced. + - `ConfigUpdateResult.actual_fee > 0`. +- **Negative variants**: + - Update with `MaxSupply(Some(< current_supply))` → typed error. + - Update with a `ChangeItem` variant disallowed by ChangeControlRules → typed auth error. + - Non-admin update → typed auth error. +- **Harness extensions required**: TK-003 helpers (fresh-deploy variant); helper to re-fetch the contract bytes after the change. +- **Estimated complexity**: M +- **Rationale**: `TokenConfigurationChangeItem` is open-ended (DPP grows it over time). One pinned variant (`MaxSupply`) catches schema-drift across DPP bumps; specific high-risk variants get their own follow-up cases. + +#### TK-013 — Token claim from pre-programmed distribution +- **Status**: STUB — `tests/e2e/cases/tk_013_token_claim_pre_programmed.rs` (Wave 2-ζ; `#[ignore]`-tagged, runs on demand). Uses a fresh deploy with `distribution_rules` override (not the shared OnceCell), since the distribution config is per-test. +- **Priority**: P2 +- **Wallet feature exercised**: `wallet/identity/network/tokens/claim.rs:18` (`token_claim_with_signer`). +- **DET parallel**: `token_tasks.rs:702` (`tc_064_estimate_perpetual_rewards`) — DET only tests the *estimate* path because their `shared_token` has no perpetual; the actual claim flow is uncovered in DET. We propose to cover it. +- **Preconditions**: a token deployed with pre-programmed distribution: epoch 0 at a past timestamp granting `100` tokens to the configured beneficiary identity (= owner). +- **Scenario**: + 1. `setup_with_token_and_pre_programmed_distribution()` returns `(token, owner)` with a distribution event already eligible. + 2. Owner calls `token_claim_with_signer(contract, 0, owner_id, distribution_type=PreProgrammed, …)`. + 3. Read post-claim balance. +- **Assertions**: + - Owner balance increased by exactly the documented per-epoch payout (`100`). + - `ClaimResult.actual_fee > 0`. + - Second claim within the same epoch returns a typed "already claimed" / "no claimable amount" error. +- **Negative variants**: + - Identity with no distribution rights claims → typed error. + - Claim on a contract with no distribution configured → typed error. +- **Harness extensions required**: TK-003 helpers extended with a `with_pre_programmed_distribution(epoch_zero_at, payout)` variant; `token_balance_of` helper (Wave G SDK-wrapper). +- **Estimated complexity**: L (the contract config is the non-trivial part — pre-programmed distribution JSON shape). +- **Rationale**: Claim is silent on failure — the balance just doesn't move. Pre-programmed-distribution variant dodges the live-time perpetual-distribution wait, putting the test inside CI runtime budget. The live-perpetual sibling (TK-002) stays out of the synchronous tier. + +#### TK-014 — Group-action gateway: queue a mint, list pending, co-sign +- **Status**: STUB — `tests/e2e/cases/tk_014_token_group_action.rs` (Wave 2-ζ; `#[ignore]`-tagged, runs on demand). Uses a fresh deploy with `main_control_group` and `groups` populated; spins three identities (proposer + two co-signers) and asserts the proposer's mint is non-final, that pending lists it, and that the co-sign produces the synchronous group MintResult. +- **Priority**: P2 +- **Wallet feature exercised**: `wallet/identity/network/tokens/mint.rs:19` (`token_mint_with_signer`) with `group_info: Some(...)`; read-side `wallet/tokens/group_queries.rs::pending_group_actions_external` and `group_action_signers_external`. +- **DET parallel**: none direct in `tests/backend-e2e/token_tasks.rs` (DET's contract uses `groups: BTreeMap::new()`); coverage exists in DET production code. +- **Preconditions**: token contract with `mint_rules` requiring group action and `groups` populated with a group containing three identities. +- **Scenario**: + 1. Identity A proposes a mint via `token_mint_with_signer(..., group_info: Some(NewGroupAction(...)))`. + 2. Read `pending_group_actions_external(...)` — assert one entry, status `Open`, params == proposed mint. + 3. Identity B co-signs by re-issuing `token_mint_with_signer(..., group_info: Some(ExistingGroupAction(action_id)))`. + 4. Read `pending_group_actions_external(...)` — status now `Closed`/`Approved`; mint applied; supply increased. +- **Assertions**: + - After step 1: pending list contains the proposal; recipient balance unchanged. + - After step 3: pending list shows action closed; recipient balance increased by minted amount; total supply increased. + - `MintResult.actual_fee > 0` on both proposer and co-signer. +- **Negative variants**: + - Co-sign by a non-member → typed auth error. + - Co-sign with a parameter mismatch (different amount) → typed mismatch error. +- **Harness extensions required**: TK-003 with group config; `setup_three_identities` helper; group-discovery accessor wiring. +- **Estimated complexity**: L +- **Rationale**: Group-gated actions are an entire class of bug surface (sign-thresholds, parameter binding). One pinned end-to-end case unlocks the rest as cheap variants in a follow-up. ### Core / SPV (CR) @@ -1763,7 +2066,7 @@ order. Each wave unlocks the cases listed. - Add `derive_identity_key(seed_bytes, network, identity_index, key_index, purpose, security_level) -> IdentityPublicKey` test helper. - Add `TestWallet::register_identity_from_addresses(funding: Credits) -> Identity` helper that builds the placeholder, calls `register_from_addresses`, and waits for on-chain visibility. - Add `wait_for_identity_balance(identity_id, expected, timeout)` in `framework/wait.rs`. -- **Unlocks**: ID-001, ID-001c, ID-002, ID-003, ID-004, ID-005, ID-005b, ID-006, ID-006b, DPNS-001, DPNS-001b, DPNS-001c, DPNS-002 (partial), CT-001, DP-001, DP-001b, DP-001c, DP-002, DP-003, TK-001, TK-001b, TK-002, TK-003, TK-004, CN-001. +- **Unlocks**: ID-001, ID-001c, ID-002, ID-003, ID-004, ID-005, ID-005b, ID-006, ID-006b, DPNS-001, DPNS-001b, DPNS-001c, DPNS-002 (partial), CT-001, DP-001, DP-001b, DP-001c, DP-002, DP-003, TK-001, TK-001b, TK-002, CN-001. ### Wave B — Multi-identity per setup - Extend `setup()` to accept `setup_with_n_identities(n: u32) -> SetupGuard { test_wallet, identities: Vec }`. @@ -1775,10 +2078,9 @@ order. Each wave unlocks the cases listed. - One canonical `minimal.json` (one doc type, two scalar fields). - **Unlocks**: CT-001, CT-002, CT-003. -### Wave D — Token contract operator config -- `Config::token_contract_id`, `Config::token_position`, optional `Config::token_claim_amount`. -- Operator pre-funds tokens to the bank-derived identity (one-time, README'd next to bank pre-funding). -- **Unlocks**: TK-001, TK-001b, TK-002, TK-003, TK-004. +### Wave D — Token contract operator config (SUPERSEDED by Wave G) +- Original plan: `Config::token_contract_id`, `Config::token_position`, optional `Config::token_claim_amount`; operator pre-funds tokens to a bank-derived identity (one-time, README'd next to bank pre-funding). +- Superseded: the wallet already accepts `tokens_schema_json` on `create_data_contract_with_signer` (`wallet/identity/network/contract.rs:124`), so the suite can deploy a fresh token contract per CI run instead of relying on operator pre-funding. See Wave G below. ### Wave E — SPV re-enablement (Task #15) - Uncomment SPV block in `harness.rs:200-218`; swap `TrustedHttpContextProvider` → `SpvContextProvider`. @@ -1786,6 +2088,33 @@ order. Each wave unlocks the cases listed. - Add Core-funded test wallet helper (faucet integration). - **Unlocks**: CR-001, CR-002, CR-003. +### Wave G — Token harness extensions +- Replaces Wave D. The wallet's `create_data_contract_with_signer` already accepts a `tokens_schema_json` argument; Wave G assembles the V1 token-config JSON from a structured `TokenContractOpts` struct so test bodies stay terse and the schema-drift surface lives in exactly one place. +- Default contract is OnceCell-cached and shared across most TK cases (mirrors PA's bank-shared / per-test-wallet split). Tests that need a non-default config (pre-programmed distribution, groups, paused-on-create) opt into a fresh deploy. +- All helpers live in `packages/rs-platform-wallet/tests/e2e/framework/tokens.rs` (new module). +- Harness helpers (~19 total — helpers 6–10 and 14–19 are SDK-wrapper helpers, replacing what were previously tracked as Gap-T1..Gap-T6 wallet-API gaps; the wallet's public API does not need new methods to support these tests): + 1. `setup_with_token_contract(harness, opts: TokenContractOpts) -> TokenContractFixture` — registers an identity (via Wave A) and deploys a permissive owner-only token contract; default opts mirror DET's `build_register_token_task` (8 decimals, max supply 1e15, owner-only ChangeControlRules, no perpetual, allow-choose-destination). + 2. `setup_with_token_and_two_identities(harness, opts) -> (TokenContractFixture, TestIdentity)` — composes (1) with `register_extra_identity` for the multi-identity TK cases. + 3. `setup_with_token_and_three_identities(harness, opts) -> (TokenContractFixture, [TestIdentity; 2])` — three-identity variant for TK-014 group co-sign. + 4. `setup_with_token_pre_programmed_distribution(harness, payout, epoch_zero_at) -> TokenContractFixture` — TK-013 variant injecting a past-timestamp epoch-zero distribution. + 5. `mint_to(wallet, fixture, recipient, amount) -> MintResult` — one-line mint shortcut for tests that need a balance on a given identity before the operation under test. + 6. `token_balance_of(identity, fixture) -> TokenAmount` — read-side accessor; wraps `TokenInfo::fetch_one` (or equivalent SDK query) directly. SDK call site: `packages/rs-sdk/src/platform/fetch_many.rs` token-info variant. (Previously tracked as Gap-T2.) + 7. `token_supply_of(fixture) -> TokenAmount` — total-supply accessor; queries SDK token-supply endpoint directly. (Previously tracked as Gap-T3.) + 8. `token_is_paused_of(fixture) -> bool` — paused-flag accessor; re-fetches the data contract via `DataContract::fetch` and reads the token-state field. (Previously tracked as Gap-T4.) + 9. `token_pricing_of(fixture) -> Option` — pricing accessor; re-fetches the data contract and extracts the pricing schedule. (Previously tracked as Gap-T5.) + 10. `token_frozen_balance_of(identity, fixture) -> Option` — frozen-balance accessor; queries the SDK freeze-state proof endpoint directly. (Previously tracked as Gap-T6.) + 11. `wait_for_token_balance(identity, fixture, expected, timeout) -> Result<()>` — polls `token_balance_of` until equal-or-timeout; mirrors the PA `wait_for_balance` shape. + 12. `permissive_owner_token_contract_json(owner_id, opts) -> String` — pure helper that assembles the V1 token-contract JSON from the opts struct + owner id; the single source of truth for "what shape DPP wants today" (mirrors DET's `build_register_token_task` payload at `dash-evo-tool/tests/backend-e2e/framework/token_helpers.rs:33-96`). + 13. `register_extra_identity(harness, funding) -> TestIdentity` — registers a fresh identity from a freshly funded test wallet; mirrors DET's `ensure_second_identity()` at `dash-evo-tool/tests/backend-e2e/token_tasks.rs:35`. Likely shared with ID-002 / ID-003 / DP-002. + 14. `register_token_contract_via_sdk(sdk, owner_key, opts) -> DataContractId` — constructs the V1 token-contract document from `TokenContractOpts` and broadcasts via `Sdk::put_data_contract` (or the equivalent state-transition method). SDK call site: `packages/rs-sdk/src/platform/put.rs`. This is the SDK-direct path that helper (12) + `create_data_contract_with_signer` compose; exposed as a standalone helper for tests that need raw control. (Previously tracked as Gap-T1.) + 15. `token_balance_raw(sdk, identity_id, contract_id, token_position) -> TokenAmount` — lower-level variant of helper (6) accepting raw ids rather than a fixture; useful for cross-contract assertions. + 16. `token_supply_raw(sdk, contract_id, token_position) -> TokenAmount` — lower-level variant of helper (7). + 17. `token_is_paused_raw(sdk, contract_id, token_position) -> bool` — lower-level variant of helper (8). + 18. `token_pricing_raw(sdk, contract_id, token_position) -> Option` — lower-level variant of helper (9). + 19. `token_frozen_balance_raw(sdk, identity_id, contract_id, token_position) -> Option` — lower-level variant of helper (10). +- **Note on Gap-T1..Gap-T6**: these were previously listed as wallet-API surface gaps requiring new methods on `PlatformWallet`. That framing is superseded. Helpers 6–10 and 14–19 above implement the same functionality as framework-level SDK wrappers. No wallet public API change is needed; the test framework calls the SDK directly. +- **Unlocks**: TK-001, TK-001b, TK-001c, TK-002, TK-003, TK-004, TK-005, TK-005b, TK-006, TK-007, TK-008, TK-009, TK-010, TK-011, TK-012, TK-013, TK-014. + ### Wave F — Test-only utility helpers - `TestWallet::transfer_with_inputs` (PA-002 negative variant; PA-004b exact-balance setup). - `TestWallet::transfer_capturing_st_bytes` (PA-006, PA-006b). @@ -1800,7 +2129,7 @@ order. Each wave unlocks the cases listed. - **Unlocks**: PA-002 (negative), PA-002b, PA-004 (full assertions), PA-004b, PA-004c, PA-006, PA-006b, PA-008c, PA-009, PA-010, PA-011, PA-012, PA-013, Harness-G1a, Harness-G1b, Harness-G4. - **Cost**: ~200-400 LoC across multiple commits; the test-DAPI-proxy and cancellation-hook items are non-trivial and can land late. -**Recommended build order**: Wave A first (highest leverage — unblocks 25+ cases), then Wave F's cheap helpers (estimate-fee, transfer-with-inputs, registry status, FUNDING_MUTEX hook) which unblock most P2 PA cases, then Wave C, then Wave B as ID-003/DP-002 land. Wave F's expensive items (test DAPI proxy, cancellation hook) and Waves D/E are independent and can run in parallel with the others once a champion is assigned. +**Recommended build order**: Wave A first (highest leverage — unblocks 25+ cases), then Wave F's cheap helpers (estimate-fee, transfer-with-inputs, registry status, FUNDING_MUTEX hook) which unblock most P2 PA cases, then Wave C, then Wave B as ID-003/DP-002 land. Wave G unlocks the entire TK column once Wave A is in place; the SDK-wrapper helpers in Wave G (helpers 6–10 and 14–19, previously tracked as Gap-T1..T6) land together with Wave G, not as follow-up wallet PRs. Wave F's expensive items (test DAPI proxy, cancellation hook) and Wave E are independent and can run in parallel with the others once a champion is assigned. Wave D is superseded by Wave G. ### Wallet-API gap notes (follow-up issues) @@ -1810,7 +2139,7 @@ the spec but each would simplify a test if filed as a follow-up issue: 1. **No `PlatformWallet::fee_paid` accessor** — every PA case derives the fee from `Σ funded - Σ received - Σ remaining`. A first-class `last_transfer_fee()` (or a `fee` field on `PlatformAddressChangeSet`) would let assertions read the fee directly. Currently noted as a comment in `cases/transfer.rs:142-147`. 2. **No public sync-watermark getter on `PlatformAddressWallet`** — PA-007 needs to read the provider's `last_known_recent_block` to assert monotonicity. The field is internal; exposing a `pub fn sync_watermark() -> Option` would unblock cleanly. 3. **`IdentityManager::known_identities()` shape** — needed by ID-001's "exactly one identity registered" assertion. If the manager exposes only `BTreeMap` without a length convenience, the test must pull internals; a `.len()` / `.identity_ids()` helper would be cleaner. -4. **Token-balance accessor by `(identity, contract, position)`** — `wallet/tokens/wallet.rs:248` already has `balance(...)`; confirm signature matches what TK-001 needs (`balance_for(identity_id, contract_id, position)`) and add the convenience if not. +4. **Token-balance, supply, freeze, and pricing accessors on `PlatformWallet`** — `wallet/tokens/wallet.rs:248` already has `balance(...)`; the remaining read-side accessors (supply, freeze state, pricing, paused flag) are not yet on the wallet's public API. These are now covered by the SDK-wrapper helpers in `framework/tokens.rs` (Wave G helpers 6–10 and 14–19); adding first-class wallet methods remains a desirable but non-blocking follow-up. Previously tracked as Gap-T2..Gap-T6. 5. **DPNS `register_name_with_external_signer` lacks a "wait for visibility" partner** — Wave A would benefit from a `wait_for_dpns_name_visible(name, timeout)` helper, ideally co-located with `wait_for_balance` in `framework/wait.rs`. 6. **No protocol-version accessor for `min_input_amount` / `max_outputs`** — PA-009 and PA-014 need to read these from the active `PlatformVersion`; expose a thin test-friendly getter. @@ -1823,7 +2152,7 @@ prevents future scope creep arguments. 1. **Shielded transfers** — entire `wallet/shielded/` surface. Reason: prover, viewing-key derivation, and note-selection are a parallel system; coverage belongs in a dedicated suite. Re-evaluate when shielded ships to mainnet. 2. **Credit withdrawals** (`wallet/identity/network/withdrawal.rs`, `wallet/platform_addresses/withdrawal.rs`) — withdrawal verification requires Layer-1 observation of the withdrawal tx. Blocked on Task #15 (SPV stabilisation). Defer. -3. **Token contract deployment** — no testnet contract registry; the suite assumes pre-deployed contracts via env config (Wave D). +3. **Operator-pre-funded testnet token contracts** — the original Wave D plan (env-config + operator-provided contract id) is superseded. The suite deploys a fresh token contract per CI run via Wave G; no operator-side registry is required and no testnet contract id is consumed from config. 4. **Asset-lock-funded identity registration** — the bank holds Platform credits, not Core UTXOs. The address-funded variant (ID-001) covers this need from the wallet's perspective; full asset-lock coverage stays with DET (`dash-evo-tool/tests/backend-e2e/identity_create.rs`). 5. **DAPI Core path** (`tx_is_ours`, mn-list diffs, peer behaviour) — DET territory; this suite tests the wallet against DAPI, not DAPI itself. 6. **Cross-process bank concurrency** — README §"Multi-process safety" documents the operator-side requirement; not a test concern. @@ -1838,7 +2167,7 @@ prevents future scope creep arguments. Each question's answer changes the spec; numbered for reference. -1. **Token contract registry** — do we maintain one canonical testnet token contract for TK-001..TK-004, or do we rely on operators to provide their own via env? (Answer changes Wave D scope.) +1. **Token contract registry** — superseded: Wave G deploys a fresh token contract per CI run via the wallet's `create_data_contract_with_signer` (`tokens_schema_json` argument). No operator-side registry is required. Retained here for historical context. 2. **Contested-name coverage** — should CN-001 be promoted to P1, or do we accept DET parity and leave it P2/deferred? 3. **Long-running tests** — PA-005 (16 funding round-trips, ~3 min) is borderline. Do we accept multi-minute tests in the default `cargo test --test e2e` run, or gate them behind a `slow-tests` cargo feature? 4. **Identity withdrawal coverage** — once SPV (Task #15) lands, do we want withdrawal coverage here, or is that DET's exclusive territory? diff --git a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs index 0f33d0b2d1..e13d7fa42b 100644 --- a/packages/rs-platform-wallet/tests/e2e/cases/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/cases/mod.rs @@ -2,4 +2,22 @@ //! `#[tokio_shared_rt::test(shared)]` entries that share the //! process-wide [`super::framework::E2eContext`]. +// Token tests (Wave 2 — per TEST_SPEC.md ### Tokens (TK)) +pub mod tk_001_token_transfer; +pub mod tk_001b_token_transfer_zero; +pub mod tk_001c_token_transfer_after_reissue; +pub mod tk_002_token_claim_perpetual; +pub mod tk_003_register_token_contract; +pub mod tk_004_token_transfer_round_trip; +pub mod tk_005_token_mint; +pub mod tk_005b_token_mint_to_other; +pub mod tk_006_token_burn; +pub mod tk_007_token_freeze; +pub mod tk_008_token_unfreeze; +pub mod tk_009_token_destroy_frozen; +pub mod tk_010_token_pause_resume; +pub mod tk_011_token_price_purchase; +pub mod tk_012_token_update_config; +pub mod tk_013_token_claim_pre_programmed; +pub mod tk_014_token_group_action; pub mod transfer; diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_001_token_transfer.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_001_token_transfer.rs new file mode 100644 index 0000000000..f94be5e8d6 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_001_token_transfer.rs @@ -0,0 +1,188 @@ +//! TK-001 — Token transfer between two identities (happy path). +//! +//! Spec: `tests/e2e/TEST_SPEC.md` § TK-001. Owner mints 100 tokens to +//! itself, then transfers 50 to a peer. Pins: +//! - sender token balance drops by exactly the transferred amount, +//! - peer token balance grows by exactly the transferred amount, +//! - sender's identity credit balance drops by `> 0` (token transfer +//! pays its fee in credits, not tokens). +//! +//! Gated behind `#[ignore]` so a stock workspace `cargo test` stays +//! green for contributors that lack the bank mnemonic and live testnet +//! access. Operator setup mirrors `cases/transfer.rs`. + +use std::sync::Arc; +use std::time::Duration; + +use dash_sdk::platform::Fetch; +use dpp::data_contract::DataContract; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::Identity; + +use crate::framework::harness::E2eContext; +use crate::framework::tokens::{ + mint_to, setup_with_token_and_two_identities, token_balance_of, wait_for_token_balance, + DEFAULT_TK_FUNDING, +}; + +/// Tokens minted to the sender before the transfer. Sized comfortably +/// above `TRANSFER_AMOUNT` so the post-transfer assertion can pin the +/// residual without being sensitive to mint-side rounding. +const MINT_AMOUNT: u64 = 100; + +/// Tokens moved from owner → peer. Picked to leave a non-zero residual +/// on the sender so we can pin "balance decreased by exactly N" rather +/// than "balance is now zero". +const TRANSFER_AMOUNT: u64 = 50; + +/// Per-step deadline for token-balance observations. Longer than the +/// PA-side `wait_for_balance` budget because token reads round-trip +/// the SDK + proof verifier rather than a wallet-cached map. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with cargo test -- --ignored"] +async fn tk_001_token_transfer_between_identities() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("init e2e context"); + + let two = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("setup token + 2 identities"); + let contract_id = two.setup.contract_id; + let position = two.setup.token_position; + let owner = &two.setup.owner; + let peer = &two.peer; + + // --- mint to owner so it has stock to transfer ------------------- + mint_to(ctx, contract_id, position, MINT_AMOUNT, owner, owner) + .await + .expect("mint to owner"); + wait_for_token_balance( + ctx, + owner.id, + contract_id, + position, + MINT_AMOUNT, + STEP_TIMEOUT, + ) + .await + .expect("mint never observed on owner"); + + let owner_tok_pre = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner token balance pre"); + let peer_tok_pre = token_balance_of(ctx, contract_id, position, peer.id) + .await + .expect("peer token balance pre"); + let owner_credits_pre = Identity::fetch(ctx.sdk(), owner.id) + .await + .expect("fetch owner identity pre") + .expect("owner identity must exist after registration") + .balance(); + + assert_eq!( + owner_tok_pre, MINT_AMOUNT, + "owner must hold the just-minted balance (observed={owner_tok_pre} expected={MINT_AMOUNT})" + ); + assert_eq!( + peer_tok_pre, 0, + "peer must start with zero token balance (observed={peer_tok_pre})" + ); + + // --- transfer ---------------------------------------------------- + let data_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch data contract") + .expect("contract not found on chain"); + + two.setup + .setup_guard + .base + .test_wallet + .platform_wallet() + .identity() + .token_transfer_with_signer( + Arc::new(data_contract), + position, + owner.id, + peer.id, + TRANSFER_AMOUNT, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + ) + .await + .expect("token_transfer_with_signer"); + + // Wait for the proof-verified peer balance to hit the target. + wait_for_token_balance( + ctx, + peer.id, + contract_id, + position, + TRANSFER_AMOUNT, + STEP_TIMEOUT, + ) + .await + .expect("peer balance never observed"); + + // --- post-transfer reads ---------------------------------------- + let owner_tok_post = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner token balance post"); + let peer_tok_post = token_balance_of(ctx, contract_id, position, peer.id) + .await + .expect("peer token balance post"); + let owner_credits_post = Identity::fetch(ctx.sdk(), owner.id) + .await + .expect("fetch owner identity post") + .expect("owner identity must still exist post-transfer") + .balance(); + + let credit_fee = owner_credits_pre.saturating_sub(owner_credits_post); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_001", + owner = ?owner.id, + peer = ?peer.id, + owner_tok_pre, + owner_tok_post, + peer_tok_pre, + peer_tok_post, + owner_credits_pre, + owner_credits_post, + credit_fee, + "post-transfer snapshot" + ); + + assert_eq!( + owner_tok_post, + owner_tok_pre - TRANSFER_AMOUNT, + "owner token balance must drop by exactly TRANSFER_AMOUNT (observed={owner_tok_post})", + ); + assert_eq!( + peer_tok_post, + peer_tok_pre + TRANSFER_AMOUNT, + "peer token balance must rise by exactly TRANSFER_AMOUNT (observed={peer_tok_post})", + ); + assert!( + credit_fee > 0, + "token transfer must charge a non-zero credit fee \ + (pre={owner_credits_pre} post={owner_credits_post})" + ); + assert!( + credit_fee < owner_credits_pre, + "credit fee implausibly large: {credit_fee} >= owner_credits_pre {owner_credits_pre}" + ); + + two.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_001b_token_transfer_zero.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_001b_token_transfer_zero.rs new file mode 100644 index 0000000000..473b65988d --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_001b_token_transfer_zero.rs @@ -0,0 +1,167 @@ +//! TK-001b — Token transfer with `amount = 0`. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` § TK-001b. Pins the **(a) Reject** +//! contract: validation surfaces an `InvalidTokenAmountError` (token +//! amount must be `> 0` and `≤ MAX_DISTRIBUTION_PARAM`, see +//! `rs-dpp/.../token_transfer_transition/validate_structure/v0/mod.rs`), +//! no broadcast lands, and both balances stay unchanged. +//! +//! The chain rejects zero-amount before broadcast / proof, so we +//! simply assert the API call returns an error and that the post-call +//! balances match the pre-call ones. + +use std::sync::Arc; +use std::time::Duration; + +use dash_sdk::platform::Fetch; +use dpp::data_contract::DataContract; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::Identity; + +use crate::framework::harness::E2eContext; +use crate::framework::tokens::{ + mint_to, setup_with_token_and_two_identities, token_balance_of, wait_for_token_balance, + DEFAULT_TK_FUNDING, +}; + +/// Tokens minted to the sender so the pre-condition (sender holds a +/// non-zero balance) holds. Mirrors TK-001's mint amount. +const MINT_AMOUNT: u64 = 100; + +/// Per-step deadline for token-balance observations. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with cargo test -- --ignored"] +async fn tk_001b_token_transfer_zero_rejected() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("init e2e context"); + + let two = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("setup token + 2 identities"); + let contract_id = two.setup.contract_id; + let position = two.setup.token_position; + let owner = &two.setup.owner; + let peer = &two.peer; + + // Mint to owner so it has stock — without this the transfer might + // fail on insufficient balance instead of the zero-amount guard, + // which would muddy the assertion. + mint_to(ctx, contract_id, position, MINT_AMOUNT, owner, owner) + .await + .expect("mint to owner"); + wait_for_token_balance( + ctx, + owner.id, + contract_id, + position, + MINT_AMOUNT, + STEP_TIMEOUT, + ) + .await + .expect("mint never observed on owner"); + + let owner_tok_pre = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner token balance pre"); + let peer_tok_pre = token_balance_of(ctx, contract_id, position, peer.id) + .await + .expect("peer token balance pre"); + + let data_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch data contract") + .expect("contract not found on chain"); + + let result = two + .setup + .setup_guard + .base + .test_wallet + .platform_wallet() + .identity() + .token_transfer_with_signer( + Arc::new(data_contract), + position, + owner.id, + peer.id, + 0, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + ) + .await; + + // `TransferResult` doesn't implement `Debug`, so use a manual + // match instead of `expect_err`. + let err = match result { + Ok(_) => panic!("zero-amount transfer must be rejected, but the call returned Ok"), + Err(e) => e, + }; + let err_msg = format!("{err}"); + tracing::info!( + target: "platform_wallet::e2e::cases::tk_001b", + error = %err_msg, + "zero-amount transfer rejected (as expected)" + ); + + // Pin the typed error shape: rs-dpp surfaces zero amounts as + // InvalidTokenAmount; the SDK preserves the variant in its + // stringified error so a substring match is the cheapest stable + // contract while we wait for a typed-error accessor in dash-sdk. + assert!( + err_msg.contains("InvalidTokenAmount") || err_msg.to_lowercase().contains("amount"), + "rejection must reference the invalid-amount validator \ + (observed: {err_msg})" + ); + + // Re-read balances; both must be unchanged (no broadcast, no fee). + let owner_tok_post = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner token balance post"); + let peer_tok_post = token_balance_of(ctx, contract_id, position, peer.id) + .await + .expect("peer token balance post"); + let owner_credits_post = Identity::fetch(ctx.sdk(), owner.id) + .await + .expect("fetch owner identity post") + .expect("owner identity must still exist post-rejection") + .balance(); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_001b", + owner = ?owner.id, + peer = ?peer.id, + owner_tok_pre, + owner_tok_post, + peer_tok_pre, + peer_tok_post, + owner_credits_post, + "post-rejection snapshot" + ); + + assert_eq!( + owner_tok_post, owner_tok_pre, + "rejected transfer must not alter sender token balance" + ); + assert_eq!( + peer_tok_post, peer_tok_pre, + "rejected transfer must not alter recipient token balance" + ); + assert!( + owner_credits_post > 0, + "owner identity must still hold credits after a rejected transfer \ + (observed={owner_credits_post})" + ); + + two.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_001c_token_transfer_after_reissue.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_001c_token_transfer_after_reissue.rs new file mode 100644 index 0000000000..3d86f33a43 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_001c_token_transfer_after_reissue.rs @@ -0,0 +1,102 @@ +//! TK-001c — Token transfer after sender's signing key has been rotated. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` § TK-001c. Depends on ID-004 +//! (identity-update — add + disable a key). The harness's +//! `SeedBackedIdentitySigner` only pre-derives keys for `key_index ∈ +//! 0..DEFAULT_GAP_LIMIT`; rotating in a freshly-issued key needs a +//! `derive_identity_key`-driven cache-injection helper that does not +//! exist on the Wave 1 baseline (see `TEST_SPEC.md` § ID-004 STUB). +//! +//! Wave 2-α writes the body up to the rotation step and panics there +//! with a TODO so Wave 3+ can wire in the new helper without rewriting +//! the surrounding setup. Once ID-004 lands, replace the panic with: +//! 1. `update_identity` (add new HIGH key) signed by `master_key`, +//! 2. `update_identity` (disable old HIGH key) signed by master, +//! 3. transfer signed by the **new** key, +//! 4. (sub-case) transfer signed by the disabled key → typed error. + +use std::time::Duration; + +use crate::framework::harness::E2eContext; +use crate::framework::tokens::{ + mint_to, setup_with_token_and_two_identities, token_balance_of, wait_for_token_balance, + DEFAULT_TK_FUNDING, +}; + +/// Tokens minted to the sender so it has stock for the post-rotation +/// transfer. +const MINT_AMOUNT: u64 = 100; + +/// Per-step deadline for token-balance observations. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with cargo test -- --ignored"] +async fn tk_001c_token_transfer_after_key_rotation() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("init e2e context"); + + let two = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("setup token + 2 identities"); + let contract_id = two.setup.contract_id; + let position = two.setup.token_position; + let owner = &two.setup.owner; + let _peer = &two.peer; + + // Mint stock so the post-rotation transfer has something to move. + mint_to(ctx, contract_id, position, MINT_AMOUNT, owner, owner) + .await + .expect("mint to owner"); + wait_for_token_balance( + ctx, + owner.id, + contract_id, + position, + MINT_AMOUNT, + STEP_TIMEOUT, + ) + .await + .expect("mint never observed on owner"); + + let owner_tok_pre = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner token balance pre"); + assert_eq!( + owner_tok_pre, MINT_AMOUNT, + "owner must hold the just-minted balance pre-rotation \ + (observed={owner_tok_pre} expected={MINT_AMOUNT})" + ); + + // ---- key rotation step: requires ID-004 helper ----------------- + // + // Two pieces are missing: + // - a `derive_identity_key(identity_index, key_index, purpose, + // security_level)` helper that hands back a fresh + // `IdentityPublicKey` outside the gap window; AND + // - a way to inject the matching private bytes into the test's + // `SeedBackedIdentitySigner` so subsequent transfers sign with + // the new key. + // + // Both are tracked under TEST_SPEC.md § ID-004 (STUB). Once they + // land, replace this panic with the rotate + transfer + sub-case + // sequence outlined in the module docs. + panic!( + "TK-001c: requires ID-004 key-rotation helper \ + (derive_identity_key + signer cache injection) — see TEST_SPEC.md § ID-004" + ); + + // Unreachable until ID-004 lands; left in place so the eventual + // implementor sees the assertion shape the spec asks for. + #[allow(unreachable_code)] + { + two.setup.setup_guard.teardown().await.expect("teardown"); + } +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_002_token_claim_perpetual.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_002_token_claim_perpetual.rs new file mode 100644 index 0000000000..04c5fb287c --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_002_token_claim_perpetual.rs @@ -0,0 +1,92 @@ +//! TK-002 — Token claim against a live perpetual distribution. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` § TK-002 (long-runtime, nightly only). +//! Demoted from CI tier because perpetual intervals run on testnet +//! block time (~3 s) and a meaningful claim window is 30–60 s of wall +//! clock; TK-013 covers the synchronous pre-programmed analogue. +//! +//! Editorial note (Wave 2-α): the spec entry calls for `TK-003`'s +//! helper to be **extended to take a `distribution_rules` override +//! (live perpetual)** — that extension is not on the Wave 1 baseline. +//! `setup_with_token_contract` only deploys the permissive owner-only +//! template (`perpetualDistribution: null`); the existing +//! `setup_with_token_pre_programmed_distribution` only handles the +//! pre-programmed shape. Wiring perpetual rules requires either a new +//! helper in `framework/tokens.rs` (out of scope for sub-team α — see +//! task constraints) or assembling the V0 `TokenPerpetualDistribution` +//! JSON inline, which is brittle without a tested round-trip. +//! +//! Following the panic-with-todo pattern authorised for +//! helper-blocked cases, the test sets up a baseline two-identity +//! token fixture and panics at the perpetual-rules step. Once the +//! helper lands, replace the panic with: +//! 1. deploy contract with `BlockBasedDistribution { interval: 1, +//! function: FixedAmount(N), recipient: ContractOwner }`, +//! 2. wait for `interval` blocks (~30–60 s on testnet), +//! 3. `token_claim_with_signer(..., TokenDistributionType::Perpetual, ...)`, +//! 4. assert balance grew by ≥ N, +//! 5. (sub-case) second claim within same interval → "already claimed" +//! / "no claimable amount" typed error. + +use std::time::Duration; + +use crate::framework::harness::E2eContext; +use crate::framework::tokens::{setup_with_token_and_two_identities, DEFAULT_TK_FUNDING}; + +/// Per-step deadline for token-balance observations. +#[allow(dead_code)] +const STEP_TIMEOUT: Duration = Duration::from_secs(120); + +/// Minimum claim window in wall-clock seconds for the perpetual rule +/// once the helper lands. Sized to cover several testnet blocks +/// (~3 s/block) plus headroom. +#[allow(dead_code)] +const PERPETUAL_WAIT: Duration = Duration::from_secs(45); + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with cargo test -- --ignored"] +async fn tk_002_token_claim_perpetual_distribution() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("init e2e context"); + + // Baseline two-identity fixture so the funding + signer plumbing + // is identical to TK-001 once the perpetual helper lands. The + // contract deployed here uses the permissive owner-only template + // with `perpetualDistribution: null` — i.e. NOT yet what TK-002 + // wants. The panic below blocks before any claim so the placeholder + // contract never confuses a future debugger. + let two = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("setup token + 2 identities"); + let _contract_id = two.setup.contract_id; + let _position = two.setup.token_position; + let _owner = &two.setup.owner; + + // ---- perpetual-distribution deploy step: helper missing ------- + // + // Wave 1's `framework/tokens.rs` does not expose a helper that + // overrides `distributionRules.perpetualDistribution` on the + // permissive template. Sub-team α is constrained from editing + // `tokens.rs`; the helper extension is the work item that unblocks + // this case. + panic!( + "TK-002: requires Wave G perpetual-distribution helper \ + (setup_with_token_contract extended with `distribution_rules` override) — \ + see TEST_SPEC.md § TK-002" + ); + + // Unreachable until the helper lands; left in place so the + // implementor sees the assertion shape spelled out in the module + // docs. + #[allow(unreachable_code)] + { + two.setup.setup_guard.teardown().await.expect("teardown"); + } +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_003_register_token_contract.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_003_register_token_contract.rs new file mode 100644 index 0000000000..93c9f6002c --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_003_register_token_contract.rs @@ -0,0 +1,134 @@ +//! TK-003 — Register a permissive owner-only token contract. +//! +//! P0 foundation case. Exercises Wave G's +//! [`crate::framework::tokens::register_token_contract_via_sdk`] end +//! to end and asserts that the chain-derived contract id is +//! immediately fetchable via `DataContract::fetch` after the +//! broadcast resolves. Composes with [`setup_with_token_contract`] +//! which already drives the helper internally — TK-003 just pins the +//! observable post-conditions. +//! +//! Editorial note (Wave 1 Bilby): the helper signs with +//! [`RegisteredIdentity::master_key`] (MASTER, KeyID 0) because the +//! `RegisteredIdentity` snapshot only carries MASTER + HIGH on the +//! Wave A PR (#3578). The chain-side contract-create transition +//! validates the signing key against the contract's CRITICAL +//! requirement; if testnet ever rejects MASTER with +//! `InvalidSignatureError`, that is the trigger for Wave 4 (Marvin) +//! to pick up the signing-key-class upgrade and is asserted here as +//! a hard `panic!` so it surfaces unambiguously in CI logs. +//! +//! Gated behind `#[ignore]` so a stock `cargo test -p platform-wallet` +//! stays green for contributors and CI jobs that lack a funded +//! testnet bank wallet, live DAPI access, and the operator `.env`. +//! See `cases/transfer.rs` for the operator-setup template. + +use std::time::Duration; + +use dash_sdk::platform::Fetch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::DataContract; + +use crate::framework::prelude::*; +use crate::framework::tokens::{setup_with_token_contract, DEFAULT_TK_FUNDING}; + +/// Per-step deadline for the post-broadcast contract fetch. The +/// register helper already awaits the broadcast proof, so the fetch +/// should resolve on the first attempt; we keep a small budget for +/// trusted-context-provider warmup. +const FETCH_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio_shared_rt::test(shared)] +#[ignore = "TK-003: requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_003_register_token_contract() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let setup = match setup_with_token_contract_with_master_signing_diagnostic().await { + Ok(s) => s, + Err(err) => { + // Wave 1 editorial note: the framework signs with MASTER. + // If chain-side rejection on signing-key class trips, the + // helper surfaces it as a `FrameworkError::Sdk` carrying + // `InvalidSignatureError`. Promote that to a sharp panic + // so Wave 4 (Marvin) sees the trigger in CI logs without + // any spelunking. + let msg = err.to_string(); + if msg.contains("InvalidSignatureError") || msg.contains("InvalidIdentityPublicKey") { + tracing::error!( + target: "platform_wallet::e2e::cases::tk_003", + %msg, + "TK-003: chain rejected MASTER-signed DataContractCreate" + ); + panic!( + "TK-003: signing key class needs CRITICAL upgrade — see Wave 1 \ + editorial note in tokens.rs (master_key vs critical_key on \ + RegisteredIdentity, PR #3578). underlying error: {msg}" + ); + } + panic!("TK-003 setup failed: {msg}"); + } + }; + + let ctx = setup.setup_guard.base.ctx; + let contract_id = setup.contract_id; + let owner_id = setup.owner.id; + + // Round-trip: the chain-derived id returned by the helper must + // resolve to a real contract whose ownerId matches the registering + // identity. `DataContract::fetch` returns `Option<_>`; `None` + // means the broadcast claimed success but the proof never landed. + let fetched = tokio::time::timeout(FETCH_TIMEOUT, DataContract::fetch(ctx.sdk(), contract_id)) + .await + .expect("fetch contract: timed out") + .expect("fetch contract: SDK error") + .expect("fetch contract: not found on chain after registration"); + + assert_eq!( + fetched.id(), + contract_id, + "fetched contract id must match the helper's chain-derived id" + ); + assert_eq!( + fetched.owner_id(), + owner_id, + "contract ownerId must match the registering identity" + ); + assert!( + !fetched.tokens().is_empty(), + "permissive owner-only contract must declare at least one token slot" + ); + assert!( + fetched.tokens().contains_key(&setup.token_position), + "contract must declare a token at the helper's default position {}", + setup.token_position, + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_003", + ?contract_id, + ?owner_id, + token_position = setup.token_position, + "TK-003: token contract registered and fetched successfully" + ); + + setup.setup_guard.teardown().await.expect("teardown"); +} + +/// Thin shim around [`setup_with_token_contract`] so the test body +/// can map the `FrameworkResult` into a structured panic for the +/// MASTER-vs-CRITICAL signing diagnostic above. Splitting the call +/// keeps the diagnostic prose and the happy path readable. +async fn setup_with_token_contract_with_master_signing_diagnostic( +) -> FrameworkResult { + // Late `init` so the diagnostic owns the very first SDK error + // (the helper does not retry on `InvalidSignatureError`). + let ctx = E2eContext::init().await?; + setup_with_token_contract(ctx, DEFAULT_TK_FUNDING).await +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_004_token_transfer_round_trip.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_004_token_transfer_round_trip.rs new file mode 100644 index 0000000000..51629ecbb0 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_004_token_transfer_round_trip.rs @@ -0,0 +1,327 @@ +//! TK-004 — Token transfer round-trip + fee/balance accounting. +//! +//! P0 foundation case. Validates that an A → B → A token round-trip +//! preserves owner balance modulo platform fees, that the +//! intermediate recipient's balance is observable on chain, and +//! that total supply stays untouched across pure transfers (only +//! `mint`/`burn` move the supply needle). +//! +//! Composes Wave G's [`setup_with_token_and_two_identities`] + +//! [`mint_to`] with a direct +//! [`TokenTransferTransitionBuilder`]/[`Sdk::token_transfer`] call — +//! the framework does not (yet) ship a typed `transfer_tokens` +//! helper, and inlining the SDK call here keeps the assertion +//! surface explicit (sender + recipient ids visible at the call +//! site) while we wait on Wave 2 / Wave 4 to decide whether the +//! helper is worth promoting. +//! +//! Editorial note: the owner mint and both transfers sign with +//! [`RegisteredIdentity::high_key`] (HIGH, KeyID 1), matching +//! `tokens::mint_to`. Token-action transitions take HIGH (not +//! CRITICAL); see the Wave 1 editorial note in `tokens.rs` for the +//! contract-create case where the master_key fallback applies. +//! +//! Gated behind `#[ignore]` so a stock `cargo test -p platform-wallet` +//! stays green for contributors and CI jobs that lack a funded +//! testnet bank wallet, live DAPI access, and the operator `.env`. + +use std::sync::Arc; +use std::time::Duration; + +use dash_sdk::platform::tokens::builders::transfer::TokenTransferTransitionBuilder; +use dash_sdk::platform::Fetch; +use dpp::data_contract::DataContract; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + mint_to, setup_with_token_and_two_identities, token_balance_of, token_supply_of, + wait_for_token_balance, DEFAULT_TK_FUNDING, +}; + +/// Tokens minted to the owner before the round-trip starts. Picked +/// well above `TRANSFER_AMOUNT` so post-roundtrip the owner's +/// balance is still strictly positive even if a chain-side delta +/// shifts (Wave 4 will pin exact arithmetic). +const MINT_AMOUNT: u64 = 1_000; + +/// Tokens A sends to B, then B sends back to A. Same value both +/// directions so the round-trip is symmetric and the owner-balance +/// invariant is the cleanest: pre-roundtrip == post-roundtrip +/// (token transfers do not currently charge a token-side fee — the +/// fee is paid in credits). +const TRANSFER_AMOUNT: u64 = 250; + +/// Per-step deadline for balance observations after a broadcast. +/// `mint_to` and `Sdk::token_transfer` both await proof internally, +/// so this is a safety net for the trusted-context-provider warmup +/// rather than an actual sync wait. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared)] +#[ignore = "TK-004: requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_004_token_transfer_round_trip() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("e2e context init failed"); + + // Two identities funded for one contract-create + a handful of + // token-action broadcasts each. `setup_with_token_and_two_identities` + // also handles the Wave 1 MASTER-signing surface — if the chain + // rejects, the failure rolls up here and is caller-visible in + // the test summary as a fixture build failure. + let two = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("TK-004: token + two-identities setup failed"); + + let TokenTwoIdentitiesSetup { + setup, + peer: identity_b, + } = two; + let contract_id = setup.contract_id; + let position = setup.token_position; + let identity_a = setup.owner.clone_for_token_setup_local(); + + // Snapshot the owner's pre-mint balance so the post-roundtrip + // assertion can isolate the arithmetic. `token_balance_of` returns + // 0 for an identity that has never held the token, so an explicit + // read here doubles as a sanity check that the contract is wired + // for the right pair of ids. + let a_pre_mint = token_balance_of(ctx, contract_id, position, identity_a.id) + .await + .expect("read A pre-mint balance"); + let supply_pre_mint = token_supply_of(ctx, contract_id, position) + .await + .expect("read pre-mint supply"); + + assert_eq!( + a_pre_mint, 0, + "fresh identity must hold zero tokens before the test mints" + ); + assert_eq!( + supply_pre_mint, 0, + "fresh permissive contract must declare zero supply before the test mints" + ); + + // ------ mint owner-side seed balance ----------------------------- + mint_to( + ctx, + contract_id, + position, + MINT_AMOUNT, + &identity_a, + &identity_a, + ) + .await + .expect("mint to owner failed"); + + // The mint helper proves on the way out, but the SDK's read-side + // is a fresh fetch — wait until the proof view sees the new + // balance before continuing. `wait_for_token_balance` returns + // the observed value so the next assertion uses live state, not + // the polled threshold. + let a_post_mint = wait_for_token_balance( + ctx, + identity_a.id, + contract_id, + position, + MINT_AMOUNT, + STEP_TIMEOUT, + ) + .await + .expect("A post-mint balance never observed"); + + assert_eq!( + a_post_mint, MINT_AMOUNT, + "owner mint must credit exactly MINT_AMOUNT to the owner" + ); + + let supply_post_mint = token_supply_of(ctx, contract_id, position) + .await + .expect("read post-mint supply"); + assert_eq!( + supply_post_mint, MINT_AMOUNT, + "total supply must rise by MINT_AMOUNT after the owner mint" + ); + + // ------ A -> B transfer ----------------------------------------- + transfer_token( + ctx, + contract_id, + position, + TRANSFER_AMOUNT, + &identity_a, + identity_b.id, + ) + .await + .expect("transfer A -> B failed"); + + let b_intermediate = wait_for_token_balance( + ctx, + identity_b.id, + contract_id, + position, + TRANSFER_AMOUNT, + STEP_TIMEOUT, + ) + .await + .expect("B intermediate balance never observed"); + assert_eq!( + b_intermediate, TRANSFER_AMOUNT, + "B must observe exactly TRANSFER_AMOUNT after A's send" + ); + + let a_after_send = token_balance_of(ctx, contract_id, position, identity_a.id) + .await + .expect("read A post-send balance"); + assert_eq!( + a_after_send, + MINT_AMOUNT - TRANSFER_AMOUNT, + "A must lose exactly TRANSFER_AMOUNT (transfers do not move token supply)" + ); + + let supply_mid = token_supply_of(ctx, contract_id, position) + .await + .expect("read mid-roundtrip supply"); + assert_eq!( + supply_mid, MINT_AMOUNT, + "total supply must stay flat across a pure A -> B transfer" + ); + + // ------ B -> A transfer (close the loop) ------------------------ + transfer_token( + ctx, + contract_id, + position, + TRANSFER_AMOUNT, + &identity_b, + identity_a.id, + ) + .await + .expect("transfer B -> A failed"); + + let a_post_roundtrip = wait_for_token_balance( + ctx, + identity_a.id, + contract_id, + position, + MINT_AMOUNT, + STEP_TIMEOUT, + ) + .await + .expect("A post-roundtrip balance never observed"); + + let b_post_roundtrip = token_balance_of(ctx, contract_id, position, identity_b.id) + .await + .expect("read B post-roundtrip balance"); + let supply_post_roundtrip = token_supply_of(ctx, contract_id, position) + .await + .expect("read post-roundtrip supply"); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_004", + ?contract_id, + position, + a_pre_mint, + a_post_mint, + b_intermediate, + a_after_send, + a_post_roundtrip, + b_post_roundtrip, + supply_post_mint, + supply_post_roundtrip, + "TK-004: round-trip balance / supply snapshot" + ); + + // Round-trip identity invariants. Token transfers settle in + // tokens (no token-side fee) — the credit-side fee for the + // transfer transition itself is charged against each sender's + // identity credits, not against the token balance, so on the + // token axis the round-trip is exact. + assert_eq!( + a_post_roundtrip, MINT_AMOUNT, + "A's post-roundtrip token balance must equal its post-mint balance \ + (transfers do not charge a token-side fee)" + ); + assert_eq!( + b_post_roundtrip, 0, + "B must hold zero tokens after sending the same amount back to A" + ); + assert_eq!( + supply_post_roundtrip, MINT_AMOUNT, + "total supply must equal the minted amount across the entire round-trip \ + (no mint or burn after the initial seed)" + ); + + setup.setup_guard.teardown().await.expect("teardown"); +} + +/// Local wrapper around [`Sdk::token_transfer`] / the +/// [`TokenTransferTransitionBuilder`] for the round-trip. Lives in +/// the case file (rather than `tokens.rs`) per Wave 2-β scope — +/// framework changes are off-limits here. Promote when a second +/// case needs the same shape. +async fn transfer_token( + ctx: &'static crate::framework::harness::E2eContext, + contract_id: dpp::prelude::Identifier, + position: dpp::data_contract::TokenContractPosition, + amount: u64, + sender: &crate::framework::wallet_factory::RegisteredIdentity, + recipient_id: dpp::prelude::Identifier, +) -> Result<(), String> { + let data_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .map_err(|err| format!("fetch contract {contract_id} for transfer: {err}"))? + .ok_or_else(|| format!("contract {contract_id} not found on chain"))?; + + let builder = TokenTransferTransitionBuilder::new( + Arc::new(data_contract), + position, + sender.id, + recipient_id, + amount, + ); + + ctx.sdk() + .token_transfer(builder, &sender.high_key, sender.signer.as_ref()) + .await + .map_err(|err| format!("token_transfer {} -> {}: {err}", sender.id, recipient_id))?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Local imports — kept at the bottom because they're entirely +// internal to TK-004's tooling. The harness re-exports `RegisteredIdentity` +// only via `tokens::TokenSetup`/`TokenTwoIdentitiesSetup`, so the +// case file pulls them through the explicit framework path. +// --------------------------------------------------------------------------- + +use crate::framework::tokens::TokenTwoIdentitiesSetup; + +/// Mirror of `tokens::CloneForTokenSetup::clone_for_token_setup`, +/// scoped to the case so we don't reach into framework internals. +/// Wave G's helper is `pub(super)`-ish (defined as a local trait +/// inside `tokens.rs`); we replicate the few lines here rather than +/// widen its visibility. +trait CloneForTokenSetupLocal { + fn clone_for_token_setup_local(&self) -> Self; +} + +impl CloneForTokenSetupLocal for crate::framework::wallet_factory::RegisteredIdentity { + fn clone_for_token_setup_local(&self) -> Self { + crate::framework::wallet_factory::RegisteredIdentity { + id: self.id, + master_key: self.master_key.clone(), + high_key: self.high_key.clone(), + signer: Arc::clone(&self.signer), + identity_index: self.identity_index, + funding: self.funding, + } + } +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_005_token_mint.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_005_token_mint.rs new file mode 100644 index 0000000000..648b2b166c --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_005_token_mint.rs @@ -0,0 +1,120 @@ +//! TK-005 — Token mint + total-supply assertion. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Tokens (TK) → TK-005). +//! Pinned status: BLOCKED until run on a live testnet. +//! +//! Drives `Sdk::token_mint` (via the framework `mint_to` helper) end +//! to end on a freshly-deployed permissive owner-only token contract. +//! Pins: +//! - Two consecutive mints to the owner accumulate in both the +//! per-identity balance and the contract-wide total supply. +//! - Pre-mint supply is `0` (matches `DEFAULT_BASE_SUPPLY`). +//! - Post-mint supply equals the sum of both mint amounts. + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + mint_to, setup_with_token_contract, token_balance_of, token_supply_of, DEFAULT_TK_FUNDING, +}; + +/// First mint amount — owner mints to self with implicit recipient. +const MINT_AMOUNT_A: u64 = 500_000; + +/// Second mint amount — owner mints to self with the explicit +/// `recipient_id = owner_id` branch (the `mint_to` helper always +/// passes a recipient via `issued_to_identity_id`, which is the +/// branch this case pins). +const MINT_AMOUNT_B: u64 = 50_000; + +/// Total expected supply / owner balance after both mints. +const EXPECTED_TOTAL: u64 = MINT_AMOUNT_A + MINT_AMOUNT_B; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_005_token_mint() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("e2e ctx init"); + let setup = setup_with_token_contract(ctx, DEFAULT_TK_FUNDING) + .await + .expect("setup_with_token_contract"); + + let contract_id = setup.contract_id; + let position = setup.token_position; + let owner_id = setup.owner.id; + + // Pre-mint supply is the contract's `baseSupply` — `0` for the + // permissive owner-only template (`DEFAULT_BASE_SUPPLY`). + let pre_supply = token_supply_of(ctx, contract_id, position) + .await + .expect("pre-mint supply"); + assert_eq!( + pre_supply, 0, + "pre-mint supply must equal DEFAULT_BASE_SUPPLY (0); got {pre_supply}" + ); + + let pre_balance = token_balance_of(ctx, contract_id, position, owner_id) + .await + .expect("pre-mint owner balance"); + assert_eq!( + pre_balance, 0, + "pre-mint owner balance must be 0; got {pre_balance}" + ); + + // Mint #1 — owner → owner. + mint_to( + ctx, + contract_id, + position, + MINT_AMOUNT_A, + &setup.owner, + &setup.owner, + ) + .await + .expect("first mint to owner"); + + // Mint #2 — owner → owner (explicit recipient via builder). + mint_to( + ctx, + contract_id, + position, + MINT_AMOUNT_B, + &setup.owner, + &setup.owner, + ) + .await + .expect("second mint to owner"); + + let post_supply = token_supply_of(ctx, contract_id, position) + .await + .expect("post-mint supply"); + assert_eq!( + post_supply, EXPECTED_TOTAL, + "post-mint supply must equal MINT_AMOUNT_A + MINT_AMOUNT_B ({EXPECTED_TOTAL}); got {post_supply}" + ); + + let post_balance = token_balance_of(ctx, contract_id, position, owner_id) + .await + .expect("post-mint owner balance"); + assert_eq!( + post_balance, EXPECTED_TOTAL, + "post-mint owner balance must equal mint total ({EXPECTED_TOTAL}); got {post_balance}" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_005", + %contract_id, + %owner_id, + pre_supply, + post_supply, + post_balance, + "TK-005 mint snapshot" + ); + + setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_005b_token_mint_to_other.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_005b_token_mint_to_other.rs new file mode 100644 index 0000000000..4a2bea118c --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_005b_token_mint_to_other.rs @@ -0,0 +1,95 @@ +//! TK-005b — Mint with `recipient_id != self`. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Tokens (TK) → TK-005b). +//! Pinned status: BLOCKED until run on a live testnet. +//! +//! Drives `Sdk::token_mint` with an explicit cross-identity +//! `issued_to_identity_id` recipient on a permissive contract that +//! sets `mintingAllowChoosingDestination = true`. Pins: +//! - The recipient (`peer`) gains the minted balance, not the owner. +//! - The owner's balance stays at `0` after the mint. +//! - Total supply equals the mint amount. + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + mint_to, setup_with_token_and_two_identities, token_balance_of, token_supply_of, + DEFAULT_TK_FUNDING, +}; + +/// Single cross-identity mint amount — sized small (the spec reads +/// `100`) since the assertion is on direction, not magnitude. +const MINT_AMOUNT: u64 = 100; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_005b_token_mint_to_other() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("e2e ctx init"); + let two = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("setup_with_token_and_two_identities"); + + let contract_id = two.setup.contract_id; + let position = two.setup.token_position; + let owner_id = two.setup.owner.id; + let peer_id = two.peer.id; + + // Owner mints to peer — `mint_to` calls the builder with + // `issued_to_identity_id(peer_id)` so this exercises the + // cross-identity destination branch the contract gates on + // `mintingAllowChoosingDestination = true`. + mint_to( + ctx, + contract_id, + position, + MINT_AMOUNT, + &two.peer, + &two.setup.owner, + ) + .await + .expect("mint to peer"); + + let supply = token_supply_of(ctx, contract_id, position) + .await + .expect("post-mint supply"); + assert_eq!( + supply, MINT_AMOUNT, + "supply must equal mint amount ({MINT_AMOUNT}); got {supply}" + ); + + let owner_balance = token_balance_of(ctx, contract_id, position, owner_id) + .await + .expect("owner balance"); + assert_eq!( + owner_balance, 0, + "owner balance must remain 0 — mint went to the recipient; got {owner_balance}" + ); + + let peer_balance = token_balance_of(ctx, contract_id, position, peer_id) + .await + .expect("peer balance"); + assert_eq!( + peer_balance, MINT_AMOUNT, + "peer balance must equal mint amount ({MINT_AMOUNT}); got {peer_balance}" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_005b", + %contract_id, + %owner_id, + %peer_id, + supply, + owner_balance, + peer_balance, + "TK-005b cross-identity mint snapshot" + ); + + two.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_006_token_burn.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_006_token_burn.rs new file mode 100644 index 0000000000..0cd40062fe --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_006_token_burn.rs @@ -0,0 +1,156 @@ +//! TK-006 — Token burn + total-supply decrement. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` (### Tokens (TK) → TK-006). +//! Pinned status: BLOCKED until run on a live testnet. +//! +//! Drives `Sdk::token_burn` end-to-end via the SDK +//! `TokenBurnTransitionBuilder` — Wave G's framework helper set +//! covers mint/transfer/freeze but does not yet expose a `burn_to` +//! shortcut, so this case calls the SDK directly. Pins: +//! - Owner balance decrements `mint → mint − burn`. +//! - Total supply decrements `mint → mint − burn` (mint+burn pair +//! is supply-conservative around the burned amount). +//! - `BurnResult::TokenBalance` reports the same remaining balance +//! the read-side accessor sees. + +use std::sync::Arc; + +use dash_sdk::platform::tokens::builders::burn::TokenBurnTransitionBuilder; +use dash_sdk::platform::tokens::transitions::BurnResult; +use dash_sdk::platform::Fetch; +use dpp::data_contract::DataContract; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + mint_to, setup_with_token_contract, token_balance_of, token_supply_of, DEFAULT_TK_FUNDING, +}; + +/// Pre-burn mint that seeds the owner's balance. +const MINT_AMOUNT: u64 = 1_000; + +/// Burn amount — strictly less than `MINT_AMOUNT` so the residual +/// balance is non-zero and the mint+burn supply arithmetic stays +/// positive (matches the spec: `1_000 → 900`). +const BURN_AMOUNT: u64 = 100; + +/// Expected residual after `MINT_AMOUNT − BURN_AMOUNT`. +const EXPECTED_RESIDUAL: u64 = MINT_AMOUNT - BURN_AMOUNT; + +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_006_token_burn() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("e2e ctx init"); + let setup = setup_with_token_contract(ctx, DEFAULT_TK_FUNDING) + .await + .expect("setup_with_token_contract"); + + let contract_id = setup.contract_id; + let position = setup.token_position; + let owner_id = setup.owner.id; + + // Seed the owner's balance — TK-006 explicitly chains a mint + // before the burn rather than depending on TK-005's run order. + mint_to( + ctx, + contract_id, + position, + MINT_AMOUNT, + &setup.owner, + &setup.owner, + ) + .await + .expect("seed mint"); + + let pre_burn_supply = token_supply_of(ctx, contract_id, position) + .await + .expect("pre-burn supply"); + assert_eq!( + pre_burn_supply, MINT_AMOUNT, + "pre-burn supply must equal seeded mint ({MINT_AMOUNT}); got {pre_burn_supply}" + ); + + let pre_burn_balance = token_balance_of(ctx, contract_id, position, owner_id) + .await + .expect("pre-burn owner balance"); + assert_eq!( + pre_burn_balance, MINT_AMOUNT, + "pre-burn owner balance must equal seeded mint ({MINT_AMOUNT}); got {pre_burn_balance}" + ); + + // Burn — go SDK-direct via the builder. The wallet exposes + // `token_burn_with_signer` but binding a full `IdentityWallet` + // here would force the test to also adopt the wallet-side + // broadcaster wiring. The builder path is what the wallet + // helper itself ends up calling. + let data_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch data contract") + .expect("contract must exist"); + + let builder = + TokenBurnTransitionBuilder::new(Arc::new(data_contract), position, owner_id, BURN_AMOUNT); + + let burn_result = ctx + .sdk() + .token_burn(builder, &setup.owner.high_key, setup.owner.signer.as_ref()) + .await + .expect("token_burn"); + + // Pin the proof-result variant. The permissive owner-only + // contract sets `keepsBurningHistory = true`, so the SDK + // resolves the burn proof to `HistoricalDocument`, not + // `TokenBalance`. Treat any other shape as a regression. + match burn_result { + BurnResult::HistoricalDocument(_) => {} + BurnResult::TokenBalance(_, _) => { + panic!( + "permissive contract has keepsBurningHistory=true but BurnResult came back as \ + TokenBalance — proof path expectation drifted" + ); + } + other => panic!( + "unexpected BurnResult variant for non-group burn on history-keeping contract: {}", + match other { + BurnResult::GroupActionWithDocument(_, _) => "GroupActionWithDocument", + BurnResult::GroupActionWithBalance(_, _, _) => "GroupActionWithBalance", + _ => "unreachable", + } + ), + } + + let post_burn_supply = token_supply_of(ctx, contract_id, position) + .await + .expect("post-burn supply"); + assert_eq!( + post_burn_supply, EXPECTED_RESIDUAL, + "post-burn supply must equal MINT_AMOUNT - BURN_AMOUNT ({EXPECTED_RESIDUAL}); got {post_burn_supply}" + ); + + let post_burn_balance = token_balance_of(ctx, contract_id, position, owner_id) + .await + .expect("post-burn owner balance"); + assert_eq!( + post_burn_balance, EXPECTED_RESIDUAL, + "post-burn owner balance must equal MINT_AMOUNT - BURN_AMOUNT ({EXPECTED_RESIDUAL}); got {post_burn_balance}" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_006", + %contract_id, + %owner_id, + pre_burn_supply, + post_burn_supply, + post_burn_balance, + "TK-006 burn snapshot" + ); + + setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_007_token_freeze.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_007_token_freeze.rs new file mode 100644 index 0000000000..b2a1d53a74 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_007_token_freeze.rs @@ -0,0 +1,226 @@ +//! TK-007 — Freeze identity for token (admin action). +//! +//! Spec: `tests/e2e/TEST_SPEC.md` §TK-007. Pins the contract owner's +//! `token_freeze_with_signer` admin path: after a successful freeze +//! the target identity's full token balance is unspendable, the +//! frozen-balance accessor reports the locked amount, and the freeze +//! transition itself charges identity credits. +//! +//! Gated behind `#[ignore]` per Wave 2 conventions — needs the +//! operator `tests/.env` plus live testnet access. Run with +//! `cargo test --test e2e -- --ignored --nocapture`. +//! +//! Self-contained: stands up its own two-identity token contract via +//! [`setup_with_token_and_two_identities`] rather than chaining onto +//! a sibling test's frozen state. The cross-test dependency note in +//! `TEST_SPEC.md` is editorial — TK-008 / TK-009 each redo this same +//! setup so a single failure is localised. +//! +//! `actual_fee` is not surfaced on `FreezeResult` (the SDK enum has +//! no fee field), so the "freeze charged credits" assertion is made +//! against the owner's identity balance pre vs. post the transition. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + setup_with_token_and_two_identities, token_balance_of, token_frozen_balance_of, + wait_for_token_balance, DEFAULT_TK_FUNDING, +}; + +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityBalance; +use dpp::balances::credits::TokenAmount; +use dpp::data_contract::DataContract; + +/// Per-identity bank funding for the TK-007 wallet. Headroom for the +/// contract create + mint + transfer + freeze chain. +const TK_FUNDING_PER: dpp::fee::Credits = DEFAULT_TK_FUNDING; + +/// Token amount the owner mints to itself before transferring some +/// to the peer. Sized well above `TRANSFER_TO_PEER` so the owner's +/// post-transfer balance is unambiguously non-zero. +const MINT_TO_OWNER: TokenAmount = 1_000; + +/// Token amount the owner transfers to the peer pre-freeze. +/// Matches the spec's pinned `200` so frozen-balance assertions +/// align with TK-009's destroy step. +const TRANSFER_TO_PEER: TokenAmount = 200; + +/// Per-step timeout for token-balance polls. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_007_token_freeze() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let two = setup_with_token_and_two_identities(s.ctx, TK_FUNDING_PER) + .await + .expect("two-identity token setup"); + let owner = &two.setup.owner; + let peer = &two.peer; + let contract_id = two.setup.contract_id; + let position = two.setup.token_position; + + // Mint to owner so we have a balance to fund the peer with. + crate::framework::tokens::mint_to(s.ctx, contract_id, position, MINT_TO_OWNER, owner, owner) + .await + .expect("mint to owner"); + + wait_for_token_balance( + s.ctx, + owner.id, + contract_id, + position, + MINT_TO_OWNER, + STEP_TIMEOUT, + ) + .await + .expect("owner mint not observed"); + + // Owner transfers TRANSFER_TO_PEER to peer. + let data_contract = DataContract::fetch(s.ctx.sdk(), contract_id) + .await + .expect("fetch contract") + .expect("contract present"); + let data_contract = std::sync::Arc::new(data_contract); + + s.test_wallet + .platform_wallet() + .identity() + .token_transfer_with_signer( + data_contract.clone(), + position, + owner.id, + peer.id, + TRANSFER_TO_PEER, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + ) + .await + .expect("token transfer pre-freeze"); + + wait_for_token_balance( + s.ctx, + peer.id, + contract_id, + position, + TRANSFER_TO_PEER, + STEP_TIMEOUT, + ) + .await + .expect("peer pre-freeze balance not observed"); + + // Capture owner's identity-credit balance before the freeze + // transition so we can assert the freeze charged a non-zero fee + // — `FreezeResult` itself does not expose `actual_fee`. + let owner_credits_pre = IdentityBalance::fetch(s.ctx.sdk(), owner.id) + .await + .expect("fetch owner credits pre-freeze") + .expect("owner identity present"); + + // Owner freezes peer. + s.test_wallet + .platform_wallet() + .identity() + .token_freeze_with_signer( + data_contract.clone(), + position, + owner.id, + peer.id, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + None, + ) + .await + .expect("token freeze"); + + let owner_credits_post = IdentityBalance::fetch(s.ctx.sdk(), owner.id) + .await + .expect("fetch owner credits post-freeze") + .expect("owner identity present"); + + let frozen_balance = token_frozen_balance_of(s.ctx, contract_id, position, peer.id) + .await + .expect("frozen balance fetch"); + assert_eq!( + frozen_balance, TRANSFER_TO_PEER, + "frozen balance must equal the locked amount \ + (peer was credited {TRANSFER_TO_PEER}, observed frozen={frozen_balance})" + ); + + // Peer attempts to transfer 50 back to owner — must fail with a + // typed "frozen" error class. We assert error semantics via + // string match: the SDK funnels DPP consensus errors as opaque + // strings here, and the variant + // `IdentityTokenAccountFrozenError`'s formatter contains the + // word "frozen" (see rs-dpp consensus state-error 40702). + let half_back = TRANSFER_TO_PEER / 4; + let attempt = s + .test_wallet + .platform_wallet() + .identity() + .token_transfer_with_signer( + data_contract, + position, + peer.id, + owner.id, + half_back, + &peer.high_key, + peer.signer.as_ref(), + None, + None, + ) + .await; + let err = match attempt { + Ok(_) => panic!("frozen peer transfer must fail, but it succeeded"), + Err(e) => e, + }; + let err_text = format!("{err:?}").to_lowercase(); + assert!( + err_text.contains("frozen") || err_text.contains("freeze"), + "expected 'frozen' / 'freeze' marker in error, got: {err:?}" + ); + + // Peer's token balance unchanged after the failed transfer. + let peer_balance = token_balance_of(s.ctx, contract_id, position, peer.id) + .await + .expect("peer balance fetch"); + assert_eq!( + peer_balance, TRANSFER_TO_PEER, + "frozen peer balance must be unchanged after rejected transfer \ + (expected {TRANSFER_TO_PEER}, observed {peer_balance})" + ); + + assert!( + owner_credits_post < owner_credits_pre, + "freeze must charge identity credits \ + (pre={owner_credits_pre} post={owner_credits_post})" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_007", + owner_id = ?owner.id, + peer_id = ?peer.id, + ?contract_id, + position, + peer_balance, + frozen_balance, + fee_charged = owner_credits_pre - owner_credits_post, + "TK-007 post-freeze snapshot" + ); + + two.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_008_token_unfreeze.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_008_token_unfreeze.rs new file mode 100644 index 0000000000..db8b3dd45f --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_008_token_unfreeze.rs @@ -0,0 +1,225 @@ +//! TK-008 — Unfreeze identity for token. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` §TK-008. Round-trip pin: freeze +//! followed by unfreeze must restore the pre-freeze invariant — a +//! peer that was rejected mid-freeze can transfer once the freeze is +//! released. After unfreeze, `token_frozen_balance_of` must return +//! `0` (per Wave G's editorial note that the helper returns `0` +//! once the `IdentityTokenInfo.frozen` flag is cleared). +//! +//! Self-contained: redoes TK-007's freeze setup inline rather than +//! sharing state across test functions, matching the harness's +//! "self-contained tests" convention. Gated behind `#[ignore]`. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + setup_with_token_and_two_identities, token_balance_of, token_frozen_balance_of, + wait_for_token_balance, DEFAULT_TK_FUNDING, +}; + +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityBalance; +use dpp::balances::credits::TokenAmount; +use dpp::data_contract::DataContract; + +const TK_FUNDING_PER: dpp::fee::Credits = DEFAULT_TK_FUNDING; +const MINT_TO_OWNER: TokenAmount = 1_000; +const TRANSFER_TO_PEER: TokenAmount = 200; +const PEER_RETURN: TokenAmount = 50; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_008_token_unfreeze() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let two = setup_with_token_and_two_identities(s.ctx, TK_FUNDING_PER) + .await + .expect("two-identity token setup"); + let owner = &two.setup.owner; + let peer = &two.peer; + let contract_id = two.setup.contract_id; + let position = two.setup.token_position; + + // Mint to owner. + crate::framework::tokens::mint_to(s.ctx, contract_id, position, MINT_TO_OWNER, owner, owner) + .await + .expect("mint to owner"); + wait_for_token_balance( + s.ctx, + owner.id, + contract_id, + position, + MINT_TO_OWNER, + STEP_TIMEOUT, + ) + .await + .expect("owner mint not observed"); + + let data_contract = DataContract::fetch(s.ctx.sdk(), contract_id) + .await + .expect("fetch contract") + .expect("contract present"); + let data_contract = std::sync::Arc::new(data_contract); + + // Owner -> peer pre-freeze transfer. + s.test_wallet + .platform_wallet() + .identity() + .token_transfer_with_signer( + data_contract.clone(), + position, + owner.id, + peer.id, + TRANSFER_TO_PEER, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + ) + .await + .expect("token transfer pre-freeze"); + wait_for_token_balance( + s.ctx, + peer.id, + contract_id, + position, + TRANSFER_TO_PEER, + STEP_TIMEOUT, + ) + .await + .expect("peer pre-freeze balance not observed"); + + // Freeze peer (TK-007 precondition replay). + s.test_wallet + .platform_wallet() + .identity() + .token_freeze_with_signer( + data_contract.clone(), + position, + owner.id, + peer.id, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + None, + ) + .await + .expect("token freeze"); + + // Snapshot owner credits before unfreeze so we can assert it + // charged a non-zero fee — `UnfreezeResult` carries no + // `actual_fee` field. + let owner_credits_pre = IdentityBalance::fetch(s.ctx.sdk(), owner.id) + .await + .expect("fetch owner credits pre-unfreeze") + .expect("owner identity present"); + + // Unfreeze. + s.test_wallet + .platform_wallet() + .identity() + .token_unfreeze_with_signer( + data_contract.clone(), + position, + owner.id, + peer.id, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + None, + ) + .await + .expect("token unfreeze"); + + let owner_credits_post = IdentityBalance::fetch(s.ctx.sdk(), owner.id) + .await + .expect("fetch owner credits post-unfreeze") + .expect("owner identity present"); + + // Frozen-balance helper: returns the identity's full token + // balance while frozen, `0` once the `frozen` flag is cleared. + let frozen_balance = token_frozen_balance_of(s.ctx, contract_id, position, peer.id) + .await + .expect("frozen balance fetch"); + assert_eq!( + frozen_balance, 0, + "post-unfreeze frozen-balance helper must return 0 \ + (the IdentityTokenInfo.frozen flag is cleared); observed {frozen_balance}" + ); + + // Peer retries the transfer that was blocked while frozen. + let owner_balance_pre_return = token_balance_of(s.ctx, contract_id, position, owner.id) + .await + .expect("owner balance pre-return"); + + s.test_wallet + .platform_wallet() + .identity() + .token_transfer_with_signer( + data_contract, + position, + peer.id, + owner.id, + PEER_RETURN, + &peer.high_key, + peer.signer.as_ref(), + None, + None, + ) + .await + .expect("post-unfreeze peer transfer"); + + let expected_owner_balance = owner_balance_pre_return + PEER_RETURN; + wait_for_token_balance( + s.ctx, + owner.id, + contract_id, + position, + expected_owner_balance, + STEP_TIMEOUT, + ) + .await + .expect("owner balance increment not observed"); + + let peer_balance = token_balance_of(s.ctx, contract_id, position, peer.id) + .await + .expect("peer balance fetch"); + assert_eq!( + peer_balance, + TRANSFER_TO_PEER - PEER_RETURN, + "peer balance must decrement by PEER_RETURN \ + (expected {}, observed {peer_balance})", + TRANSFER_TO_PEER - PEER_RETURN + ); + + assert!( + owner_credits_post < owner_credits_pre, + "unfreeze must charge identity credits \ + (pre={owner_credits_pre} post={owner_credits_post})" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_008", + owner_id = ?owner.id, + peer_id = ?peer.id, + ?contract_id, + position, + peer_balance, + fee_charged = owner_credits_pre - owner_credits_post, + "TK-008 post-unfreeze snapshot" + ); + + two.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_009_token_destroy_frozen.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_009_token_destroy_frozen.rs new file mode 100644 index 0000000000..9107ba6bb0 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_009_token_destroy_frozen.rs @@ -0,0 +1,207 @@ +//! TK-009 — Destroy frozen funds. +//! +//! Spec: `tests/e2e/TEST_SPEC.md` §TK-009. Pins the irreversible +//! "burn the rule-breaker's bag" admin action: after a freeze, the +//! owner can call `token_destroy_frozen_funds_with_signer` (which +//! takes no `amount` — the call always destroys the full frozen +//! balance) to drop the peer's balance to `0`. Total supply +//! decreases by the destroyed amount, and a follow-up frozen-balance +//! read returns `0` (no balance left to be frozen). +//! +//! Self-contained: stages its own freeze precondition rather than +//! chaining onto TK-007's state. Gated behind `#[ignore]`. + +use std::time::Duration; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + setup_with_token_and_two_identities, token_balance_of, token_frozen_balance_of, + token_supply_of, wait_for_token_balance, DEFAULT_TK_FUNDING, +}; + +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityBalance; +use dpp::balances::credits::TokenAmount; +use dpp::data_contract::DataContract; + +const TK_FUNDING_PER: dpp::fee::Credits = DEFAULT_TK_FUNDING; +const MINT_TO_OWNER: TokenAmount = 1_000; +const TRANSFER_TO_PEER: TokenAmount = 200; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_009_token_destroy_frozen() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let s = setup().await.expect("e2e setup failed"); + let two = setup_with_token_and_two_identities(s.ctx, TK_FUNDING_PER) + .await + .expect("two-identity token setup"); + let owner = &two.setup.owner; + let peer = &two.peer; + let contract_id = two.setup.contract_id; + let position = two.setup.token_position; + + // Mint to owner so we have a balance to fund the peer with. + crate::framework::tokens::mint_to(s.ctx, contract_id, position, MINT_TO_OWNER, owner, owner) + .await + .expect("mint to owner"); + wait_for_token_balance( + s.ctx, + owner.id, + contract_id, + position, + MINT_TO_OWNER, + STEP_TIMEOUT, + ) + .await + .expect("owner mint not observed"); + + let data_contract = DataContract::fetch(s.ctx.sdk(), contract_id) + .await + .expect("fetch contract") + .expect("contract present"); + let data_contract = std::sync::Arc::new(data_contract); + + // Owner -> peer pre-freeze transfer. + s.test_wallet + .platform_wallet() + .identity() + .token_transfer_with_signer( + data_contract.clone(), + position, + owner.id, + peer.id, + TRANSFER_TO_PEER, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + ) + .await + .expect("token transfer pre-freeze"); + wait_for_token_balance( + s.ctx, + peer.id, + contract_id, + position, + TRANSFER_TO_PEER, + STEP_TIMEOUT, + ) + .await + .expect("peer pre-freeze balance not observed"); + + // Snapshot the post-mint total supply. With no burns yet, this + // equals MINT_TO_OWNER; we capture the live value rather than + // pinning the constant so a future change to the helper's + // base-supply default doesn't drift this assertion. + let supply_pre_destroy = token_supply_of(s.ctx, contract_id, position) + .await + .expect("supply pre-destroy"); + + // Freeze peer (TK-007 precondition). + s.test_wallet + .platform_wallet() + .identity() + .token_freeze_with_signer( + data_contract.clone(), + position, + owner.id, + peer.id, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + None, + ) + .await + .expect("token freeze"); + + // Snapshot owner credits before destroy so we can assert it + // charged a non-zero fee — `DestroyFrozenFundsResult` carries no + // `actual_fee` field. + let owner_credits_pre = IdentityBalance::fetch(s.ctx.sdk(), owner.id) + .await + .expect("fetch owner credits pre-destroy") + .expect("owner identity present"); + + // Destroy frozen funds (no amount param — always full balance). + s.test_wallet + .platform_wallet() + .identity() + .token_destroy_frozen_funds_with_signer( + data_contract, + position, + owner.id, + peer.id, + &owner.high_key, + owner.signer.as_ref(), + None, + None, + None, + ) + .await + .expect("destroy frozen funds"); + + let owner_credits_post = IdentityBalance::fetch(s.ctx.sdk(), owner.id) + .await + .expect("fetch owner credits post-destroy") + .expect("owner identity present"); + + let peer_balance = token_balance_of(s.ctx, contract_id, position, peer.id) + .await + .expect("peer balance post-destroy"); + assert_eq!( + peer_balance, 0, + "peer balance must be 0 after destroy_frozen_funds; observed {peer_balance}" + ); + + let supply_post_destroy = token_supply_of(s.ctx, contract_id, position) + .await + .expect("supply post-destroy"); + assert_eq!( + supply_post_destroy, + supply_pre_destroy - TRANSFER_TO_PEER, + "total supply must decrease by exactly the destroyed amount \ + (pre={supply_pre_destroy} post={supply_post_destroy} destroyed={TRANSFER_TO_PEER})" + ); + + // Frozen-balance helper: with the peer's balance now zero, the + // helper returns 0 even though the `IdentityTokenInfo.frozen` + // flag may still be set (full balance × frozen-flag = 0). + let frozen_balance = token_frozen_balance_of(s.ctx, contract_id, position, peer.id) + .await + .expect("frozen balance fetch post-destroy"); + assert_eq!( + frozen_balance, 0, + "post-destroy frozen-balance must be 0 (nothing left to freeze); observed {frozen_balance}" + ); + + assert!( + owner_credits_post < owner_credits_pre, + "destroy_frozen_funds must charge identity credits \ + (pre={owner_credits_pre} post={owner_credits_post})" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_009", + owner_id = ?owner.id, + peer_id = ?peer.id, + ?contract_id, + position, + peer_balance, + supply_pre_destroy, + supply_post_destroy, + fee_charged = owner_credits_pre - owner_credits_post, + "TK-009 post-destroy snapshot" + ); + + two.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_010_token_pause_resume.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_010_token_pause_resume.rs new file mode 100644 index 0000000000..284e8a7c62 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_010_token_pause_resume.rs @@ -0,0 +1,175 @@ +//! TK-010 — Pause and resume token (emergency action). +//! +//! Two-identity setup (owner + peer). The owner pauses the token, +//! attempts a transfer (must be rejected with a "token is paused" +//! consensus error), then resumes and retries the transfer. +//! +//! Wave 2 stub: `#[ignore]`d so a stock `cargo test` stays green. +//! Wave 4 runs it against a live testnet. +//! +//! Spec drift note: TEST_SPEC.md asks for a positive `actual_fee` on +//! both pause and resume `EmergencyActionResult`s, but the bare SDK +//! `EmergencyActionResult` enum (rs-sdk/src/platform/tokens/ +//! transitions/emergency_action.rs) does not surface a fee field — +//! that lives in DET's task wrapper. Wave 4 will either fold a fee +//! accessor into the SDK result or read fees from credit-balance +//! deltas; until then the `actual_fee` assertion is a TODO. + +use std::sync::Arc; +use std::time::Duration; + +use dash_sdk::platform::tokens::builders::emergency_action::TokenEmergencyActionTransitionBuilder; +use dash_sdk::platform::tokens::builders::transfer::TokenTransferTransitionBuilder; +use dash_sdk::platform::Fetch; +use dpp::data_contract::DataContract; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + mint_to, setup_with_token_and_two_identities, token_balance_of, token_is_paused_of, + DEFAULT_TK_FUNDING, DEFAULT_TOKEN_POSITION, +}; + +const MINT_AMOUNT: u64 = 1_000; +const SEED_TRANSFER: u64 = 100; +const POST_RESUME_TRANSFER: u64 = 50; +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_010_token_pause_blocks_transfers_then_resume_restores() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("init e2e context"); + let s = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("token + two identities setup"); + + let owner = &s.setup.owner; + let peer = &s.peer; + let contract_id = s.setup.contract_id; + let position = s.setup.token_position; + + // Step 1: owner mints to self, then seeds peer with a small balance + // so the post-resume transfer has somewhere to land. The pause path + // is exercised by the owner -> peer transfer in step 3. + mint_to(ctx, contract_id, position, MINT_AMOUNT, owner, owner) + .await + .expect("owner mint to self"); + + // Pre-pause sanity: owner balance reflects the mint, token is not paused. + let owner_pre = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner balance pre-pause"); + assert!( + owner_pre >= MINT_AMOUNT, + "owner mint must be observable before pause (balance={owner_pre})" + ); + let paused_before = token_is_paused_of(ctx, contract_id, position) + .await + .expect("paused flag pre-pause"); + assert!(!paused_before, "token must start unpaused"); + + let data_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch data contract for pause builder") + .expect("contract on chain"); + let data_contract = Arc::new(data_contract); + + // Step 2: owner pauses. + let pause_builder = + TokenEmergencyActionTransitionBuilder::pause(data_contract.clone(), position, owner.id); + ctx.sdk() + .token_emergency_action(pause_builder, &owner.high_key, owner.signer.as_ref()) + .await + .expect("pause emergency action"); + + // Wave G's `token_is_paused_of` must flip to true. + let paused_after = token_is_paused_of(ctx, contract_id, position) + .await + .expect("paused flag post-pause"); + assert!(paused_after, "token must report paused after pause action"); + + // Step 3: owner transfer must be rejected with a "token is paused" + // typed error. We match on the consensus-error error display string; + // the upstream type is `dpp::...::TokenIsPausedError`. + let transfer_builder = TokenTransferTransitionBuilder::new( + data_contract.clone(), + position, + owner.id, + peer.id, + SEED_TRANSFER, + ); + let result = ctx + .sdk() + .token_transfer(transfer_builder, &owner.high_key, owner.signer.as_ref()) + .await; + // `TransferResult` doesn't impl `Debug`, so unpack with `match` rather than + // `expect_err`. + let err_str = match result { + Ok(_) => panic!("transfer must fail while paused"), + Err(err) => err.to_string(), + }; + assert!( + err_str.contains("paused") || err_str.contains("TokenIsPaused"), + "expected a 'token paused' typed error class, got: {err_str}" + ); + + // Step 4: owner resumes. + let resume_builder = + TokenEmergencyActionTransitionBuilder::resume(data_contract.clone(), position, owner.id); + ctx.sdk() + .token_emergency_action(resume_builder, &owner.high_key, owner.signer.as_ref()) + .await + .expect("resume emergency action"); + + let paused_resumed = token_is_paused_of(ctx, contract_id, position) + .await + .expect("paused flag post-resume"); + assert!( + !paused_resumed, + "token must report not-paused after resume action" + ); + + // Step 5: owner retries the transfer; succeeds. + let retry_builder = TokenTransferTransitionBuilder::new( + data_contract, + position, + owner.id, + peer.id, + POST_RESUME_TRANSFER, + ); + ctx.sdk() + .token_transfer(retry_builder, &owner.high_key, owner.signer.as_ref()) + .await + .expect("post-resume transfer"); + + let peer_post = token_balance_of(ctx, contract_id, position, peer.id) + .await + .expect("peer balance post-resume"); + assert!( + peer_post >= POST_RESUME_TRANSFER, + "peer must observe the post-resume transfer (balance={peer_post})" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_010", + ?contract_id, + owner_pre, + peer_post, + "TK-010 pause/resume round-trip complete" + ); + + // TODO(spec-drift): once SDK's EmergencyActionResult exposes + // actual_fee, assert pause_fee > 0 and resume_fee > 0 per + // TEST_SPEC.md TK-010. + + let _ = STEP_TIMEOUT; // currently unused — kept for future wait_for_token_balance hooks. + + s.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_011_token_price_purchase.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_011_token_price_purchase.rs new file mode 100644 index 0000000000..cdfb3aabd8 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_011_token_price_purchase.rs @@ -0,0 +1,216 @@ +//! TK-011 — Set price + direct purchase round-trip. +//! +//! Two-identity setup (owner + buyer). Owner mints, sets a single +//! price, buyer purchases — owner+buyer credit and token balances +//! pin the cross-identity money flow. +//! +//! Wave 2 stub: `#[ignore]`d. Wave 4 runs against live testnet. +//! +//! Spec drift note: TEST_SPEC.md asks for a positive `actual_fee` on +//! `SetPriceResult` and `DirectPurchaseResult`, but the bare SDK enums +//! (rs-sdk/src/platform/tokens/transitions/{set_price_for_direct_ +//! purchase,direct_purchase}.rs) don't surface a fee field. We assert +//! the fee via credit-balance deltas instead — buyer's decrease must +//! exceed `total_agreed_price`, owner's increase must be at most +//! `total_agreed_price` (a positive seller-side protocol fee shrinks +//! the credit landing in the owner's account). + +use std::sync::Arc; + +use dash_sdk::platform::tokens::builders::purchase::TokenDirectPurchaseTransitionBuilder; +use dash_sdk::platform::tokens::builders::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityBalance; +use dpp::data_contract::DataContract; +use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + mint_to, setup_with_token_and_two_identities, token_balance_of, token_pricing_of, + DEFAULT_TK_FUNDING, DEFAULT_TOKEN_POSITION, +}; + +const MINT_AMOUNT: u64 = 1_000; +const PRICE_PER_TOKEN: u64 = 1_000; +const PURCHASE_AMOUNT: u64 = 10; +const TOTAL_AGREED_PRICE: u64 = 10_000; + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_011_set_price_and_direct_purchase_round_trip() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("init e2e context"); + let s = setup_with_token_and_two_identities(ctx, DEFAULT_TK_FUNDING) + .await + .expect("token + two identities setup"); + + let owner = &s.setup.owner; + let buyer = &s.peer; + let contract_id = s.setup.contract_id; + let position = s.setup.token_position; + + // Step 1: owner mints 1_000 tokens to self. + mint_to(ctx, contract_id, position, MINT_AMOUNT, owner, owner) + .await + .expect("owner mint to self"); + + let owner_token_pre = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner token balance pre-purchase"); + assert!( + owner_token_pre >= MINT_AMOUNT, + "owner mint must settle before set_price (balance={owner_token_pre})" + ); + + let buyer_token_pre = token_balance_of(ctx, contract_id, position, buyer.id) + .await + .expect("buyer token balance pre-purchase"); + assert_eq!(buyer_token_pre, 0, "buyer must start with zero tokens"); + + // Pricing must be unset initially. + let pricing_pre = token_pricing_of(ctx, contract_id, position) + .await + .expect("pricing pre-set"); + assert!( + pricing_pre.is_none(), + "no pricing schedule should exist before set_price (got {pricing_pre:?})" + ); + + let data_contract = Arc::new( + DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch contract for set_price") + .expect("contract on chain"), + ); + + // Step 2: owner sets the pricing schedule to SinglePrice(1_000). + let set_price_builder = TokenChangeDirectPurchasePriceTransitionBuilder::new( + data_contract.clone(), + position, + owner.id, + ) + .with_single_price(PRICE_PER_TOKEN); + + ctx.sdk() + .token_set_price_for_direct_purchase( + set_price_builder, + &owner.high_key, + owner.signer.as_ref(), + ) + .await + .expect("set_price transition"); + + let pricing_post = token_pricing_of(ctx, contract_id, position) + .await + .expect("pricing post-set"); + match pricing_post { + Some(TokenPricingSchedule::SinglePrice(p)) => { + assert_eq!(p, PRICE_PER_TOKEN, "on-chain price must match what we set") + } + other => panic!("expected SinglePrice({PRICE_PER_TOKEN}), got {other:?}"), + } + + // Snapshot credit balances around the purchase. The bare SDK + // result enums don't expose actual_fee, so we read the deltas + // directly to verify the spec's two-side credit-flow assertions. + let buyer_credits_pre = ::fetch(ctx.sdk(), buyer.id) + .await + .expect("buyer credit balance pre-purchase") + .expect("buyer balance present"); + let owner_credits_pre = ::fetch(ctx.sdk(), owner.id) + .await + .expect("owner credit balance pre-purchase") + .expect("owner balance present"); + + // Step 3: buyer purchases 10 tokens at total_agreed_price=10_000. + let purchase_builder = TokenDirectPurchaseTransitionBuilder::new( + data_contract, + position, + buyer.id, + PURCHASE_AMOUNT, + TOTAL_AGREED_PRICE, + ); + ctx.sdk() + .token_purchase(purchase_builder, &buyer.high_key, buyer.signer.as_ref()) + .await + .expect("purchase transition"); + + // Step 4: post-purchase balances. + let buyer_token_post = token_balance_of(ctx, contract_id, position, buyer.id) + .await + .expect("buyer token balance post-purchase"); + let owner_token_post = token_balance_of(ctx, contract_id, position, owner.id) + .await + .expect("owner token balance post-purchase"); + assert_eq!( + buyer_token_post, PURCHASE_AMOUNT, + "buyer must hold exactly PURCHASE_AMOUNT after the purchase \ + (got {buyer_token_post})" + ); + assert_eq!( + owner_token_post, + owner_token_pre - PURCHASE_AMOUNT, + "owner stock must decrease by PURCHASE_AMOUNT \ + (pre={owner_token_pre} post={owner_token_post})" + ); + + let buyer_credits_post = ::fetch(ctx.sdk(), buyer.id) + .await + .expect("buyer credit balance post-purchase") + .expect("buyer balance present"); + let owner_credits_post = ::fetch(ctx.sdk(), owner.id) + .await + .expect("owner credit balance post-purchase") + .expect("owner balance present"); + + let buyer_credit_drop = buyer_credits_pre.saturating_sub(buyer_credits_post); + let owner_credit_gain = owner_credits_post.saturating_sub(owner_credits_pre); + let purchase_fee = buyer_credit_drop.saturating_sub(TOTAL_AGREED_PRICE); + + assert!( + buyer_credit_drop >= TOTAL_AGREED_PRICE, + "buyer credits must drop by at least the agreed price \ + (drop={buyer_credit_drop} agreed={TOTAL_AGREED_PRICE})" + ); + assert!( + purchase_fee > 0, + "buyer must pay a non-zero protocol fee on top of the price \ + (drop={buyer_credit_drop} agreed={TOTAL_AGREED_PRICE})" + ); + // Owner's net gain is bounded by the agreed price; the protocol + // pricing-schedule spec allows a seller-side fee to shave off some + // of the incoming credits. + assert!( + owner_credit_gain <= TOTAL_AGREED_PRICE, + "owner gain must not exceed the agreed price \ + (gain={owner_credit_gain} agreed={TOTAL_AGREED_PRICE})" + ); + assert!( + owner_credit_gain > 0, + "owner must receive some credits from the purchase (gain={owner_credit_gain})" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_011", + ?contract_id, + buyer_credit_drop, + owner_credit_gain, + purchase_fee, + "TK-011 purchase round-trip complete" + ); + + // TODO(spec-drift): once SetPriceResult / DirectPurchaseResult + // expose actual_fee, also assert SetPriceResult.actual_fee > 0 and + // DirectPurchaseResult.actual_fee > 0 per TEST_SPEC.md TK-011. + + let _ = DEFAULT_TOKEN_POSITION; // silence unused-import in stripped builds. + + s.setup.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_012_token_update_config.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_012_token_update_config.rs new file mode 100644 index 0000000000..6b84e62f17 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_012_token_update_config.rs @@ -0,0 +1,133 @@ +//! TK-012 — Update token config (single ChangeItem mutation). +//! +//! Single-identity (owner) setup. Owner mutates `max_supply` via a +//! `TokenConfigurationChangeItem::MaxSupply(...)` and we re-fetch the +//! contract to confirm the change is observable on chain. +//! +//! Wave 2 stub: `#[ignore]`d. Wave 4 runs against live testnet. +//! +//! Spec drift note: TEST_SPEC.md asks for a positive `actual_fee` on +//! `ConfigUpdateResult`, but the bare SDK `ConfigUpdateResult` enum +//! (rs-sdk/src/platform/tokens/transitions/config_update.rs) does not +//! surface a fee field. Wave 4 will read the fee from credit-balance +//! deltas or wait on an SDK fee accessor; for now the `actual_fee` +//! assertion is a TODO. +//! +//! Each call to `setup_with_token_contract` deploys a brand-new +//! contract under a fresh owner — the spec's "fresh deploy" requirement +//! falls out for free. + +use std::sync::Arc; + +use dash_sdk::platform::tokens::builders::config_update::TokenConfigUpdateTransitionBuilder; +use dash_sdk::platform::Fetch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; +use dpp::data_contract::DataContract; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + setup_with_token_contract, DEFAULT_MAX_SUPPLY, DEFAULT_TK_FUNDING, DEFAULT_TOKEN_POSITION, +}; + +/// Doubled max_supply target — `TEST_SPEC.md` TK-012 step 2. +const NEW_MAX_SUPPLY: u64 = 2_000_000_000_000_000; + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_012_update_token_config_max_supply() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("init e2e context"); + let s = setup_with_token_contract(ctx, DEFAULT_TK_FUNDING) + .await + .expect("token + owner setup"); + + let owner = &s.owner; + let contract_id = s.contract_id; + let position = s.token_position; + + // Pre-state: confirm the freshly-deployed contract has the default + // max_supply we expect to mutate from. + let pre_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch pre-update contract") + .expect("contract on chain"); + let pre_version = pre_contract.version(); + let pre_token_config = pre_contract + .tokens() + .get(&position) + .expect("token slot present at default position"); + assert_eq!( + pre_token_config.max_supply(), + Some(DEFAULT_MAX_SUPPLY), + "freshly-deployed permissive contract must have max_supply=DEFAULT_MAX_SUPPLY" + ); + + let pre_contract_arc = Arc::new(pre_contract); + + // Step 2: owner submits a single-ChangeItem mutation. + let change_item = TokenConfigurationChangeItem::MaxSupply(Some(NEW_MAX_SUPPLY)); + let builder = + TokenConfigUpdateTransitionBuilder::new(pre_contract_arc, position, owner.id, change_item); + + ctx.sdk() + .token_update_contract_token_configuration(builder, &owner.high_key, owner.signer.as_ref()) + .await + .expect("config update transition"); + + // Step 3: re-fetch the contract; assert max_supply moved and the + // contract version (or token-config version, whichever DPP bumps) + // advanced. + let post_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch post-update contract") + .expect("contract still on chain"); + let post_version = post_contract.version(); + let post_token_config = post_contract + .tokens() + .get(&position) + .expect("token slot still at default position"); + assert_eq!( + post_token_config.max_supply(), + Some(NEW_MAX_SUPPLY), + "max_supply must reflect the change-item value (got {:?})", + post_token_config.max_supply() + ); + assert!( + post_version >= pre_version, + "contract version must not regress (pre={pre_version} post={post_version})" + ); + // DPP bumps either the contract version or the token-config version + // on a config mutation — at least one of the two must advance. + let contract_version_bumped = post_version > pre_version; + assert!( + contract_version_bumped, + "contract version must advance on a TokenConfigurationChangeItem mutation \ + (pre={pre_version} post={post_version})" + ); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_012", + ?contract_id, + pre_version, + post_version, + new_max_supply = NEW_MAX_SUPPLY, + "TK-012 max_supply update settled" + ); + + // TODO(spec-drift): once ConfigUpdateResult exposes actual_fee, + // assert config_update_fee > 0 per TEST_SPEC.md TK-012. + + let _ = DEFAULT_TOKEN_POSITION; // pin import even when unused. + + s.setup_guard.teardown().await.expect("teardown"); +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_013_token_claim_pre_programmed.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_013_token_claim_pre_programmed.rs new file mode 100644 index 0000000000..6d0bd3f50b --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_013_token_claim_pre_programmed.rs @@ -0,0 +1,245 @@ +//! TK-013 — Token claim from pre-programmed distribution. +//! +//! Owner deploys a token with a pre-programmed distribution whose +//! epoch zero is parked at a past timestamp, then calls `token_claim` +//! with `TokenDistributionType::PreProgrammed`. Asserts the owner's +//! balance increases by exactly the configured payout. Mirrors the +//! wallet's `token_claim_with_signer` chain path — the wallet helper +//! just forwards to `Sdk::token_claim`, which is what this test +//! drives directly to keep the framework surface flat (cf. `mint_to` +//! in `framework/tokens.rs`). +//! +//! Pre-programmed (not perpetual). Perpetual is TK-002, gated behind +//! `slow-tests` because it needs live block-time. The pre-programmed +//! variant short-circuits that wait via a past-timestamp epoch zero. +//! +//! Gated behind `#[ignore]` — same operator-env reasoning as the +//! transfer case (`PLATFORM_WALLET_E2E_BANK_MNEMONIC` + live testnet +//! DAPI access). + +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use dpp::balances::credits::TokenAmount; +use dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; +use dpp::data_contract::DataContract; +use dpp::prelude::{Identifier, TimestampMillis}; + +use dash_sdk::platform::tokens::builders::claim::TokenClaimTransitionBuilder; +use dash_sdk::platform::tokens::transitions::ClaimResult; +use dash_sdk::platform::Fetch; + +use crate::framework::prelude::*; +use crate::framework::setup_with_n_identities; +use crate::framework::tokens::{ + register_token_contract_via_sdk, token_balance_of, DEFAULT_BASE_SUPPLY, DEFAULT_DECIMALS, + DEFAULT_MAX_SUPPLY, DEFAULT_TOKEN_POSITION, +}; + +/// Per-epoch payout the schedule credits to the owner. Small enough +/// that an over-shoot regression (multiple credits, double-mint) +/// surfaces as an unmistakable balance mismatch. +const PAYOUT: TokenAmount = 100; + +/// Per-identity bank funding for the setup helper. Covers contract +/// create + a couple of state transitions with headroom — sized in +/// line with the rest of the TK fixtures. +const FUNDING: dpp::fee::Credits = 1_000_000_000; + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_013_token_claim_from_pre_programmed_distribution() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + // Register the owner first so its identifier is known before we + // bake the distribution schedule into the contract JSON. The + // helper `setup_with_token_pre_programmed_distribution` takes the + // schedule by value and registers + deploys in a single call — it + // can't see the owner id ahead of time, so for the + // owner-claims-its-own-payout shape (TK-013) we drive the lower + // primitives directly. + let setup_guard = setup_with_n_identities(1, FUNDING) + .await + .expect("register owner identity"); + let ctx = setup_guard.base.ctx; + let owner = &setup_guard.identities[0]; + let owner_id = owner.id; + + // Park epoch zero one hour in the past so the chain treats the + // payout as already eligible the moment the contract lands — + // dodges the live-time wait that gates the perpetual variant + // (TK-002). + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is past UNIX_EPOCH") + .as_millis() as TimestampMillis; + let epoch_zero_at = now_ms.saturating_sub(Duration::from_secs(3600).as_millis() as u64); + + let contract_json = build_pre_programmed_token_json(owner_id, epoch_zero_at, PAYOUT); + let contract_id = register_token_contract_via_sdk(ctx, owner, contract_json) + .await + .expect("register pre-programmed token contract"); + + // Snapshot pre-claim balance so the assertion is robust against + // any historical seed in the contract (there shouldn't be one, + // but a strict diff is the right shape). + let balance_before = token_balance_of(ctx, contract_id, DEFAULT_TOKEN_POSITION, owner_id) + .await + .expect("pre-claim balance"); + + // Build + broadcast the claim. The wallet's + // `token_claim_with_signer` is a thin forward to + // `Sdk::token_claim`, so we drive the SDK builder directly here + // — same chain path, fewer indirections, mirrors the existing + // `mint_to` framework helper. + let data_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch token data contract") + .expect("token data contract present on chain"); + let builder = TokenClaimTransitionBuilder::new( + Arc::new(data_contract), + DEFAULT_TOKEN_POSITION, + owner_id, + TokenDistributionType::PreProgrammed, + ); + let claim_result = ctx + .sdk() + .token_claim(builder, &owner.high_key, owner.signer.as_ref()) + .await + .expect("token_claim broadcast"); + + // The proof envelope returns either a Document (history-tracked) + // or a GroupActionWithDocument (group-gated). For TK-013 the + // contract is owner-only and the claim is non-group, so we expect + // a Document — guarding both arms keeps the test sensitive to + // a result-shape change without depending on it. + match &claim_result { + ClaimResult::Document(_) | ClaimResult::GroupActionWithDocument(_, _) => {} + } + + let balance_after = token_balance_of(ctx, contract_id, DEFAULT_TOKEN_POSITION, owner_id) + .await + .expect("post-claim balance"); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_013", + ?contract_id, + ?owner_id, + epoch_zero_at, + balance_before, + balance_after, + payout = PAYOUT, + "TK-013 post-claim balance snapshot" + ); + + assert_eq!( + balance_after, + balance_before + PAYOUT, + "post-claim balance must equal pre-claim + payout (claim from pre-programmed distribution silently fails — balance just doesn't move). \ + observed before={balance_before} after={balance_after} expected_delta={PAYOUT}" + ); + + setup_guard.teardown().await.expect("teardown"); +} + +/// Build a permissive owner-only V1 token-contract JSON with a +/// pre-programmed distribution baked in at `epoch_zero_at_ms` +/// granting `payout` to `owner_id`. Self-contained rather than +/// mutating `permissive_owner_token_contract_json` so this case file +/// owns the exact shape it tests against. +fn build_pre_programmed_token_json( + owner_id: Identifier, + epoch_zero_at_ms: TimestampMillis, + payout: TokenAmount, +) -> serde_json::Value { + use serde_json::json; + + let owner_b58 = bs58::encode(owner_id.to_buffer()).into_string(); + let owner_only = json!({ + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false, + }); + + // `serde_json::json!` requires literal map keys, so build the + // schedule map manually. + let mut by_recipient = serde_json::Map::new(); + by_recipient.insert(owner_b58.clone(), json!(payout)); + let mut schedule = serde_json::Map::new(); + schedule.insert( + epoch_zero_at_ms.to_string(), + serde_json::Value::Object(by_recipient), + ); + + let token_slot = json!({ + "$formatVersion": "0", + "conventions": { + "$formatVersion": "0", + "decimals": DEFAULT_DECIMALS, + "localizations": { + "en": { + "$formatVersion": "0", + "shouldCapitalize": false, + "singularForm": "E2ETestToken", + "pluralForm": "E2ETestTokens", + } + }, + }, + "conventionsChangeRules": owner_only, + "baseSupply": DEFAULT_BASE_SUPPLY, + "maxSupply": DEFAULT_MAX_SUPPLY, + "keepsHistory": { + "$formatVersion": "0", + "keepsTransferHistory": true, + "keepsFreezingHistory": true, + "keepsMintingHistory": true, + "keepsBurningHistory": true, + "keepsDirectPricingHistory": true, + "keepsDirectPurchaseHistory": true, + }, + "startAsPaused": false, + "allowTransferToFrozenBalance": false, + "maxSupplyChangeRules": owner_only, + "distributionRules": { + "$formatVersion": "0", + "perpetualDistribution": null, + "perpetualDistributionRules": owner_only, + "preProgrammedDistribution": { + "$formatVersion": "0", + "distributions": serde_json::Value::Object(schedule), + }, + "newTokensDestinationIdentity": owner_b58, + "newTokensDestinationIdentityRules": owner_only, + "mintingAllowChoosingDestination": true, + "mintingAllowChoosingDestinationRules": owner_only, + "changeDirectPurchasePricingRules": owner_only, + }, + "manualMintingRules": owner_only, + "manualBurningRules": owner_only, + "freezeRules": owner_only, + "unfreezeRules": owner_only, + "destroyFrozenFundsRules": owner_only, + "emergencyActionRules": owner_only, + "mainControlGroup": null, + "mainControlGroupCanBeModified": "ContractOwner", + "description": "TK-013 pre-programmed distribution token (rs-platform-wallet e2e).", + "marketplaceRules": { + "$formatVersion": "0", + "tradeMode": 1, + "tradeModeChangeRules": owner_only, + }, + }); + + let mut tokens = serde_json::Map::new(); + tokens.insert(DEFAULT_TOKEN_POSITION.to_string(), token_slot); + serde_json::Value::Object(tokens) +} diff --git a/packages/rs-platform-wallet/tests/e2e/cases/tk_014_token_group_action.rs b/packages/rs-platform-wallet/tests/e2e/cases/tk_014_token_group_action.rs new file mode 100644 index 0000000000..c960836dc2 --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/cases/tk_014_token_group_action.rs @@ -0,0 +1,510 @@ +//! TK-014 — Group-action gateway: queue a mint, list pending, co-sign. +//! +//! Three-identity contract whose `manualMintingRules` route through a +//! 2-of-3 group at position 0. Walks the gateway end-to-end: +//! 1. Identity #0 (owner) proposes a mint of `MINT_AMOUNT` to peer A. +//! 2. `pending_group_actions` lists the proposal (status +//! `ActionActive`). +//! 3. Identity #1 (peer A) co-signs by re-broadcasting the same mint +//! with `GroupStateTransitionInfoOtherSigner(action_id)`. +//! 4. After threshold, `pending_group_actions` shows the action +//! `ActionClosed` and the recipient balance has moved. +//! +//! Wallet-feature parity: `wallet/identity/network/tokens/mint.rs:19` +//! (`token_mint_with_signer`) with `group_info: Some(...)`. The wallet +//! helper is a thin forward to `Sdk::token_mint`, so the test drives +//! the SDK builder directly — same chain path, mirrors the existing +//! `mint_to` framework helper. +//! +//! Group config is built inline (not via +//! `permissive_owner_token_contract_json`) because that builder +//! belongs to Wave 1; Wave 2 owns this case file's JSON shape. +//! `register_token_contract_via_sdk` doesn't surface a `groups` +//! injection point either, so this file ships its own +//! `publish_token_contract_with_groups` helper that mirrors the +//! framework helper's V1-envelope assembly with `groups` populated. +//! +//! Gated behind `#[ignore]` for the same reason as transfer / TK-013. + +use std::sync::Arc; + +use dpp::balances::credits::TokenAmount; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::serialized_version::DataContractInSerializationFormat; +use dpp::data_contract::{DataContract, GroupContractPosition}; +use dpp::group::group_action_status::GroupActionStatus; +use dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; +use dpp::prelude::Identifier; +use dpp::version::PlatformVersion; + +use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder; +use dash_sdk::platform::tokens::transitions::MintResult; +use dash_sdk::platform::transition::put_contract::PutContract; +use dash_sdk::platform::Fetch; + +use crate::framework::prelude::*; +use crate::framework::tokens::{ + setup_with_token_and_three_identities, token_balance_of, token_supply_of, DEFAULT_BASE_SUPPLY, + DEFAULT_DECIMALS, DEFAULT_MAX_SUPPLY, DEFAULT_TOKEN_POSITION, +}; +use crate::framework::wallet_factory::RegisteredIdentity; + +/// Per-identity bank funding. Three identities each broadcast at +/// least one state transition; the floor leaves headroom for the +/// extra contract-create + mint propose / co-sign legs. +const FUNDING: dpp::fee::Credits = 1_500_000_000; + +/// Tokens minted via the group-gated proposal. Small enough that any +/// arithmetic regression (extra credit, dropped co-sign) surfaces as +/// a stark balance mismatch. +const MINT_AMOUNT: TokenAmount = 42; + +/// Group is at position 0 in the contract, threshold 2-of-3. +const GROUP_POSITION: GroupContractPosition = 0; + +#[tokio_shared_rt::test(shared)] +#[ignore = "requires PLATFORM_WALLET_E2E_BANK_MNEMONIC and live testnet access; run with `cargo test -- --ignored`"] +async fn tk_014_token_group_action_mint_co_sign() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,platform_wallet=debug".into()), + ) + .with_test_writer() + .try_init(); + + let ctx = E2eContext::init().await.expect("e2e context init"); + + // Bootstrap three identities. The contract the helper publishes + // has no group config, so we discard its `contract_id` and + // re-deploy our own with a 2-of-3 group at position 0. + let three = setup_with_token_and_three_identities(ctx, FUNDING) + .await + .expect("setup_with_token_and_three_identities"); + let setup = three.setup; + let peers = three.peers; + let ctx = setup.setup_guard.base.ctx; + let owner = &setup.owner; + let peer_a = &peers[0]; + let peer_b = &peers[1]; + let recipient_id = peer_a.id; + + let group_member_ids = [owner.id, peer_a.id, peer_b.id]; + let contract_id = publish_token_contract_with_groups(ctx, owner, &group_member_ids) + .await + .expect("publish group-gated token contract"); + + // Snapshot baseline. Token max supply is the harness default, + // base supply zero — both balance and supply start at 0; the + // assertions still diff against the snapshot to stay robust + // against any historical seed. + let supply_before = token_supply_of(ctx, contract_id, DEFAULT_TOKEN_POSITION) + .await + .expect("pre-propose total supply"); + let balance_before = token_balance_of(ctx, contract_id, DEFAULT_TOKEN_POSITION, recipient_id) + .await + .expect("pre-propose recipient balance"); + + let data_contract: Arc = Arc::new( + DataContract::fetch(ctx.sdk(), contract_id) + .await + .expect("fetch token data contract") + .expect("token data contract present on chain"), + ); + + // Step 1 — owner proposes a mint via the group gateway. + let propose_result = mint_with_group_info( + ctx, + Arc::clone(&data_contract), + owner, + recipient_id, + MINT_AMOUNT, + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(GROUP_POSITION), + ) + .await + .expect("owner propose mint"); + + // After step 1 the recipient balance must be unchanged — the + // proposal sits below the threshold and tokens haven't moved. + let balance_mid = token_balance_of(ctx, contract_id, DEFAULT_TOKEN_POSITION, recipient_id) + .await + .expect("post-propose recipient balance"); + assert_eq!( + balance_mid, balance_before, + "recipient balance must not change before threshold is met (observed before={balance_before} mid={balance_mid})" + ); + + // Step 2 — list pending group actions; assert one entry with the + // proposed amount + recipient. + let pending = pending_group_actions( + ctx.sdk(), + contract_id, + GROUP_POSITION, + GroupActionStatus::ActionActive, + ) + .await + .expect("list pending group actions"); + + let active_entry = pending + .iter() + .find(|e| { + matches!(&e.params, GroupActionParamsLite::Mint { amount, recipient } + if *amount == MINT_AMOUNT && *recipient == recipient_id) + }) + .expect("pending list must contain the proposed mint"); + + let action_id = active_entry.action_id; + tracing::info!( + target: "platform_wallet::e2e::cases::tk_014", + ?action_id, + proposer = ?active_entry.proposer, + "TK-014 proposed action surfaced in pending list" + ); + + // Cross-reference the result of step 1: the proposer leg must + // produce a group-action shape (never the synchronous + // TokenBalance/HistoricalDocument shape — those would mean the + // proposal already executed, contradicting the 2-of-3 threshold). + match &propose_result { + MintResult::GroupActionWithBalance(_, status, _) => { + assert_eq!( + *status, + GroupActionStatus::ActionActive, + "proposer leg must leave the action ActionActive (observed {status:?})" + ); + } + MintResult::GroupActionWithDocument(_, _) => {} + MintResult::TokenBalance(_, _) | MintResult::HistoricalDocument(_) => { + panic!("proposer leg must NOT produce a synchronous mint result — that would bypass the 2-of-3 group threshold"); + } + } + + // Step 3 — peer A co-signs. Same builder, group_info points at + // the existing action_id with `action_is_proposer = false`. + let co_sign_result = mint_with_group_info( + ctx, + Arc::clone(&data_contract), + peer_a, + recipient_id, + MINT_AMOUNT, + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: GROUP_POSITION, + action_id, + action_is_proposer: false, + }, + ), + ) + .await + .expect("peer A co-sign mint"); + + // Step 4 — recipient balance and supply must have advanced now + // that the threshold (2-of-3) is met. + let balance_after = token_balance_of(ctx, contract_id, DEFAULT_TOKEN_POSITION, recipient_id) + .await + .expect("post-cosign recipient balance"); + let supply_after = token_supply_of(ctx, contract_id, DEFAULT_TOKEN_POSITION) + .await + .expect("post-cosign total supply"); + + tracing::info!( + target: "platform_wallet::e2e::cases::tk_014", + ?contract_id, + ?recipient_id, + ?action_id, + balance_before, + balance_mid, + balance_after, + supply_before, + supply_after, + amount = MINT_AMOUNT, + "TK-014 post-cosign balance + supply snapshot" + ); + + assert_eq!( + balance_after, + balance_before + MINT_AMOUNT, + "recipient balance must advance by the minted amount after threshold is met. \ + observed before={balance_before} after={balance_after} expected_delta={MINT_AMOUNT}" + ); + assert_eq!( + supply_after, + supply_before + MINT_AMOUNT, + "total supply must advance by the minted amount after threshold is met. \ + observed before={supply_before} after={supply_after} expected_delta={MINT_AMOUNT}" + ); + + // Pending list now reports the action as Closed. + let closed = pending_group_actions( + ctx.sdk(), + contract_id, + GROUP_POSITION, + GroupActionStatus::ActionClosed, + ) + .await + .expect("list closed group actions"); + assert!( + closed.iter().any(|e| e.action_id == action_id), + "closed-action list must contain the just-completed action_id={action_id}" + ); + + // Active list must no longer carry the action_id. + let still_active = pending_group_actions( + ctx.sdk(), + contract_id, + GROUP_POSITION, + GroupActionStatus::ActionActive, + ) + .await + .expect("re-list active group actions"); + assert!( + still_active.iter().all(|e| e.action_id != action_id), + "active-action list must NOT carry the closed action_id={action_id}" + ); + + // Sanity-check the co-sign result envelope. + match &co_sign_result { + MintResult::GroupActionWithBalance(_, status, _) => { + assert_eq!( + *status, + GroupActionStatus::ActionClosed, + "co-sign leg that meets threshold must close the action (observed {status:?})" + ); + } + // History-tracked tokens take the document arm; the closed + // status is implicit in the balance/supply assertions above. + MintResult::GroupActionWithDocument(_, _) => {} + MintResult::TokenBalance(_, _) | MintResult::HistoricalDocument(_) => { + panic!("co-sign leg must produce a group-action MintResult"); + } + } + + setup.setup_guard.teardown().await.expect("teardown"); +} + +/// Drive `Sdk::token_mint` with the supplied `group_info`. Mirrors +/// the wallet's `token_mint_with_signer` (`mint.rs:19`) — that helper +/// just forwards to the SDK with the same `with_using_group_info` +/// hook, which is what we drive here to keep the test surface flat. +async fn mint_with_group_info( + ctx: &E2eContext, + data_contract: Arc, + actor: &RegisteredIdentity, + recipient_id: Identifier, + amount: TokenAmount, + group_info: GroupStateTransitionInfoStatus, +) -> Result { + let builder = + TokenMintTransitionBuilder::new(data_contract, DEFAULT_TOKEN_POSITION, actor.id, amount) + .issued_to_identity_id(recipient_id) + .with_using_group_info(group_info); + ctx.sdk() + .token_mint(builder, &actor.high_key, actor.signer.as_ref()) + .await +} + +/// Flattened mint-only view over a pending group action. Drops the +/// fields TK-014 doesn't read (public_note, position) so the +/// assertion site stays compact. +struct PendingActionLite { + action_id: Identifier, + proposer: Identifier, + params: GroupActionParamsLite, +} + +enum GroupActionParamsLite { + Mint { + amount: TokenAmount, + recipient: Identifier, + }, + Other, +} + +/// Local wrapper around `GroupAction::fetch_many` filtered to +/// `(contract, position, status)`. The wallet's +/// `pending_group_actions_external` helper does the same thing in +/// production code; we mirror it inline so the test crate stays free +/// of platform-wallet's internal-helper dependency. +async fn pending_group_actions( + sdk: &dash_sdk::Sdk, + contract_id: Identifier, + group_contract_position: GroupContractPosition, + status: GroupActionStatus, +) -> Result, dash_sdk::Error> { + use dash_sdk::platform::group_actions::GroupActionsQuery; + use dash_sdk::platform::FetchMany; + use dpp::group::action_event::GroupActionEvent; + use dpp::group::group_action::{GroupAction, GroupActionAccessors}; + use dpp::tokens::token_event::TokenEvent; + + let query = GroupActionsQuery { + contract_id, + group_contract_position, + status, + start_at_action_id: None, + limit: None, + }; + + let rows = GroupAction::fetch_many(sdk, query).await?; + let mut out = Vec::with_capacity(rows.len()); + for (action_id, maybe_action) in rows { + let Some(action) = maybe_action else { continue }; + let GroupActionEvent::TokenEvent(event) = action.event().clone(); + let params = match event { + TokenEvent::Mint(amount, recipient, _note) => { + GroupActionParamsLite::Mint { amount, recipient } + } + _ => GroupActionParamsLite::Other, + }; + out.push(PendingActionLite { + action_id, + proposer: action.proposer_id(), + params, + }); + } + Ok(out) +} + +/// Inline V1-envelope assembler. Mirrors the framework's +/// `register_token_contract_via_sdk` (Wave 1) but injects the +/// `groups` field — which the framework helper currently doesn't +/// surface. Returns the chain-confirmed contract id. +async fn publish_token_contract_with_groups( + ctx: &E2eContext, + owner: &RegisteredIdentity, + group_members: &[Identifier; 3], +) -> FrameworkResult { + use serde_json::json; + + let placeholder_id = Identifier::default(); + let owner_b58 = bs58::encode(owner.id.to_buffer()).into_string(); + + let owner_only = json!({ + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false, + }); + let group_only = json!({ + "$formatVersion": "0", + "authorizedToMakeChange": { "Group": GROUP_POSITION }, + "adminActionTakers": { "Group": GROUP_POSITION }, + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false, + }); + + // `serde_json::json!` requires literal map keys, so build the + // member roster manually. + let mut members = serde_json::Map::new(); + for id in group_members { + members.insert(bs58::encode(id.to_buffer()).into_string(), json!(1u32)); + } + let group = json!({ + "$formatVersion": "0", + "members": serde_json::Value::Object(members), + "requiredPower": 2u32, + }); + let mut groups = serde_json::Map::new(); + groups.insert(GROUP_POSITION.to_string(), group); + + let token_slot = json!({ + "$formatVersion": "0", + "conventions": { + "$formatVersion": "0", + "decimals": DEFAULT_DECIMALS, + "localizations": { + "en": { + "$formatVersion": "0", + "shouldCapitalize": false, + "singularForm": "E2ETestToken", + "pluralForm": "E2ETestTokens", + } + }, + }, + "conventionsChangeRules": owner_only, + "baseSupply": DEFAULT_BASE_SUPPLY, + "maxSupply": DEFAULT_MAX_SUPPLY, + "keepsHistory": { + "$formatVersion": "0", + "keepsTransferHistory": true, + "keepsFreezingHistory": true, + "keepsMintingHistory": true, + "keepsBurningHistory": true, + "keepsDirectPricingHistory": true, + "keepsDirectPurchaseHistory": true, + }, + "startAsPaused": false, + "allowTransferToFrozenBalance": false, + "maxSupplyChangeRules": owner_only, + "distributionRules": { + "$formatVersion": "0", + "perpetualDistribution": null, + "perpetualDistributionRules": owner_only, + "preProgrammedDistribution": null, + "newTokensDestinationIdentity": owner_b58, + "newTokensDestinationIdentityRules": owner_only, + "mintingAllowChoosingDestination": true, + "mintingAllowChoosingDestinationRules": owner_only, + "changeDirectPurchasePricingRules": owner_only, + }, + // The whole point of TK-014: gate manual minting on the + // 2-of-3 group at position 0. + "manualMintingRules": group_only, + "manualBurningRules": owner_only, + "freezeRules": owner_only, + "unfreezeRules": owner_only, + "destroyFrozenFundsRules": owner_only, + "emergencyActionRules": owner_only, + "mainControlGroup": GROUP_POSITION, + "mainControlGroupCanBeModified": "ContractOwner", + "description": "TK-014 group-gated mint token (rs-platform-wallet e2e).", + "marketplaceRules": { + "$formatVersion": "0", + "tradeMode": 1, + "tradeModeChangeRules": owner_only, + }, + }); + + let mut tokens = serde_json::Map::new(); + tokens.insert(DEFAULT_TOKEN_POSITION.to_string(), token_slot); + + let mut envelope = serde_json::Map::new(); + envelope.insert("$formatVersion".into(), json!("1")); + envelope.insert( + "id".into(), + json!(bs58::encode(placeholder_id.to_buffer()).into_string()), + ); + envelope.insert("ownerId".into(), json!(owner_b58)); + envelope.insert("version".into(), json!(1u32)); + envelope.insert("documentSchemas".into(), json!({})); + envelope.insert("groups".into(), serde_json::Value::Object(groups)); + envelope.insert("tokens".into(), serde_json::Value::Object(tokens)); + + let serialized = serde_json::to_string(&serde_json::Value::Object(envelope)) + .map_err(|err| FrameworkError::Sdk(format!("token-contract serialize: {err}")))?; + let format: DataContractInSerializationFormat = serde_json::from_str(&serialized) + .map_err(|err| FrameworkError::Sdk(format!("token-contract deserialize: {err}")))?; + + let platform_version = PlatformVersion::latest(); + let mut errors = vec![]; + let data_contract = + DataContract::try_from_platform_versioned(format, true, &mut errors, platform_version) + .map_err(|err| { + FrameworkError::Sdk(format!("token-contract build: {err} (errors={errors:?})")) + })?; + + let confirmed = data_contract + .put_to_platform_and_wait_for_response( + ctx.sdk(), + owner.master_key.clone(), + owner.signer.as_ref(), + None, + ) + .await + .map_err(|err| FrameworkError::Sdk(format!("put_to_platform: {err}")))?; + + Ok(confirmed.id()) +} diff --git a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs index 177f0db472..7dba35170d 100644 --- a/packages/rs-platform-wallet/tests/e2e/framework/mod.rs +++ b/packages/rs-platform-wallet/tests/e2e/framework/mod.rs @@ -27,6 +27,7 @@ pub mod registry; pub mod sdk; pub mod signer; pub mod spv; +pub mod tokens; pub mod wait; pub mod wait_hub; pub mod wallet_factory; diff --git a/packages/rs-platform-wallet/tests/e2e/framework/tokens.rs b/packages/rs-platform-wallet/tests/e2e/framework/tokens.rs new file mode 100644 index 0000000000..00a1938dcf --- /dev/null +++ b/packages/rs-platform-wallet/tests/e2e/framework/tokens.rs @@ -0,0 +1,798 @@ +//! Wave G token-harness extensions. +//! +//! Helpers for the TK-NNN test column: deploy permissive +//! token contracts, mint/transfer/freeze, and read back token state +//! through the SDK without per-test plumbing. Mirrors DET's +//! `tests/backend-e2e/framework/token_helpers.rs` but composes +//! against the e2e harness's [`E2eContext`] and Wave A +//! [`RegisteredIdentity`]. +//! +//! All read accessors come in two shapes: the high-level "of" +//! variant operates on a deployed [`TokenContractFixture`] / typed +//! `RegisteredIdentity`, and a lower-level `*_raw` variant accepts +//! raw 32-byte ids for tests that probe across contracts. +//! +//! Status: Wave G framework helpers only — Wave 2 wires up TK-NNN +//! test cases that exercise these. Runtime correctness is verified +//! in Wave 4 against a live testnet. +//! +//! Editorial notes (vs. Diziet's investigation sketch): +//! - `register_token_contract_via_sdk` signs with the +//! [`RegisteredIdentity::master_key`] (MASTER, KeyID 0). The +//! wallet's `create_data_contract_with_signer` filters for +//! CRITICAL keys (see `wallet/identity/network/contract.rs:158`), +//! but the SDK-direct path does not — so MASTER is accepted at +//! build-time and the chain-side security-level decision is +//! exercised in Wave 4. If testnet rejects MASTER on +//! `DataContractCreate`, swap to the wallet helper. +//! - `token_frozen_balance_of` returns a [`TokenAmount`] (the +//! identity's full token balance when `IdentityTokenInfo.frozen` +//! is `true`, else `0`). DPP only stores a `frozen: bool`; the +//! "frozen-balance" framing in TK-009/010/011 means "balance +//! that would be unspendable due to the freeze flag". + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dash_sdk::platform::transition::put_contract::PutContract; +use dash_sdk::platform::{Fetch, FetchMany}; +use dash_sdk::Sdk; +use dpp::balances::credits::TokenAmount; +use dpp::balances::total_single_token_balance::TotalSingleTokenBalance; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::serialized_version::DataContractInSerializationFormat; +use dpp::data_contract::{DataContract, TokenContractPosition}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::{Identifier, TimestampMillis}; +use dpp::tokens::calculate_token_id; +use dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; +use dpp::tokens::status::v0::TokenStatusV0Accessors; +use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; +use dpp::version::PlatformVersion; +use serde_json::json; + +use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder; +use dash_sdk::platform::tokens::identity_token_balances::IdentityTokenBalancesQuery; +use dash_sdk::platform::tokens::token_info::IdentityTokenInfosQuery; + +use super::harness::E2eContext; +use super::wallet_factory::RegisteredIdentity; +use super::{setup_with_n_identities, FrameworkError, FrameworkResult, MultiIdentitySetupGuard}; + +/// Default TK-NNN token slot. The permissive owner-only contract +/// always deploys a single token at position `0`. +pub const DEFAULT_TOKEN_POSITION: TokenContractPosition = 0; + +/// Default TK-NNN base supply (zero — owner mints in-test). +pub const DEFAULT_BASE_SUPPLY: TokenAmount = 0; + +/// Default TK-NNN max supply (`1e15`, mirrors DET). +pub const DEFAULT_MAX_SUPPLY: TokenAmount = 1_000_000_000_000_000; + +/// Default TK-NNN decimals (8, mirrors DET). +pub const DEFAULT_DECIMALS: u8 = 8; + +/// Default per-identity funding for TK setup helpers — covers +/// contract-create + a few state transitions with headroom. +pub const DEFAULT_TK_FUNDING: dpp::fee::Credits = 1_000_000_000; + +/// Pre-programmed distribution rule passed to +/// [`setup_with_token_pre_programmed_distribution`]. +/// +/// Each entry says: at `timestamp_ms`, credit `recipient` with +/// `amount`. The harness embeds this verbatim into the V1 +/// `tokens["0"].distributionRules.preProgrammedDistribution.distributions` +/// node so `token_claim_with_signer` can claim against a past-timestamp +/// epoch without waiting on live block time. +#[derive(Debug, Clone)] +pub struct PreProgrammedDistribution { + /// Distribution timeline. Each timestamp may credit one or more + /// identities — Wave 2 TK-013 uses a single past timestamp with + /// the owner as the sole recipient. + pub distributions: BTreeMap>, +} + +/// Single-identity TK setup. Returned by +/// [`setup_with_token_contract`] / +/// [`setup_with_token_pre_programmed_distribution`]. +/// +/// Holds the [`MultiIdentitySetupGuard`] so test bodies can `await +/// guard.teardown()`. The contract id is the canonical +/// chain-derived id (owner + nonce) returned by +/// [`register_token_contract_via_sdk`]. +pub struct TokenSetup { + /// Owns the test wallet + the bank loan. Caller must + /// `setup_guard.teardown()` at the end of the test body. + pub setup_guard: MultiIdentitySetupGuard, + /// Contract owner — funded with `owner_funding` credits at + /// registration time. + pub owner: RegisteredIdentity, + /// Chain-derived data-contract id. + pub contract_id: Identifier, + /// Token slot inside the contract; pinned to + /// [`DEFAULT_TOKEN_POSITION`] for the permissive default. + pub token_position: TokenContractPosition, +} + +impl TokenSetup { + /// Convenience id for the token at `token_position` — + /// `calculate_token_id(contract_id, position)`. + pub fn token_id(&self) -> Identifier { + Identifier::from(calculate_token_id( + self.contract_id.as_bytes(), + self.token_position, + )) + } +} + +/// Two-identity TK setup — owner + peer. +pub struct TokenTwoIdentitiesSetup { + /// Underlying single-identity setup (owns the contract id + + /// teardown guard). + pub setup: TokenSetup, + /// Second identity registered alongside the owner. + pub peer: RegisteredIdentity, +} + +/// Three-identity TK setup — owner + two peers (TK-014 group co-sign). +pub struct TokenThreeIdentitiesSetup { + /// Underlying single-identity setup. + pub setup: TokenSetup, + /// Two extra identities (peer_a, peer_b). + pub peers: [RegisteredIdentity; 2], +} + +// --------------------------------------------------------------------------- +// 14. register_token_contract_via_sdk — SDK-direct deploy +// --------------------------------------------------------------------------- + +/// Build a V1 token-contract document from `contract_json` and +/// broadcast it via [`PutContract::put_to_platform_and_wait_for_response`]. +/// +/// `contract_json` is the V1 `tokens` object, keyed by stringified +/// slot index (`"0"`, `"1"`, …). The helper wraps it in the rest of +/// the V1 envelope (`$formatVersion`, `id`, `ownerId`, `version`, +/// empty `documentSchemas`) before round-tripping through +/// [`DataContractInSerializationFormat`] — mirrors the wallet's +/// `create_data_contract_with_signer` path so the schema-drift +/// surface stays in one shape. +/// +/// Signs with [`RegisteredIdentity::master_key`] (MASTER). On chain +/// the contract-create transition validates the signing key against +/// the contract's CRITICAL requirement — Wave 4 confirms +/// real-world fitness. +pub async fn register_token_contract_via_sdk( + ctx: &E2eContext, + owner: &RegisteredIdentity, + contract_json: serde_json::Value, +) -> FrameworkResult { + let placeholder_id = Identifier::default(); + + let mut envelope = serde_json::Map::new(); + envelope.insert("$formatVersion".into(), json!("1")); + envelope.insert( + "id".into(), + json!(bs58::encode(placeholder_id.to_buffer()).into_string()), + ); + envelope.insert( + "ownerId".into(), + json!(bs58::encode(owner.id.to_buffer()).into_string()), + ); + envelope.insert("version".into(), json!(1u32)); + envelope.insert("documentSchemas".into(), json!({})); + envelope.insert("tokens".into(), contract_json); + + let serialized = serde_json::to_string(&serde_json::Value::Object(envelope)) + .map_err(|err| FrameworkError::Sdk(format!("token-contract serialize: {err}")))?; + let format: DataContractInSerializationFormat = serde_json::from_str(&serialized) + .map_err(|err| FrameworkError::Sdk(format!("token-contract deserialize: {err}")))?; + + let platform_version = PlatformVersion::latest(); + let mut errors = vec![]; + let data_contract = + DataContract::try_from_platform_versioned(format, true, &mut errors, platform_version) + .map_err(|err| { + FrameworkError::Sdk(format!("token-contract build: {err} (errors={errors:?})")) + })?; + + // SDK fetches+bumps the identity nonce internally and overwrites + // the placeholder id with the canonical (owner, nonce) derivation. + let confirmed = data_contract + .put_to_platform_and_wait_for_response( + ctx.sdk(), + owner.master_key.clone(), + owner.signer.as_ref(), + None, + ) + .await + .map_err(|err| FrameworkError::Sdk(format!("put_to_platform: {err}")))?; + + Ok(confirmed.id()) +} + +// --------------------------------------------------------------------------- +// 18. permissive_owner_token_contract_json — V1 JSON template +// --------------------------------------------------------------------------- + +/// Build the V1 `tokens` JSON node for a permissive owner-only token +/// contract, mirroring DET's +/// `tests/backend-e2e/framework/token_helpers.rs:33` +/// (`build_register_token_task`): 8 decimals, owner-only +/// ChangeControlRules across every gate, no perpetual distribution, +/// `mintingAllowChoosingDestination = true`, +/// `allowTransferToFrozenBalance = false`, +/// `marketplaceTradeMode = 1`. +/// +/// The returned [`serde_json::Value`] is the +/// `tokens` map (`{"0": {...}}`) ready to drop into +/// [`register_token_contract_via_sdk`]. +pub fn permissive_owner_token_contract_json( + owner_id: Identifier, + position: u16, + supply: TokenAmount, +) -> serde_json::Value { + let owner_b58 = bs58::encode(owner_id.to_buffer()).into_string(); + let owner_only = json!({ + "$formatVersion": "0", + "authorizedToMakeChange": "ContractOwner", + "adminActionTakers": "ContractOwner", + "changingAuthorizedActionTakersToNoOneAllowed": false, + "changingAdminActionTakersToNoOneAllowed": false, + "selfChangingAdminActionTakersAllowed": false, + }); + + let token_slot = json!({ + "$formatVersion": "0", + "conventions": { + "$formatVersion": "0", + "decimals": DEFAULT_DECIMALS, + "localizations": { + "en": { + "$formatVersion": "0", + "shouldCapitalize": false, + "singularForm": "E2ETestToken", + "pluralForm": "E2ETestTokens", + } + }, + }, + "conventionsChangeRules": owner_only, + "baseSupply": DEFAULT_BASE_SUPPLY, + "maxSupply": supply, + "keepsHistory": { + "$formatVersion": "0", + "keepsTransferHistory": true, + "keepsFreezingHistory": true, + "keepsMintingHistory": true, + "keepsBurningHistory": true, + "keepsDirectPricingHistory": true, + "keepsDirectPurchaseHistory": true, + }, + "startAsPaused": false, + "allowTransferToFrozenBalance": false, + "maxSupplyChangeRules": owner_only, + "distributionRules": { + "$formatVersion": "0", + "perpetualDistribution": null, + "perpetualDistributionRules": owner_only, + "preProgrammedDistribution": null, + "newTokensDestinationIdentity": owner_b58, + "newTokensDestinationIdentityRules": owner_only, + "mintingAllowChoosingDestination": true, + "mintingAllowChoosingDestinationRules": owner_only, + "changeDirectPurchasePricingRules": owner_only, + }, + "manualMintingRules": owner_only, + "manualBurningRules": owner_only, + "freezeRules": owner_only, + "unfreezeRules": owner_only, + "destroyFrozenFundsRules": owner_only, + "emergencyActionRules": owner_only, + "mainControlGroup": null, + "mainControlGroupCanBeModified": "ContractOwner", + "description": "Permissive owner-only token deployed by rs-platform-wallet e2e (Wave G).", + "marketplaceRules": { + "$formatVersion": "0", + "tradeMode": 1, + "tradeModeChangeRules": owner_only, + }, + }); + + let mut tokens = serde_json::Map::new(); + tokens.insert(position.to_string(), token_slot); + serde_json::Value::Object(tokens) +} + +// --------------------------------------------------------------------------- +// 12. setup_with_token_contract — single-identity bootstrap +// --------------------------------------------------------------------------- + +/// Register one identity (via [`setup_with_n_identities`]) and +/// deploy a permissive owner-only token contract owned by it. +/// Returns the [`TokenSetup`] guard so the test body can `setup. +/// setup_guard.teardown()` at the end. +pub async fn setup_with_token_contract( + ctx: &E2eContext, + owner_funding: dpp::fee::Credits, +) -> FrameworkResult { + let _ = ctx; + let setup_guard = setup_with_n_identities(1, owner_funding).await?; + let owner = setup_guard + .identities + .first() + .ok_or_else(|| { + FrameworkError::Wallet("setup_with_n_identities returned empty identities vec".into()) + })? + .clone_for_token_setup(); + + let json = + permissive_owner_token_contract_json(owner.id, DEFAULT_TOKEN_POSITION, DEFAULT_MAX_SUPPLY); + let contract_id = register_token_contract_via_sdk(setup_guard.base.ctx, &owner, json).await?; + + Ok(TokenSetup { + setup_guard, + owner, + contract_id, + token_position: DEFAULT_TOKEN_POSITION, + }) +} + +// --------------------------------------------------------------------------- +// 13. setup_with_token_and_two_identities +// --------------------------------------------------------------------------- + +/// Two-identity TK setup. Identity #0 owns the contract, identity +/// #1 is a peer for transfer / freeze / purchase scenarios. +pub async fn setup_with_token_and_two_identities( + ctx: &E2eContext, + funding_per: dpp::fee::Credits, +) -> FrameworkResult { + let _ = ctx; + let setup_guard = setup_with_n_identities(2, funding_per).await?; + let owner = setup_guard.identities[0].clone_for_token_setup(); + let peer = setup_guard.identities[1].clone_for_token_setup(); + + let json = + permissive_owner_token_contract_json(owner.id, DEFAULT_TOKEN_POSITION, DEFAULT_MAX_SUPPLY); + let contract_id = register_token_contract_via_sdk(setup_guard.base.ctx, &owner, json).await?; + + Ok(TokenTwoIdentitiesSetup { + setup: TokenSetup { + setup_guard, + owner, + contract_id, + token_position: DEFAULT_TOKEN_POSITION, + }, + peer, + }) +} + +// --------------------------------------------------------------------------- +// 14. setup_with_token_and_three_identities +// --------------------------------------------------------------------------- + +/// Three-identity TK setup — owner plus two peers (TK-014 group +/// co-sign happy path). +pub async fn setup_with_token_and_three_identities( + ctx: &E2eContext, + funding_per: dpp::fee::Credits, +) -> FrameworkResult { + let _ = ctx; + let setup_guard = setup_with_n_identities(3, funding_per).await?; + let owner = setup_guard.identities[0].clone_for_token_setup(); + let peers = [ + setup_guard.identities[1].clone_for_token_setup(), + setup_guard.identities[2].clone_for_token_setup(), + ]; + + let json = + permissive_owner_token_contract_json(owner.id, DEFAULT_TOKEN_POSITION, DEFAULT_MAX_SUPPLY); + let contract_id = register_token_contract_via_sdk(setup_guard.base.ctx, &owner, json).await?; + + Ok(TokenThreeIdentitiesSetup { + setup: TokenSetup { + setup_guard, + owner, + contract_id, + token_position: DEFAULT_TOKEN_POSITION, + }, + peers, + }) +} + +// --------------------------------------------------------------------------- +// 15. setup_with_token_pre_programmed_distribution +// --------------------------------------------------------------------------- + +/// Single-identity TK setup with a pre-programmed distribution +/// rule (TK-013). The caller supplies the `(timestamp → +/// {recipient → amount})` schedule; the helper embeds it under +/// `tokens["0"].distributionRules.preProgrammedDistribution`. +/// +/// Tests place a past timestamp here so the first claim becomes +/// eligible immediately, dodging the live-perpetual wall-clock +/// wait that gates TK-002. +pub async fn setup_with_token_pre_programmed_distribution( + ctx: &E2eContext, + owner_funding: dpp::fee::Credits, + distribution: PreProgrammedDistribution, +) -> FrameworkResult { + let _ = ctx; + let setup_guard = setup_with_n_identities(1, owner_funding).await?; + let owner = setup_guard.identities[0].clone_for_token_setup(); + + let mut json = + permissive_owner_token_contract_json(owner.id, DEFAULT_TOKEN_POSITION, DEFAULT_MAX_SUPPLY); + let token_slot = json + .get_mut(DEFAULT_TOKEN_POSITION.to_string()) + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| FrameworkError::Sdk("permissive token JSON missing slot 0".into()))?; + let distribution_rules = token_slot + .get_mut("distributionRules") + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| FrameworkError::Sdk("token slot missing distributionRules".into()))?; + + let mut distributions_json = serde_json::Map::new(); + for (ts, recipients) in distribution.distributions { + let mut by_recipient = serde_json::Map::new(); + for (id, amount) in recipients { + by_recipient.insert(bs58::encode(id.to_buffer()).into_string(), json!(amount)); + } + distributions_json.insert(ts.to_string(), serde_json::Value::Object(by_recipient)); + } + + distribution_rules.insert( + "preProgrammedDistribution".into(), + json!({ + "$formatVersion": "0", + "distributions": distributions_json, + }), + ); + + let contract_id = register_token_contract_via_sdk(setup_guard.base.ctx, &owner, json).await?; + + Ok(TokenSetup { + setup_guard, + owner, + contract_id, + token_position: DEFAULT_TOKEN_POSITION, + }) +} + +// --------------------------------------------------------------------------- +// 16. mint_to — owner-mints-to-recipient shortcut +// --------------------------------------------------------------------------- + +/// Owner mints `amount` to `recipient` via +/// [`Sdk::token_mint`]. Resolves only after the proof confirms the +/// new balance. +/// +/// The owner signs with [`RegisteredIdentity::high_key`] (HIGH) — +/// mint is a token-action transition, not a contract-mutate one, +/// so HIGH is the canonical signing level. +pub async fn mint_to( + ctx: &E2eContext, + contract_id: Identifier, + position: TokenContractPosition, + amount: TokenAmount, + recipient: &RegisteredIdentity, + owner_signer: &RegisteredIdentity, +) -> FrameworkResult<()> { + let data_contract = DataContract::fetch(ctx.sdk(), contract_id) + .await + .map_err(|err| FrameworkError::Sdk(format!("fetch data contract: {err}")))? + .ok_or_else(|| FrameworkError::Sdk(format!("contract {contract_id} not found on chain")))?; + + let builder = + TokenMintTransitionBuilder::new(Arc::new(data_contract), position, owner_signer.id, amount) + .issued_to_identity_id(recipient.id); + + ctx.sdk() + .token_mint( + builder, + &owner_signer.high_key, + owner_signer.signer.as_ref(), + ) + .await + .map_err(|err| FrameworkError::Sdk(format!("token_mint: {err}")))?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 17. wait_for_token_balance — poll-until-target +// --------------------------------------------------------------------------- + +/// Poll [`token_balance_of`] every +/// [`super::wait::DEFAULT_POLL_INTERVAL`] until the cached balance +/// reaches `expected`, then return the observed value. Mirrors PA's +/// `wait_for_balance` shape. +pub async fn wait_for_token_balance( + ctx: &E2eContext, + identity_id: Identifier, + contract_id: Identifier, + position: TokenContractPosition, + expected: TokenAmount, + timeout: Duration, +) -> FrameworkResult { + let deadline = Instant::now() + timeout; + loop { + match token_balance_raw(ctx.sdk(), identity_id, contract_id, position).await { + Ok(current) if current >= expected => return Ok(current), + Ok(current) => { + tracing::debug!( + target: "platform_wallet::e2e::tokens", + ?identity_id, + ?contract_id, + position, + current, + expected, + "token balance below target" + ); + } + Err(err) => { + tracing::debug!( + target: "platform_wallet::e2e::tokens", + ?identity_id, + error = %err, + "token balance fetch failed; retrying" + ); + } + } + + if Instant::now() >= deadline { + return Err(FrameworkError::Cleanup(format!( + "wait_for_token_balance timed out after {timeout:?} \ + (identity={identity_id} contract={contract_id} position={position} expected={expected})" + ))); + } + tokio::time::sleep(super::wait::DEFAULT_POLL_INTERVAL).await; + } +} + +// --------------------------------------------------------------------------- +// 19. register_extra_identity +// --------------------------------------------------------------------------- + +/// Register a fresh identity on the existing test wallet attached +/// to `setup`, funded with `funding` credits from the bank. Used by +/// TK cases that need a third party past the helpers' baseline +/// (e.g. an unauthorised-mint variant). +pub async fn register_extra_identity( + ctx: &E2eContext, + setup: &mut TokenSetup, + funding: dpp::fee::Credits, +) -> FrameworkResult { + use super::wait::wait_for_balance; + + let test_wallet = &setup.setup_guard.base.test_wallet; + + // Allocate the next DIP-9 slot above whatever `setup_with_n_identities` + // already consumed. Slot collisions would surface at registration. + let next_index = setup.setup_guard.identities.len() as u32; + + let funding_addr = test_wallet.next_unused_address().await?; + ctx.bank().fund_address(&funding_addr, funding).await?; + wait_for_balance(test_wallet, &funding_addr, funding, Duration::from_secs(60)).await?; + + let registered = test_wallet + .register_identity_from_addresses(funding_addr, funding, next_index) + .await?; + + // Keep wallet caches consistent — `register_from_addresses` + // doesn't refresh per-address balance/nonce on its own. + test_wallet.sync_balances().await?; + + setup.setup_guard.identities.push(registered); + Ok(setup + .setup_guard + .identities + .last() + .expect("just-pushed identity") + .clone_for_token_setup()) +} + +// --------------------------------------------------------------------------- +// 2-6. Typed read-side accessors +// --------------------------------------------------------------------------- + +/// Token balance for `identity_id` on `(contract_id, position)`. +pub async fn token_balance_of( + ctx: &E2eContext, + contract_id: Identifier, + position: TokenContractPosition, + identity_id: Identifier, +) -> FrameworkResult { + token_balance_raw(ctx.sdk(), identity_id, contract_id, position).await +} + +/// Total supply for `(contract_id, position)`. +pub async fn token_supply_of( + ctx: &E2eContext, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult { + token_supply_raw(ctx.sdk(), contract_id, position).await +} + +/// Paused flag for `(contract_id, position)`. +pub async fn token_is_paused_of( + ctx: &E2eContext, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult { + token_is_paused_raw(ctx.sdk(), contract_id, position).await +} + +/// Active pricing schedule for `(contract_id, position)`. +pub async fn token_pricing_of( + ctx: &E2eContext, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult> { + token_pricing_raw(ctx.sdk(), contract_id, position).await +} + +/// Frozen-balance accessor — returns the identity's full token +/// balance when `IdentityTokenInfo.frozen` is `true`, else `0`. +/// See module-level note on the bool-vs-balance framing. +pub async fn token_frozen_balance_of( + ctx: &E2eContext, + contract_id: Identifier, + position: TokenContractPosition, + identity_id: Identifier, +) -> FrameworkResult { + token_frozen_balance_of_raw(ctx.sdk(), identity_id, contract_id, position).await +} + +// --------------------------------------------------------------------------- +// 7-11. Raw-id variants (lower-level, accept (contract_id, position) as 32-byte ids) +// --------------------------------------------------------------------------- + +/// Lower-level [`token_balance_of`] — accepts the `Sdk` plus raw +/// identifiers so cross-contract reads don't need a fixture. +pub async fn token_balance_raw( + sdk: &Sdk, + identity_id: Identifier, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult { + let token_id = Identifier::from(calculate_token_id(contract_id.as_bytes(), position)); + + let query = IdentityTokenBalancesQuery { + identity_id, + token_ids: vec![token_id], + }; + + let balances: dash_sdk::platform::tokens::identity_token_balances::IdentityTokenBalances = + TokenAmount::fetch_many(sdk, query) + .await + .map_err(|err| FrameworkError::Sdk(format!("fetch token balance: {err}")))?; + + Ok(balances.0.get(&token_id).copied().flatten().unwrap_or(0)) +} + +/// Lower-level [`token_supply_of`]. +pub async fn token_supply_raw( + sdk: &Sdk, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult { + let token_id = Identifier::from(calculate_token_id(contract_id.as_bytes(), position)); + + let total = TotalSingleTokenBalance::fetch(sdk, token_id) + .await + .map_err(|err| FrameworkError::Sdk(format!("fetch token supply: {err}")))? + .ok_or_else(|| FrameworkError::Sdk(format!("token supply not found for {token_id}")))?; + + // SignedTokenAmount is i64; supplies are non-negative on a healthy + // chain. Clamp negatives to 0 so a corrupted state surfaces as a + // mismatched assertion instead of a panic. + Ok(total.token_supply.max(0) as TokenAmount) +} + +/// Lower-level [`token_is_paused_of`]. +pub async fn token_is_paused_raw( + sdk: &Sdk, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult { + use dpp::tokens::status::TokenStatus; + + let token_id = Identifier::from(calculate_token_id(contract_id.as_bytes(), position)); + + let statuses = TokenStatus::fetch_many(sdk, vec![token_id]) + .await + .map_err(|err| FrameworkError::Sdk(format!("fetch token status: {err}")))?; + + Ok(statuses + .get(&token_id) + .and_then(|s| s.as_ref()) + .map(|s| s.paused()) + .unwrap_or(false)) +} + +/// Lower-level [`token_pricing_of`]. +pub async fn token_pricing_raw( + sdk: &Sdk, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult> { + let token_id = Identifier::from(calculate_token_id(contract_id.as_bytes(), position)); + + let ids: Vec = vec![token_id]; + let prices: dash_sdk::query_types::TokenDirectPurchasePrices = + TokenPricingSchedule::fetch_many(sdk, ids.as_slice()) + .await + .map_err(|err| FrameworkError::Sdk(format!("fetch token pricing: {err}")))?; + + Ok(prices.get(&token_id).cloned().flatten()) +} + +/// Lower-level [`token_frozen_balance_of`]. +/// +/// First reads `IdentityTokenInfo` to learn whether the identity is +/// frozen for the given token; only when frozen does it issue the +/// follow-up balance fetch. Returns `0` for an unfrozen identity to +/// keep callers' arithmetic free of `Option` plumbing. +pub async fn token_frozen_balance_of_raw( + sdk: &Sdk, + identity_id: Identifier, + contract_id: Identifier, + position: TokenContractPosition, +) -> FrameworkResult { + use dpp::tokens::info::IdentityTokenInfo; + + let token_id = Identifier::from(calculate_token_id(contract_id.as_bytes(), position)); + + let infos: dash_sdk::query_types::token_info::IdentityTokenInfos = + IdentityTokenInfo::fetch_many( + sdk, + IdentityTokenInfosQuery { + identity_id, + token_ids: vec![token_id], + }, + ) + .await + .map_err(|err| FrameworkError::Sdk(format!("fetch token info: {err}")))?; + + let frozen = infos + .0 + .get(&token_id) + .and_then(|i: &Option| i.as_ref()) + .map(|i: &IdentityTokenInfo| i.frozen()) + .unwrap_or(false); + + if frozen { + token_balance_raw(sdk, identity_id, contract_id, position).await + } else { + Ok(0) + } +} + +// --------------------------------------------------------------------------- +// Helpers internal to this module. +// --------------------------------------------------------------------------- + +/// `RegisteredIdentity` is not `Clone` upstream (the +/// `SeedBackedIdentitySigner` is `Arc`-shared, so cloning the +/// owning struct is cheap if we wire it ourselves). The TK setup +/// helpers need to surface a copy of the owner / peer identity in +/// their return types while keeping the original inside +/// [`MultiIdentitySetupGuard::identities`] for teardown bookkeeping. +trait CloneForTokenSetup { + fn clone_for_token_setup(&self) -> Self; +} + +impl CloneForTokenSetup for RegisteredIdentity { + fn clone_for_token_setup(&self) -> Self { + RegisteredIdentity { + id: self.id, + master_key: self.master_key.clone(), + high_key: self.high_key.clone(), + signer: Arc::clone(&self.signer), + identity_index: self.identity_index, + funding: self.funding, + } + } +}