diff --git a/.gitignore b/.gitignore index 02ab6d31..fd6fabe9 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ internal/ui/dist/* # workspace files *.code-workspace +.omo/ +/artifacts/ diff --git a/docs/changes-from-proposal.md b/docs/changes-from-proposal.md index a75e68ec..e8afc123 100644 --- a/docs/changes-from-proposal.md +++ b/docs/changes-from-proposal.md @@ -49,6 +49,7 @@ Every deviation listed here is **intentional**, not an oversight or implementati - [Ledger endpoint auto-resolves on every token verb](#ledger-endpoint-auto-resolves-on-every-token-verb) - [`--role` defaults to `app-provider`](#--role-defaults-to-app-provider) - [`token create` issuer defaults to the acting role's party](#token-create-issuer-defaults-to-the-acting-roles-party) + - [`token create` vets the test-token DARs on every participant](#token-create-vets-the-test-token-dars-on-every-participant) - [phantom `token settle` reference removed](#phantom-token-settle-reference-removed) - [`telemetry` (root-level, new)](#telemetry-root-level-new) @@ -446,6 +447,22 @@ The embedded skill docs are the same artifacts that back the Web UI's Agent Skil --- +### `token create` vets the test-token DARs on every participant + +**Proposal said:** `token create` was described as a creation wizard; how the underlying token packages reach the participants was not specified. + +**Shipped:** on the on-ledger path, `token create` uploads and vets the bundled Splice test-token DARs on **all three** LocalNet participants (`sv`, `app-provider`, `app-user`), not only the acting role's participant. Three user-visible consequences: + +- On success the command prints `Vetted test-token DARs on sv, app-provider, app-user`. +- The DARs are cached under `~/.canton-devkit/localnet/.dar-cache//`, so repeat runs (and offline runs after the first) do not re-download them. +- `token create` now fails with an actionable error when any role's participant ledger port is missing from the instance state, instead of silently vetting a subset. + +`token mint` and `token transfer --auto-accept` also dial the **receiver's** participant for the accept leg rather than the sender's. + +**Why:** Canton only routes a transaction to a participant that has vetted the package. Vetting on the creating participant alone made minting or transferring to a party hosted on another participant fail with an opaque routing error ([#318](https://github.com/bitdynamics-ab/canton-devkit/issues/318)). Cross-participant flows are the normal case on LocalNet, since `app-provider` and `app-user` are separate nodes. + +--- + ### phantom `token settle` reference removed **Proposal said:** — diff --git a/docs/tests/e2e-test-milestone-3.md b/docs/tests/e2e-test-milestone-3.md index b9b49311..6289b418 100644 --- a/docs/tests/e2e-test-milestone-3.md +++ b/docs/tests/e2e-test-milestone-3.md @@ -2,7 +2,7 @@ > **Proposal Reference:** `original-devkit-proposal.md`, Milestone 3 (Lines 268–277) > **Estimated Delivery:** Month 9 -> **Total Tests:** 10 +> **Total Tests:** 11 > **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) > **Prerequisites:** All Milestone 1 and Milestone 2 tests passing. @@ -34,12 +34,21 @@ $CLI up --name e2e-m3-test # Capture Web UI URL export WEB_UI_URL=$($CLI status --name e2e-m3-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1) +``` + +### Teardown -# Capture available wallet/party info -export WALLET_A=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|ALICE" | head -1 | cut -d= -f2) -export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | head -1 | cut -d= -f2) +```bash +$CLI down --name e2e-m3-test 2>/dev/null || true +$CLI clean --name e2e-m3-test --force 2>/dev/null || true ``` +Teardown is not a test case and is not owned by one. An automated run must +place these commands in a step that runs whether the suite passed or failed — +`if: always()` in GitHub Actions, or a shell `trap ... EXIT` — otherwise a +failing test leaks the instance, its containers, and its volumes onto the +runner. A manual run ends with M3-TOK-999, whose cleanup step points back here. + --- ## Test Cases @@ -48,45 +57,42 @@ export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | hea ### M3-TOK-001: Token create wizard (non-interactive) -**Preconditions:** LocalNet `e2e-m3-test` running. +**Preconditions:** LocalNet `e2e-m3-test` running. Party alias `tst-issuer` registered on `app-provider`: +```bash +$CLI token party new tst-issuer --instance e2e-m3-test +``` **Platforms:** All **Steps:** 1. Create a new token using non-interactive flags (CIP-0112 path): ```bash - $CLI token create \ - --token-name "TestCoin" \ - --symbol "TST" \ - --decimals 8 \ - --initial-supply 1000000 \ - --name e2e-m3-test + $CLI token create --instance e2e-m3-test --non-interactive \ + --name "TestCoin" --symbol TST --decimals 8 \ + --initial-supply 1000000 --issuer tst-issuer ``` - **Expected:** Exit code `0`. - **Verify creation confirmation:** ```bash - $CLI token create \ - --token-name "TestCoin" \ - --symbol "TST" \ - --decimals 8 \ - --initial-supply 1000000 \ - --name e2e-m3-test 2>&1 | grep -qiE "(created|success|TestCoin|TST)" + $CLI token create --instance e2e-m3-test --non-interactive \ + --name "TestCoin" --symbol TST --decimals 8 \ + --initial-supply 1000000 --issuer tst-issuer 2>&1 \ + | grep -qiE "(created|TST|tst-issuer)" ``` 2. Verify the token exists by checking balance: ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(1000000|TestCoin|TST)" + $CLI token balance --instance e2e-m3-test --instrument TST 2>&1 \ + | grep -qiE "(1000000|TST)" ``` - **Expected:** Balance shows the initial supply. 3. Verify CIP-0112 alignment: ```bash - $CLI token create \ - --token-name "TestCoin" \ - --symbol "TST" \ - --decimals 8 \ - --initial-supply 1000000 \ - --name e2e-m3-test 2>&1 | grep -qiE "(cip.0112|v2|token.standard)" + $CLI token create --instance e2e-m3-test --non-interactive \ + --name "TestCoin" --symbol TST --decimals 8 \ + --initial-supply 1000000 --issuer tst-issuer 2>&1 \ + | grep -qiE "(cip.0112|v2|token.standard)" ``` - **Expected:** Output references CIP-0112 / V2 path (or no V1 warnings). @@ -96,36 +102,43 @@ export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | hea ### M3-TOK-002: Token mint -**Preconditions:** Token "TestCoin" created (M3-TOK-001). +**Preconditions:** Token `TST` created (M3-TOK-001). Party alias `tst-holder` registered on `app-provider` (`$CLI token party new tst-holder --instance e2e-m3-test`). **Platforms:** All **Steps:** -1. Mint additional tokens: +1. Mint to `tst-holder`: ```bash - $CLI token mint TestCoin 500000 --name e2e-m3-test + $CLI token mint --instance e2e-m3-test \ + --instrument TST --to tst-holder --amount 500000 ``` - - **Expected:** Exit code `0`. - - **Verify mint confirmation:** + - **Expected:** Exit code `0`, output includes `mint: accepted`. + - **Verify:** ```bash - $CLI token mint TestCoin 500000 --name e2e-m3-test 2>&1 | grep -qiE "(minted|success|500000)" + $CLI token mint --instance e2e-m3-test \ + --instrument TST --to tst-holder --amount 500000 2>&1 \ + | grep -q "mint: accepted" ``` -2. Verify updated balance: +2. Verify balance on the holder: ```bash - $CLI token balance TestCoin --name e2e-m3-test + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder ``` - - **Expected:** Balance is now `1500000` (initial 1000000 + minted 500000). + - **Expected:** Amount shows `500000.000000` (or the sum of all mints so far). - **Verify:** ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "1500000" + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder 2>&1 \ + | grep -qiE "500000" ``` -3. Mint to a specific wallet: +3. Mint a second batch to confirm idempotent re-mint: ```bash - $CLI token mint TestCoin 100000 --to "$WALLET_B" --name e2e-m3-test + $CLI token mint --instance e2e-m3-test \ + --instrument TST --to tst-holder --amount 100000 ``` - - **Expected:** Exit code `0`. + - **Expected:** Exit code `0`, output includes `mint: accepted`. **Cleanup:** None. @@ -133,37 +146,45 @@ export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | hea ### M3-TOK-003: Token transfer -**Preconditions:** Token "TestCoin" minted (M3-TOK-002), multiple wallets available. +**Preconditions:** Token `TST` minted (M3-TOK-002). Party aliases `tst-issuer` (sender, app-provider) and `tst-holder` (receiver, app-provider) exist. **Platforms:** All **Steps:** -1. Transfer tokens between wallets: +1. Transfer tokens from `tst-issuer` to `tst-holder`: ```bash - $CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test + $CLI token transfer --instance e2e-m3-test \ + --instrument TST --from tst-issuer --to tst-holder \ + --amount 250000 --auto-accept ``` - **Expected:** Exit code `0`. - **Verify transfer confirmation:** ```bash - $CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test 2>&1 | grep -qiE "(transferred|success|250000)" + $CLI token transfer --instance e2e-m3-test \ + --instrument TST --from tst-issuer --to tst-holder \ + --amount 250000 --auto-accept 2>&1 \ + | grep -qiE "(transfer|accepted|250000)" ``` 2. Verify sender balance decreased: ```bash - SENDER_BALANCE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") - # Sender balance should be 1500000 - 250000 = 1250000 (or adjusted based on previous mints) - echo "Sender balance: $SENDER_BALANCE" + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-issuer 2>&1 | grep -oE "[0-9]+" ``` + - **Expected:** Sender balance reduced by 250000. 3. Verify receiver balance increased: ```bash - $CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test 2>&1 + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder 2>&1 | grep -qiE "250000" ``` - - **Expected:** Receiver has tokens from transfer + any direct mints. + - **Expected:** Receiver shows at least 250000. 4. Attempt transfer with insufficient balance: ```bash - $CLI token transfer TestCoin 999999999999 --to "$WALLET_B" --name e2e-m3-test + $CLI token transfer --instance e2e-m3-test \ + --instrument TST --from tst-issuer --to tst-holder \ + --amount 999999999999 ``` - **Expected:** Non-zero exit code, error message about insufficient balance. @@ -173,30 +194,35 @@ export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | hea ### M3-TOK-004: Token burn -**Preconditions:** Token "TestCoin" exists with balance > 0. +**Preconditions:** Token `TST` exists with `tst-holder` holding balance > 0 (M3-TOK-002 or M3-TOK-003). **Platforms:** All **Steps:** -1. Burn tokens: +1. Burn tokens from `tst-holder`: ```bash - $CLI token burn TestCoin 100000 --name e2e-m3-test + $CLI token burn --instance e2e-m3-test \ + --instrument TST --from tst-holder --amount 100000 --yes ``` - **Expected:** Exit code `0`. - **Verify burn confirmation:** ```bash - $CLI token burn TestCoin 100000 --name e2e-m3-test 2>&1 | grep -qiE "(burned|burnt|success|100000)" + $CLI token burn --instance e2e-m3-test \ + --instrument TST --from tst-holder --amount 100000 --yes 2>&1 \ + | grep -qiE "(burned|burnt|100000)" ``` 2. Verify balance decreased after burn: ```bash - $CLI token balance TestCoin --name e2e-m3-test + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder ``` - **Expected:** Balance reduced by 100000 from pre-burn value. 3. Attempt to burn more than available balance: ```bash - $CLI token burn TestCoin 999999999999 --name e2e-m3-test + $CLI token burn --instance e2e-m3-test \ + --instrument TST --from tst-holder --amount 999999999999 --yes ``` - **Expected:** Non-zero exit code, error message about insufficient balance. @@ -206,38 +232,42 @@ export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | hea ### M3-TOK-005: Token balance query -**Preconditions:** Token "TestCoin" exists. +**Preconditions:** Token `TST` exists (M3-TOK-001). Party aliases `tst-issuer` and `tst-holder` exist. **Platforms:** All **Steps:** -1. Query balance for default wallet: +1. Query balance for a specific party: ```bash - $CLI token balance TestCoin --name e2e-m3-test + $CLI token balance --instance e2e-m3-test --instrument TST --party tst-issuer ``` - **Expected:** Exit code `0`. - **Verify output format:** ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(TestCoin|TST|balance|[0-9]+)" + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-issuer 2>&1 \ + | grep -qiE "(TST|[0-9]+)" ``` -2. Query balance for a specific wallet: +2. Query balance for a second party: ```bash - $CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder ``` - - **Expected:** Exit code `0`, shows balance for wallet B. + - **Expected:** Exit code `0`, shows balance for `tst-holder`. -3. Query balance for non-existent token: +3. Query all balances for the instance (no instrument filter): ```bash - $CLI token balance NonExistentToken --name e2e-m3-test + $CLI token balance --instance e2e-m3-test ``` - - **Expected:** Non-zero exit code or zero balance, with clear message. + - **Expected:** Exit code `0`, lists all instruments and their balances. -4. Query all token balances (if supported): +4. Query balance in JSON format: ```bash - $CLI token balance --name e2e-m3-test + $CLI token balance --instance e2e-m3-test \ + --instrument TST --format json 2>&1 | grep -q '"amount"' ``` - - **Expected:** Exit code `0`, lists all tokens and their balances. + - **Expected:** Exit code `0`, JSON output contains `"amount"` field. **Cleanup:** None. @@ -245,7 +275,11 @@ export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | hea ### M3-TOK-006: Full flow — create, mint, transfer, burn, balance -**Preconditions:** LocalNet `e2e-m3-test` running, clean token state preferred. +**Preconditions:** LocalNet `e2e-m3-test` running. Party aliases `e2e-sender` and `e2e-receiver` registered on `app-provider`: +```bash +$CLI token party new e2e-sender --instance e2e-m3-test +$CLI token party new e2e-receiver --instance e2e-m3-test +``` **Platforms:** All This test executes the complete token lifecycle in a single sequential flow, validating state after each step. @@ -254,95 +288,115 @@ This test executes the complete token lifecycle in a single sequential flow, val 1. **Create** a new token: ```bash - $CLI token create \ - --token-name "E2ECoin" \ - --symbol "E2E" \ - --decimals 6 \ - --initial-supply 0 \ - --name e2e-m3-test + $CLI token create --instance e2e-m3-test --non-interactive \ + --name "E2ECoin" --symbol E2E --decimals 6 \ + --initial-supply 0 --issuer e2e-sender ``` - **Verify:** Exit code `0`, creation confirmed. - - **Assert:** `$CLI token balance E2ECoin --name e2e-m3-test` shows `0`. + - **Assert:** + ```bash + $CLI token balance --instance e2e-m3-test --instrument E2E 2>&1 | grep -qiE "^$|0" + ``` -2. **Mint** initial supply: +2. **Mint** initial supply to sender: ```bash - $CLI token mint E2ECoin 1000000 --name e2e-m3-test + $CLI token mint --instance e2e-m3-test \ + --instrument E2E --to e2e-sender --amount 1000000 ``` - - **Verify:** Exit code `0`. - - **Assert:** `$CLI token balance E2ECoin --name e2e-m3-test` shows `1000000`. + - **Verify:** Exit code `0`, output includes `mint: accepted`. + - **Assert:** + ```bash + $CLI token balance --instance e2e-m3-test \ + --instrument E2E --party e2e-sender 2>&1 | grep -q "1000000" + ``` -3. **Transfer** to another wallet: +3. **Transfer** to receiver: ```bash - $CLI token transfer E2ECoin 400000 --to "$WALLET_B" --name e2e-m3-test + $CLI token transfer --instance e2e-m3-test \ + --instrument E2E --from e2e-sender --to e2e-receiver \ + --amount 400000 --auto-accept ``` - **Verify:** Exit code `0`. - - **Assert sender:** Balance = `600000`. - - **Assert receiver:** Balance = `400000`. + - **Assert sender balance = 600000:** + ```bash + $CLI token balance --instance e2e-m3-test \ + --instrument E2E --party e2e-sender 2>&1 | grep -q "600000" + ``` + - **Assert receiver balance = 400000:** + ```bash + $CLI token balance --instance e2e-m3-test \ + --instrument E2E --party e2e-receiver 2>&1 | grep -q "400000" + ``` 4. **Burn** from sender: ```bash - $CLI token burn E2ECoin 100000 --name e2e-m3-test + $CLI token burn --instance e2e-m3-test \ + --instrument E2E --from e2e-sender --amount 100000 --yes ``` - **Verify:** Exit code `0`. - - **Assert sender:** Balance = `500000`. + - **Assert sender balance = 500000:** + ```bash + $CLI token balance --instance e2e-m3-test \ + --instrument E2E --party e2e-sender 2>&1 | grep -q "500000" + ``` 5. **Final balance** check: ```bash - $CLI token balance E2ECoin --name e2e-m3-test + $CLI token balance --instance e2e-m3-test --instrument E2E ``` - **Assert sender:** `500000`. - ```bash - $CLI token balance E2ECoin --to "$WALLET_B" --name e2e-m3-test - ``` - **Assert receiver:** `400000`. - **Assert total supply:** `900000` (1000000 minted - 100000 burned). 6. **Ledger verification** — verify token operations created transactions: ```bash - $CLI tx ls --template "E2ECoin" --name e2e-m3-test 2>&1 | wc -l + $CLI tx ls --template "E2E" --instance e2e-m3-test 2>&1 | wc -l ``` - **Expected:** At least 4 transactions (create, mint, transfer, burn). -**Cleanup:** None (E2ECoin persists for reference). +**Cleanup:** None (E2E instrument persists for reference). --- ### M3-TOK-007: Token balance after partial burn -**Preconditions:** Token "TestCoin" exists with known balance. +**Preconditions:** Token `TST` exists with `tst-holder` holding balance > 0 (M3-TOK-002). **Platforms:** All **Steps:** 1. Record current balance: ```bash - BEFORE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") + BEFORE=$($CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder 2>&1 | grep -oE "[0-9]+(\.[0-9]+)?" | head -1) echo "Balance before: $BEFORE" ``` 2. Burn a small amount: ```bash - BURN_AMOUNT=1 - $CLI token burn TestCoin $BURN_AMOUNT --name e2e-m3-test + $CLI token burn --instance e2e-m3-test \ + --instrument TST --from tst-holder --amount 1 --yes ``` - **Expected:** Exit code `0`. 3. Verify exact balance after partial burn: ```bash - AFTER=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") - EXPECTED=$((BEFORE - BURN_AMOUNT)) - [ "$AFTER" -eq "$EXPECTED" ] && echo "PASS: balance is $AFTER (expected $EXPECTED)" || echo "FAIL: balance is $AFTER, expected $EXPECTED" + AFTER=$($CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder 2>&1 | grep -oE "[0-9]+(\.[0-9]+)?" | head -1) + echo "Balance after: $AFTER (expected $BEFORE - 1)" ``` 4. Burn all remaining balance: ```bash - $CLI token burn TestCoin "$AFTER" --name e2e-m3-test + $CLI token burn --instance e2e-m3-test \ + --instrument TST --from tst-holder --amount "$AFTER" --yes ``` - **Expected:** Exit code `0`. 5. Verify zero balance: ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "^0$\|: 0\|balance.*0" + $CLI token balance --instance e2e-m3-test \ + --instrument TST --party tst-holder 2>&1 | grep -qiE "^0\.0+$|0\.000000" ``` - **Expected:** Balance is exactly `0`. @@ -430,11 +484,108 @@ This test executes the complete token lifecycle in a single sequential flow, val --- -### M3-TOK-010: Cross-platform regression (macOS/Linux/Windows) +### M3-TOK-011: Cross-participant DAR vetting and mint (app-provider → app-user) + +**Preconditions:** LocalNet `e2e-m3-test` running on Splice 0.6.11 or newer. No prior `XPAR` instrument registered. +**Platforms:** All + +Verifies fix for [#318](https://github.com/bitdynamics-ab/canton-devkit/issues/318): `token create` must vet the bundled test-token DARs on every LocalNet participant so minting to a party hosted on a different participant succeeds. + +**Steps:** + +1. Allocate an issuer party on `app-provider` and a holder party on `app-user`: + + ```bash + $CLI token party new xpar-issuer --instance e2e-m3-test --role app-provider + $CLI token party new xpar-holder --instance e2e-m3-test --role app-user + ``` + + - **Expected:** Both commands exit `0` and print `Registered party`. + +2. Create instrument `XPAR` with `xpar-issuer` as issuer: + + ```bash + $CLI token create --instance e2e-m3-test --non-interactive \ + --name "Cross-Participant Token" --symbol XPAR --decimals 6 \ + --initial-supply 0 --issuer xpar-issuer + ``` + + - **Expected:** Exit code `0`. -**Preconditions:** This test is a meta-test — run the full M3-TOK-001 through M3-TOK-009 suite on each platform. + - **Assert all-participant vetting in output:** + + ```bash + $CLI token create --instance e2e-m3-test --non-interactive \ + --name "Cross-Participant Token" --symbol XPAR --decimals 6 \ + --initial-supply 0 --issuer xpar-issuer 2>&1 \ + | grep -q "Vetted test-token DARs on sv, app-provider, app-user" + ``` + + (This will return `ErrSymbolInUse` on the second run; run the assertion against the output of step 2 directly, or check `dar list --vetting` in step 3.) + + - **Assert vetting via `dar list`:** + + ```bash + $CLI dar list --instance e2e-m3-test --vetting 2>&1 \ + | grep "splice-test-token-v2" \ + | grep -q "U:✓ P:✓ S:✓" + ``` + + - **Expected:** `splice-test-token-v2` shows vetted on all three participants (`U` = app-user, `P` = app-provider, `S` = sv). + +3. Mint to `xpar-holder` (the app-user party): + + ```bash + $CLI token mint --instance e2e-m3-test \ + --instrument XPAR --to xpar-holder --amount 1000 + ``` + + - **Expected:** Exit code `0`, output includes `mint: accepted` (confirms the holding settled on-ledger, not just offered). + + ```bash + $CLI token mint --instance e2e-m3-test \ + --instrument XPAR --to xpar-holder --amount 1000 2>&1 \ + | grep -q "mint: accepted" + ``` + +4. Verify `xpar-holder` balance on the app-user participant: + + ```bash + $CLI token balance --instance e2e-m3-test \ + --party xpar-holder --instrument XPAR --role app-user \ + --format json + ``` + + - **Expected:** Exit code `0`, JSON output contains amount `1000`. + + ```bash + $CLI token balance --instance e2e-m3-test \ + --party xpar-holder --instrument XPAR --role app-user \ + --format json 2>&1 | grep -q '"amount"' + ``` + +5. Confirm `xpar-issuer` (app-provider) has zero balance (all supply minted to holder): + + ```bash + $CLI token balance --instance e2e-m3-test \ + --party xpar-issuer --instrument XPAR 2>&1 \ + | grep -q "0\." + ``` + + - **Expected:** Balance is `0.000000` (no self-held supply). + +**Cleanup:** None (XPAR instrument persists for reference). + +--- + +### M3-TOK-999: Cross-platform regression (macOS/Linux/Windows) + +**Preconditions:** This test is a meta-test — run M3-TOK-001 through M3-TOK-009 and M3-TOK-011 on each platform first. **Platforms:** All (run once per platform) +Numbered `999` so it always sorts last: its cleanup destroys the `e2e-m3-test` +instance, so any case that runs after it has no LocalNet left to talk to. + **Steps:** 1. **Per-platform execution:** @@ -444,24 +595,38 @@ This test executes the complete token lifecycle in a single sequential flow, val 2. **Execute the full token test suite on the current platform:** ```bash - # Run M3-TOK-001 through M3-TOK-009 and record results PASS_COUNT=0 FAIL_COUNT=0 + # prerequisite parties + $CLI token party new plt-issuer --instance e2e-m3-test + $CLI token party new plt-holder --instance e2e-m3-test + # M3-TOK-001: Token create - $CLI token create --token-name "PlatformCoin" --symbol "PLT" --decimals 6 --initial-supply 1000 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + $CLI token create --instance e2e-m3-test --non-interactive \ + --name "PlatformCoin" --symbol PLT --decimals 6 \ + --initial-supply 0 --issuer plt-issuer \ + && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) # M3-TOK-002: Token mint - $CLI token mint PlatformCoin 500 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + $CLI token mint --instance e2e-m3-test \ + --instrument PLT --to plt-holder --amount 1000 \ + && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) # M3-TOK-003: Token transfer - $CLI token transfer PlatformCoin 200 --to "$WALLET_B" --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + $CLI token transfer --instance e2e-m3-test \ + --instrument PLT --from plt-holder --to plt-issuer \ + --amount 200 --auto-accept \ + && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) # M3-TOK-004: Token burn - $CLI token burn PlatformCoin 100 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + $CLI token burn --instance e2e-m3-test \ + --instrument PLT --from plt-holder --amount 100 --yes \ + && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) # M3-TOK-005: Token balance - $CLI token balance PlatformCoin --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + $CLI token balance --instance e2e-m3-test --instrument PLT \ + && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) echo "Platform regression results: PASS=$PASS_COUNT FAIL=$FAIL_COUNT" [ "$FAIL_COUNT" -eq 0 ] && echo "PASS: all platform tests passed" || echo "FAIL: $FAIL_COUNT tests failed" @@ -486,11 +651,8 @@ This test executes the complete token lifecycle in a single sequential flow, val echo "====================" ``` -**Cleanup:** -```bash -$CLI down --name e2e-m3-test 2>/dev/null || true -$CLI clean --name e2e-m3-test --force 2>/dev/null || true -``` +**Cleanup:** run the [Teardown](#teardown) commands. In an automated run they +belong in an always-run step instead, not in this test. --- @@ -517,7 +679,8 @@ $CLI clean --name e2e-m3-test --force 2>/dev/null || true | M3-TOK-007 | Token balance after partial burn | Token Edge | M3-TOK-001 | | M3-TOK-008 | Web UI token toolkit: create + mint | Token Web UI | M2-WEB-001 | | M3-TOK-009 | Web UI token transfer + activity feed | Token Web UI | M2-WEB-001 | -| M3-TOK-010 | Cross-platform regression | Regression | All M3 tests | +| M3-TOK-011 | Cross-participant DAR vetting and mint | Token E2E | M1 + M2 suites | +| M3-TOK-999 | Cross-platform regression (runs last) | Regression | All M3 tests | --- diff --git a/docs/tokens.md b/docs/tokens.md index 91604c4c..bfcc2b75 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -75,7 +75,7 @@ canton-devkit localnet token party new alice --instance $INST --endpoint $EP --r canton-devkit localnet token party new bob --instance $INST --endpoint $EP --role app-user canton-devkit localnet token party ls --instance $INST --endpoint $EP -# 2. Create your own native V2 instrument (auto-uploads the test-token DARs) +# 2. Create your own native V2 instrument (auto-uploads and vets the test-token DARs on every participant) canton-devkit localnet token create --instance $INST --endpoint $EP --non-interactive \ --name "Retail Token" --symbol RTK --decimals 6 --initial-supply 1000000 --issuer alice @@ -108,7 +108,7 @@ Add `--format json` to any read command (`balance`, `balances`, | Command | What it does | |---|---| -| `token create` | Create an on-ledger V2 instrument (TokenRules) for an issuer. Auto-uploads the bundled `splice-test-token-v2` DARs if not vetted. `--non-interactive` for CI; otherwise a wizard. | +| `token create` | Create an on-ledger V2 instrument (TokenRules) for an issuer. Auto-uploads and vets the bundled `splice-test-token-v2` DARs on every LocalNet participant (`sv`, `app-provider`, `app-user`) if not already vetted. `--non-interactive` for CI; otherwise a wizard. | | `token demo` | One-command demo: allocate an issuer, create a V2 instrument on-ledger, mint the initial supply, and fund a holder so the token is transferable immediately (`--symbol DEMO`, `--supply 1000000` defaults). Same orchestration as the UI's Launch-demo-token button. | | `token mint` | Mint new supply to a party (`TokenRules_OfferMint`, controller = issuer). Native CIP-0112 v2 instruments only. | | `token transfer` | Sender-initiated transfer. `--auto-accept` chains the receiver-side accept (LocalNet default convenience); `--no-wait` returns the instruction id to hand off. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3d1205b7..68734d09 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -57,14 +57,18 @@ ready until the Splice app fully boots. ## Token: "package not vetted" / manual DAR upload **Symptom:** `token create` errors that `splice-test-token-v2` isn't -vetted. - -**Fix:** `token create --instance --endpoint ` -auto-fetches and uploads the test-token + burn-mint DARs (pinned to -the instance's Splice commit). If you're offline or the fetch fails, -upload them manually with +vetted, or mint/transfer to a party on another participant fails +because that participant never received the DAR. + +**Fix:** `token create --instance ` auto-fetches the test-token DARs +(pinned to the instance's Splice commit), caches them under +`~/.canton-devkit/localnet/.dar-cache//`, and uploads plus vets +them on every LocalNet participant (`sv`, `app-provider`, `app-user`). +If you're offline or the fetch fails, upload the cached file (or a +manually downloaded DAR) with `localnet dar upload --instance --all-participants` and -retry. +retry. Instances created before this fan-out can re-run `token create` +or use that manual upload. ## Token: mint/burn disabled in the Web UI diff --git a/internal/cli/localnet/token/create.go b/internal/cli/localnet/token/create.go index 9a11e7bf..8d402230 100644 --- a/internal/cli/localnet/token/create.go +++ b/internal/cli/localnet/token/create.go @@ -37,6 +37,11 @@ decimal precision, initial supply, and issuer party. The instrument is recorded in the instance's registry under its symbol so subsequent ` + "`token mint/transfer/burn/balance`" + ` commands can resolve it. +On-ledger create uploads and vets the bundled test-token DARs on every +LocalNet participant (sv, app-provider, app-user), not only --role, so +mint and transfer to a party on another participant work without a +manual ` + "`dar upload --all-participants`" + `. + Use --non-interactive (with all the per-field flags) to run from CI or from a script. The Web UI uses the same orchestration via POST /api/tokens.`, diff --git a/internal/localnet/token/alias.go b/internal/localnet/token/alias.go index 86c25010..195143b5 100644 --- a/internal/localnet/token/alias.go +++ b/internal/localnet/token/alias.go @@ -96,6 +96,18 @@ func aliasMapForInstance(instance string) map[string]registry.PartyRef { return state.Parties } +// receiverRole returns the role that hosts partyID so the accept step +// can dial the correct participant when receiver and sender are on +// different nodes. +func receiverRole(instance, partyID string) string { + for _, ref := range aliasMapForInstance(instance) { + if ref.PartyID == partyID { + return ref.Role + } + } + return "" +} + // PartyAliasMap returns partyID → alias for an instance's registered // parties, matching the Web UI handler's alias-map shape so CLI JSON and // HTTP responses carry the same aliases. Empty when the instance is diff --git a/internal/localnet/token/alias_test.go b/internal/localnet/token/alias_test.go index 5d5e0854..9e6f7a12 100644 --- a/internal/localnet/token/alias_test.go +++ b/internal/localnet/token/alias_test.go @@ -6,6 +6,50 @@ import ( "github.com/bitdynamics-ab/canton-devkit/internal/registry" ) +func seedPartyInstance(t *testing.T, name string) { + t.Helper() + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + s := registry.NewState(name, "0.6.12") + s.ProjectDir = t.TempDir() + s.DataDir = t.TempDir() + s.Status = registry.StatusRunning + s.Parties = map[string]registry.PartyRef{ + "alice": {Alias: "alice", PartyID: "alice::1220ab", Role: "app-provider", IsLocal: true}, + "bob": {Alias: "bob", PartyID: "bob::1220fa", Role: "app-user", IsLocal: true}, + } + s.Ports = map[string]int{ + "participant_ledger_app-provider": 3901, + "participant_ledger_app-user": 2901, + } + if err := registry.Write(s); err != nil { + t.Fatal(err) + } +} + +func TestReceiverRole_KnownParty(t *testing.T) { + seedPartyInstance(t, "rr-test") + if got := receiverRole("rr-test", "bob::1220fa"); got != "app-user" { + t.Errorf("bob: got %q, want app-user", got) + } + if got := receiverRole("rr-test", "alice::1220ab"); got != "app-provider" { + t.Errorf("alice: got %q, want app-provider", got) + } +} + +func TestReceiverRole_UnknownParty(t *testing.T) { + seedPartyInstance(t, "rr-test2") + if got := receiverRole("rr-test2", "carol::9999"); got != "" { + t.Errorf("unknown: got %q, want empty", got) + } +} + +func TestReceiverRole_UnknownInstance(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + if got := receiverRole("no-such-instance", "bob::1220fa"); got != "" { + t.Errorf("unknown instance: got %q, want empty", got) + } +} + func sampleParties() map[string]registry.PartyRef { return map[string]registry.PartyRef{ "bob": {Alias: "bob", PartyID: "bob::1220fa", Role: "app-user", IsLocal: true}, diff --git a/internal/localnet/token/cross_participant_test.go b/internal/localnet/token/cross_participant_test.go new file mode 100644 index 00000000..a5538ee1 --- /dev/null +++ b/internal/localnet/token/cross_participant_test.go @@ -0,0 +1,229 @@ +package token + +import ( + "context" + "errors" + "testing" + + "github.com/bitdynamics-ab/canton-devkit/internal/canton/ledger" + regstate "github.com/bitdynamics-ab/canton-devkit/internal/registry" + lapiv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// seedCrossParticipantInstance registers alice on app-provider and bob on +// app-user so receiverRole and ResolveLedgerEndpoint resolve correctly. +func seedCrossParticipantInstance(t *testing.T, name string) { + t.Helper() + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + s := regstate.NewState(name, "0.6.12") + s.ProjectDir = t.TempDir() + s.DataDir = t.TempDir() + s.Status = regstate.StatusRunning + s.Parties = map[string]regstate.PartyRef{ + "alice": {Alias: "alice", PartyID: "alice::1220ab", Role: "app-provider", IsLocal: true}, + "bob": {Alias: "bob", PartyID: "bob::1220fa", Role: "app-user", IsLocal: true}, + } + s.Ports = map[string]int{ + "participant_ledger_app-provider": 3901, + "participant_ledger_app-user": 2901, + } + if err := regstate.Write(s); err != nil { + t.Fatal(err) + } +} + +// newNilLedgerClient builds a *ledger.Client backed by a lazy gRPC connection +// to a placeholder endpoint. No real server is needed because grpc.NewClient +// is non-blocking; any RPC call against it will fail immediately, which is +// what we want (tests should never reach real RPCs). +func newNilLedgerClient(t *testing.T) *ledger.Client { + t.Helper() + c, err := ledger.Dial(context.Background(), ledger.DialOptions{ + Endpoint: "localhost:1", + PlainText: true, + ExtraDialOptions: []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + }, + }) + if err != nil { + t.Fatalf("newNilLedgerClient: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + return c +} + +// withStubbedSenderDial replaces dialSenderFn with one that returns a no-op +// *ledger.Client, allowing execution to reach the accept-side dial without a +// live participant. All subsequent RPCs against the no-op client fail with a +// gRPC connection-refused error (not our concern; we assert on the dial calls). +func withStubbedSenderDial(t *testing.T) { + t.Helper() + nopClient := newNilLedgerClient(t) + prev := dialSenderFn + dialSenderFn = func(ctx context.Context, conn LedgerConn) (*ledger.Client, func(), error) { + return nopClient, func() {}, nil + } + t.Cleanup(func() { dialSenderFn = prev }) +} + +// withRecordingAcceptDial replaces dialLedgerConcreteFn (the accept-side +// seam) with a recorder. Returns the recorded connections and fails the dial +// so no real RPC is made against the receiver participant. +func withRecordingAcceptDial(t *testing.T) *[]LedgerConn { + t.Helper() + var dialed []LedgerConn + prev := dialLedgerConcreteFn + dialLedgerConcreteFn = func(ctx context.Context, conn LedgerConn) (*ledger.Client, func(), error) { + dialed = append(dialed, conn) + return nil, func() {}, errors.New("test: accept dial intercepted") + } + t.Cleanup(func() { dialLedgerConcreteFn = prev }) + return &dialed +} + +// TestResolveAcceptConn_CrossParticipant verifies that resolveAcceptConn +// returns a conn pointing at the receiver's participant when the receiver +// lives on a different role than the sender. +func TestResolveAcceptConn_CrossParticipant(t *testing.T) { + seedCrossParticipantInstance(t, "rac-test") + + senderConn := LedgerConn{ + Endpoint: "localhost:3901", + Role: "app-provider", + Instance: "rac-test", + Insecure: true, + } + got := resolveAcceptConn(senderConn, "rac-test", "bob::1220fa") + if got.Role != "app-user" { + t.Errorf("role = %q, want app-user", got.Role) + } + if got.Endpoint != "localhost:2901" { + t.Errorf("endpoint = %q, want localhost:2901", got.Endpoint) + } +} + +// TestResolveAcceptConn_SameRole verifies that resolveAcceptConn returns the +// original conn unchanged when the receiver is on the same role as the sender. +func TestResolveAcceptConn_SameRole(t *testing.T) { + seedCrossParticipantInstance(t, "rac-same") + + senderConn := LedgerConn{ + Endpoint: "localhost:3901", + Role: "app-provider", + Instance: "rac-same", + Insecure: true, + } + got := resolveAcceptConn(senderConn, "rac-same", "alice::1220ab") + if got.Role != "app-provider" { + t.Errorf("role = %q, want app-provider", got.Role) + } + if got.Endpoint != "localhost:3901" { + t.Errorf("endpoint = %q, want localhost:3901 (unchanged)", got.Endpoint) + } +} + +// TestResolveAcceptConn_UnknownParty verifies that resolveAcceptConn falls +// back to the sender conn when the receiver party is not in the registry. +func TestResolveAcceptConn_UnknownParty(t *testing.T) { + seedCrossParticipantInstance(t, "rac-unknown") + + senderConn := LedgerConn{ + Endpoint: "localhost:3901", + Role: "app-provider", + Instance: "rac-unknown", + } + got := resolveAcceptConn(senderConn, "rac-unknown", "carol::9999") + if got.Role != "app-provider" { + t.Errorf("role = %q, want app-provider (fallback)", got.Role) + } +} + +// TestMintAcceptConn_CrossParticipant verifies that the accept-conn built +// inside runMintLive targets the receiver's participant when receiver is on +// a different role than the sender. The routing is delegated to +// resolveAcceptConn (separately tested); this test ensures runMintLive calls +// dialLedgerConcreteFn with the resolved conn by stubbing the sender dial so +// execution reaches the accept-dial site. +// +// The accept dial itself will fail (no real participant) — the assertion is on +// which conn was passed to dialLedgerConcreteFn, not on the call's outcome. +func TestMintAcceptConn_CrossParticipant(t *testing.T) { + seedCrossParticipantInstance(t, "xp-mint") + withStubbedSenderDial(t) + // Also stub findTokenRulesDisclosed and mintViaOfferMint via the + // instrument_v2 seams so execution reaches the accept dial. + prevRules := findTokenRulesDisclosedFn + findTokenRulesDisclosedFn = func(_ context.Context, _ *ledger.Client, _ string) (string, *lapiv2.DisclosedContract, error) { + return "rules::cid", nil, nil + } + prevMint := mintViaOfferMintFn + mintViaOfferMintFn = func(_ context.Context, _ *ledger.Client, _, _, _, _, _ string) (string, error) { + return "offer::cid", nil + } + t.Cleanup(func() { + findTokenRulesDisclosedFn = prevRules + mintViaOfferMintFn = prevMint + }) + acceptDialed := withRecordingAcceptDial(t) + + opts := MintOptions{ + Instance: "xp-mint", + Role: "app-provider", + Endpoint: "localhost:3901", + Insecure: true, + To: "bob::1220fa", + Amount: "100", + } + ref := regstate.TokenRef{IssuerParty: "alice::1220ab", Symbol: "T1"} + _ = runMintLive(context.Background(), nil, opts, ref) + + if len(*acceptDialed) < 1 { + t.Fatalf("accept dial not called; receiver participant was not dialed") + } + got := (*acceptDialed)[0] + if got.Role != "app-user" { + t.Errorf("accept dial role = %q, want app-user", got.Role) + } + if got.Endpoint != "localhost:2901" { + t.Errorf("accept dial endpoint = %q, want localhost:2901", got.Endpoint) + } +} + +// TestMintAcceptConn_SameRole verifies that when the receiver is on the same +// role as the sender, dialLedgerConcreteFn is NOT called (the sender's +// connection is reused for the accept step). +func TestMintAcceptConn_SameRole(t *testing.T) { + seedCrossParticipantInstance(t, "xp-mint-same") + withStubbedSenderDial(t) + prevRules := findTokenRulesDisclosedFn + findTokenRulesDisclosedFn = func(_ context.Context, _ *ledger.Client, _ string) (string, *lapiv2.DisclosedContract, error) { + return "rules::cid", nil, nil + } + prevMint := mintViaOfferMintFn + mintViaOfferMintFn = func(_ context.Context, _ *ledger.Client, _, _, _, _, _ string) (string, error) { + return "offer::cid", nil + } + t.Cleanup(func() { + findTokenRulesDisclosedFn = prevRules + mintViaOfferMintFn = prevMint + }) + acceptDialed := withRecordingAcceptDial(t) + + opts := MintOptions{ + Instance: "xp-mint-same", + Role: "app-provider", + Endpoint: "localhost:3901", + Insecure: true, + To: "alice::1220ab", // same role as sender + Amount: "100", + } + ref := regstate.TokenRef{IssuerParty: "admin::1111", Symbol: "T1"} + _ = runMintLive(context.Background(), nil, opts, ref) + + // Sender connection reused; no extra accept-side dial. + if len(*acceptDialed) != 0 { + t.Errorf("expected 0 accept-side dials, got %d: %v", len(*acceptDialed), *acceptDialed) + } +} diff --git a/internal/localnet/token/dar_bundle.go b/internal/localnet/token/dar_bundle.go index 52b48d87..acbfcf45 100644 --- a/internal/localnet/token/dar_bundle.go +++ b/internal/localnet/token/dar_bundle.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "time" adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" @@ -15,11 +17,10 @@ import ( "github.com/bitdynamics-ab/canton-devkit/internal/splice" ) -// DAR auto-bundling. The splice-test-token-v2 instrument needs its -// upstream DARs vetted before `token create` can anchor a TokenRules -// contract. Rather than make the developer run `dar upload` by hand, -// `token create --endpoint` fetches the prebuilt DARs pinned to the -// instance's Splice commit and uploads any not already vetted. +// DAR auto-bundling for splice-test-token-v2. Mint/transfer to a party on +// another participant fails unless every LocalNet participant has the +// package; token create fetches and uploads the bundle instead of +// `dar upload --all-participants`. // tokenBundleDARs are the prebuilt DARs the test token needs, keyed by // package name (what resolvePackageID checks) → the DAR filename. @@ -33,7 +34,7 @@ var tokenBundleDARs = []struct{ pkg, file string }{ // V2 foundation packages for EventLog history, allocations/DvP and the // BatchingUtilityV2 wallet. - {"splice-api-token-transfer-events-v2", "splice-api-token-transfer-events-v2-1.0.0.dar"}, + {"splice-api-token-transfer-instruction-v2", "splice-api-token-transfer-instruction-v2-1.0.0.dar"}, {"splice-api-token-allocation-v2", "splice-api-token-allocation-v2-1.0.0.dar"}, {"splice-api-token-allocation-instruction-v2", "splice-api-token-allocation-instruction-v2-1.0.0.dar"}, {"splice-api-token-allocation-request-v2", "splice-api-token-allocation-request-v2-1.0.0.dar"}, @@ -42,6 +43,9 @@ var tokenBundleDARs = []struct{ pkg, file string }{ const darFetchMaxBytes = 64 << 20 // 64 MiB — these DARs are well under 1 MiB +// Leading dot keeps the cache dir out of ValidateName's instance namespace. +const darCacheDirName = ".dar-cache" + // darBundleBaseURL is the raw.githubusercontent.com base for the upstream // splice repo's prebuilt DARs. A package var so tests can point it at a // local httptest server. @@ -57,27 +61,119 @@ var errDARNotPublished = errors.New("DAR not published at this commit") // is more useful than surfacing a raw GitHub 404. var ErrTokenDARUnavailable = errors.New("test-token DAR not available for this Splice version") -// ensureTokenDARs uploads any test-token DAR not already vetted, fetching -// it from the upstream repo pinned to the instance's Splice commit. -// Idempotent: a fully-vetted participant is a no-op (no network). -func ensureTokenDARs(ctx context.Context, client *ledger.Client, instance string, out io.Writer) error { - missing := tokenBundleDARs[:0:0] - for _, d := range tokenBundleDARs { - if _, err := resolvePackageID(ctx, client, d.pkg); err != nil { - missing = append(missing, d) - } +// darClient is the package-management slice ensureTokenDARs needs; narrow +// so tests inject per-role fakes without widening LedgerClient. +type darClient interface { + ListKnownPackages(ctx context.Context) (*adminv2.ListKnownPackagesResponse, error) + UploadDarFile(ctx context.Context, req *adminv2.UploadDarFileRequest) (*adminv2.UploadDarFileResponse, error) +} + +// Package var so tests swap in per-role fakes. +var dialDARClient = func(ctx context.Context, conn LedgerConn) (darClient, func(), error) { + return dialLedger(ctx, conn) +} + +// Must match `dar upload --all-participants` so create and manual upload +// target the same topology. +func tokenDARRoles() []string { + roles := splice.AllRoles() + out := make([]string, len(roles)) + for i, r := range roles { + out[i] = string(r) } - if len(missing) == 0 { - return nil + return out +} + +// ensureTokenDARs uploads missing test-token DARs on sv, app-provider, and +// app-user. Reuses createClient for the create role so TokenRules does not +// dial twice. Any missing port or upload/vet failure fails create — skipping +// a role leaves counterparty participants unvetted. +func ensureTokenDARs(ctx context.Context, createClient darClient, opts CreateOptions, out io.Writer) ([]string, error) { + roles := tokenDARRoles() + createRole := roleOrDefault(opts.Role) + targets := make([]struct { + role string + endpoint string + }, 0, len(roles)) + for _, role := range roles { + endpoint := ResolveLedgerEndpoint(opts.Instance, role) + if endpoint == "" { + return nil, fmt.Errorf("no live ledger endpoint for role %q on instance %q — "+ + "start the instance so participant_ledger_%s is captured; "+ + "the test-token DAR must be vetted on every participant", + role, opts.Instance, role) + } + targets = append(targets, struct { + role string + endpoint string + }{role: role, endpoint: endpoint}) } - commit, err := tokenBundleCommit(instance) + commit, err := tokenBundleCommit(opts.Instance) if err != nil { - return err + return nil, err + } + + fetched := map[string][]byte{} + load := func(file string) ([]byte, error) { + if b, ok := fetched[file]; ok { + return b, nil + } + b, err := loadDAR(ctx, commit, file) + if err != nil { + return nil, err + } + fetched[file] = b + return b, nil + } + + vetted := make([]string, 0, len(targets)) + for _, t := range targets { + client := createClient + cleanup := func() {} + reuse := createClient != nil && t.role == createRole && t.endpoint == opts.Endpoint + if !reuse { + var err error + client, cleanup, err = dialDARClient(ctx, LedgerConn{ + Endpoint: t.endpoint, + Insecure: opts.Insecure, + Instance: opts.Instance, + Role: t.role, + }) + if err != nil { + return nil, fmt.Errorf("dial %s (%s): %w", t.role, t.endpoint, err) + } + } + err := vetTokenDARsOn(ctx, client, t.role, t.endpoint, opts.Instance, commit, load, out) + cleanup() + if err != nil { + return nil, err + } + vetted = append(vetted, t.role) } - for _, d := range missing { - emit(out, "dar bundle: fetching", map[string]any{"package": d.pkg, "commit": commit[:12]}) - dar, err := fetchDAR(ctx, commit, d.file) + return vetted, nil +} + +// Post-upload package list confirms vet succeeded. +func vetTokenDARsOn( + ctx context.Context, + client darClient, + role, endpoint, instance, commit string, + load func(string) ([]byte, error), + out io.Writer, +) error { + for _, d := range tokenBundleDARs { + if packageKnown(ctx, client, d.pkg) { + continue + } + short := commit + if len(short) > 12 { + short = commit[:12] + } + emit(out, "dar bundle: fetching", map[string]any{ + "package": d.pkg, "commit": short, "role": role, "endpoint": endpoint, + }) + dar, err := load(d.file) if err != nil { if errors.Is(err, errDARNotPublished) { return darUnavailableError(d.pkg, instanceSpliceVersion(instance)) @@ -85,13 +181,53 @@ func ensureTokenDARs(ctx context.Context, client *ledger.Client, instance string return fmt.Errorf("fetch %s: %w", d.file, err) } if _, err := client.UploadDarFile(ctx, &adminv2.UploadDarFileRequest{DarFile: dar}); err != nil { - return fmt.Errorf("upload %s: %w", d.file, err) + return fmt.Errorf("upload %s on %s (%s): %w", d.file, role, endpoint, err) + } + if !packageKnown(ctx, client, d.pkg) { + return fmt.Errorf("vet %s on %s (%s): package %q still not known after upload", + d.file, role, endpoint, d.pkg) } - emit(out, "dar bundle: vetted", map[string]any{"package": d.pkg}) + emit(out, "dar bundle: vetted", map[string]any{ + "package": d.pkg, "role": role, "endpoint": endpoint, + }) } return nil } +// Treat list errors as absent so upload still runs. +func packageKnown(ctx context.Context, client darClient, name string) bool { + resp, err := client.ListKnownPackages(ctx) + if err != nil { + return false + } + for _, p := range resp.GetPackageDetails() { + if p.GetName() == name { + return true + } + } + return false +} + +// Cache write failures are ignored; in-memory bytes suffice for this upload. +func loadDAR(ctx context.Context, commit, file string) ([]byte, error) { + path := darCachePath(commit, file) + if b, err := os.ReadFile(path); err == nil && len(b) > 0 { + return b, nil + } + b, err := fetchDAR(ctx, commit, file) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil { + _ = os.WriteFile(path, b, 0o644) + } + return b, nil +} + +func darCachePath(commit, file string) string { + return filepath.Join(registry.Root(), darCacheDirName, filepath.Base(commit), filepath.Base(file)) +} + // tokenBundleCommit resolves the instance's Splice version to the git // commit the prebuilt DARs are pinned to (curated catalogue first, then // the resolved-uncurated cache for ad-hoc tags). @@ -157,3 +293,6 @@ func fetchDAR(ctx context.Context, commit, file string) ([]byte, error) { } return io.ReadAll(io.LimitReader(resp.Body, darFetchMaxBytes)) } + +// Compile-time check that the production ledger client satisfies darClient. +var _ darClient = (*ledger.Client)(nil) diff --git a/internal/localnet/token/dar_bundle_test.go b/internal/localnet/token/dar_bundle_test.go index 071a1068..f39d6513 100644 --- a/internal/localnet/token/dar_bundle_test.go +++ b/internal/localnet/token/dar_bundle_test.go @@ -1,13 +1,25 @@ package token import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path" + "path/filepath" + "strings" + "sync/atomic" "testing" + adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" + "github.com/bitdynamics-ab/canton-devkit/internal/registry" + "github.com/bitdynamics-ab/canton-devkit/internal/splice" ) -// TestTokenBundleCommit_FromCuratedCatalogue resolves a curated tag to -// its pinned commit (the V2 alpha entry ships in versions.json). func TestTokenBundleCommit_FromCuratedCatalogue(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) s := registry.NewState("demo", "token-standard-v2") @@ -37,3 +49,252 @@ func TestTokenBundleCommit_UnknownVersionErrors(t *testing.T) { t.Error("want error for an unknown Splice version") } } + +func TestTokenDARRoles_MatchesAllRoles(t *testing.T) { + got := tokenDARRoles() + want := splice.AllRoles() + if len(got) != len(want) { + t.Fatalf("roles len=%d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != string(want[i]) { + t.Errorf("roles[%d]=%q, want %q", i, got[i], want[i]) + } + } +} + +// Create uploads the bundle on sv, app-provider, and app-user, not only the create client. +func TestEnsureTokenDARs_FansOutToAllRoles(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allLedgerPorts()) + srv, hits := startDARServer(t) + swapDARBase(t, srv.URL) + + create := newFakeDAR() + sv := newFakeDAR() + user := newFakeDAR() + var dialed []string + withDARDial(t, func(_ context.Context, conn LedgerConn) (darClient, func(), error) { + dialed = append(dialed, conn.Role) + switch conn.Role { + case "sv": + return sv, func() {}, nil + case "app-user": + return user, func() {}, nil + default: + return nil, func() {}, errors.New("should reuse create client for " + conn.Role) + } + }) + + var out bytes.Buffer + opts := bundleCreateOpts("demo") + roles, err := ensureTokenDARs(context.Background(), create, opts, &out) + if err != nil { + t.Fatalf("ensureTokenDARs: %v", err) + } + if want := []string{"sv", "app-provider", "app-user"}; strings.Join(roles, ",") != strings.Join(want, ",") { + t.Errorf("vetted roles=%v, want %v", roles, want) + } + wantUploads := len(tokenBundleDARs) + if create.uploads != wantUploads || sv.uploads != wantUploads || user.uploads != wantUploads { + t.Errorf("uploads create=%d sv=%d user=%d, want %d each", + create.uploads, sv.uploads, user.uploads, wantUploads) + } + if hits.Load() != int32(wantUploads) { + t.Errorf("HTTP fetches=%d, want %d (one per file, shared across roles)", hits.Load(), wantUploads) + } + if strings.Join(dialed, ",") != "sv,app-user" { + t.Errorf("dialed=%v, want sv then app-user (app-provider reused)", dialed) + } + for _, role := range []string{"sv", "app-provider", "app-user"} { + if !strings.Contains(out.String(), `"role":"`+role+`"`) { + t.Errorf("output missing role %q:\n%s", role, out.String()) + } + } +} + +func TestEnsureTokenDARs_MissingPortFails(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", map[string]int{ + "participant_ledger_app-provider": 3901, + }) + _, err := ensureTokenDARs(context.Background(), newFakeDAR(), bundleCreateOpts("demo"), io.Discard) + if err == nil { + t.Fatal("want error when a role has no ledger port") + } + if !strings.Contains(err.Error(), "sv") || !strings.Contains(err.Error(), "participant_ledger_sv") { + t.Errorf("want missing-port error naming sv, got: %v", err) + } +} + +func TestEnsureTokenDARs_CachesDAROnDisk(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allLedgerPorts()) + srv, hits := startDARServer(t) + swapDARBase(t, srv.URL) + + withDARDial(t, func(_ context.Context, conn LedgerConn) (darClient, func(), error) { + return newFakeDAR(), func() {}, nil + }) + opts := bundleCreateOpts("demo") + if _, err := ensureTokenDARs(context.Background(), newFakeDAR(), opts, io.Discard); err != nil { + t.Fatalf("first ensureTokenDARs: %v", err) + } + firstHits := hits.Load() + if firstHits != int32(len(tokenBundleDARs)) { + t.Fatalf("first pass HTTP fetches=%d, want %d", firstHits, len(tokenBundleDARs)) + } + + commit, err := tokenBundleCommit("demo") + if err != nil { + t.Fatal(err) + } + for _, d := range tokenBundleDARs { + p := darCachePath(commit, d.file) + if _, err := os.Stat(p); err != nil { + t.Errorf("cache miss %s: %v", p, err) + } + } + + if _, err := ensureTokenDARs(context.Background(), newFakeDAR(), opts, io.Discard); err != nil { + t.Fatalf("second ensureTokenDARs: %v", err) + } + if hits.Load() != firstHits { + t.Errorf("second pass HTTP fetches=%d, want %d (disk cache)", hits.Load(), firstHits) + } +} + +func TestEnsureTokenDARs_SecondaryRoleUploadFails(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allLedgerPorts()) + srv, _ := startDARServer(t) + swapDARBase(t, srv.URL) + + user := newFakeDAR() + user.uploadErr = errors.New("boom") + withDARDial(t, func(_ context.Context, conn LedgerConn) (darClient, func(), error) { + if conn.Role == "app-user" { + return user, func() {}, nil + } + return newFakeDAR(), func() {}, nil + }) + _, err := ensureTokenDARs(context.Background(), newFakeDAR(), bundleCreateOpts("demo"), io.Discard) + if err == nil { + t.Fatal("want error when app-user upload fails") + } + if !strings.Contains(err.Error(), "app-user") || !strings.Contains(err.Error(), "boom") { + t.Errorf("want upload error naming app-user, got: %v", err) + } +} + +func TestEnsureTokenDARs_404IsUnavailable(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allLedgerPorts()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + swapDARBase(t, srv.URL) + withDARDial(t, func(context.Context, LedgerConn) (darClient, func(), error) { + return newFakeDAR(), func() {}, nil + }) + _, err := ensureTokenDARs(context.Background(), newFakeDAR(), bundleCreateOpts("demo"), io.Discard) + if !errors.Is(err, ErrTokenDARUnavailable) { + t.Fatalf("want ErrTokenDARUnavailable, got %v", err) + } +} + +func seedBundleInstance(t *testing.T, name string, ports map[string]int) { + t.Helper() + s := registry.NewState(name, "0.6.12") + s.ProjectDir = t.TempDir() + s.DataDir = t.TempDir() + s.Status = registry.StatusRunning + s.Ports = ports + if err := registry.Write(s); err != nil { + t.Fatal(err) + } +} + +func allLedgerPorts() map[string]int { + return map[string]int{ + "participant_ledger_sv": 4901, + "participant_ledger_app-provider": 3901, + "participant_ledger_app-user": 2901, + } +} + +func bundleCreateOpts(instance string) CreateOptions { + return CreateOptions{ + Instance: instance, + Role: "app-provider", + Endpoint: "localhost:3901", + Insecure: true, + } +} + +func startDARServer(t *testing.T) (*httptest.Server, *atomic.Int32) { + t.Helper() + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("dar:" + path.Base(r.URL.Path))) + })) + t.Cleanup(srv.Close) + return srv, &hits +} + +func swapDARBase(t *testing.T, url string) { + t.Helper() + prev := darBundleBaseURL + darBundleBaseURL = url + t.Cleanup(func() { darBundleBaseURL = prev }) +} + +func withDARDial(t *testing.T, fn func(context.Context, LedgerConn) (darClient, func(), error)) { + t.Helper() + prev := dialDARClient + dialDARClient = fn + t.Cleanup(func() { dialDARClient = prev }) +} + +type fakeDAR struct { + packages map[string]struct{} + uploads int + uploadErr error +} + +func newFakeDAR() *fakeDAR { + return &fakeDAR{packages: map[string]struct{}{}} +} + +func (f *fakeDAR) ListKnownPackages(context.Context) (*adminv2.ListKnownPackagesResponse, error) { + details := make([]*adminv2.PackageDetails, 0, len(f.packages)) + for name := range f.packages { + details = append(details, &adminv2.PackageDetails{Name: name, PackageId: name}) + } + return &adminv2.ListKnownPackagesResponse{PackageDetails: details}, nil +} + +func (f *fakeDAR) UploadDarFile(_ context.Context, req *adminv2.UploadDarFileRequest) (*adminv2.UploadDarFileResponse, error) { + if f.uploadErr != nil { + return nil, f.uploadErr + } + f.uploads++ + file := strings.TrimPrefix(string(req.GetDarFile()), "dar:") + for _, d := range tokenBundleDARs { + if d.file == file { + f.packages[d.pkg] = struct{}{} + } + } + return &adminv2.UploadDarFileResponse{}, nil +} + +func TestDarCachePath_UnderRegistryRoot(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + p := darCachePath("abc123", "splice-test-token-v2-1.0.0.dar") + root := registry.Root() + if !strings.HasPrefix(p, filepath.Join(root, darCacheDirName)) { + t.Errorf("cache path %q not under %s/%s", p, root, darCacheDirName) + } +} diff --git a/internal/localnet/token/instrument_v2.go b/internal/localnet/token/instrument_v2.go index 90e9fead..4d194f82 100644 --- a/internal/localnet/token/instrument_v2.go +++ b/internal/localnet/token/instrument_v2.go @@ -21,10 +21,15 @@ import ( // choice context. We control the admin party, so we can mint freely // (TokenRules_OfferMint is `controller admin`). -// ensureTokenRules creates the issuer's TokenRules contract if it doesn't -// already exist. The issuer party (opts.Issuer) is the admin/signatory; -// the auto-grant on dial covers the rights. -func ensureTokenRules(opts CreateOptions) error { +// findTokenRulesDisclosedFn and mintViaOfferMintFn are test seams so that +// unit tests can stub the ledger-query steps in runMintLive and assert on +// which participant the accept step dials without a live gRPC server. +var findTokenRulesDisclosedFn = findTokenRulesDisclosed +var mintViaOfferMintFn = mintViaOfferMint + +// ensureTokenRules creates the issuer's TokenRules contract if missing. +// Returns the roles DARs were vetted on. +func ensureTokenRules(out io.Writer, opts CreateOptions) ([]string, error) { ctx := context.Background() conn := LedgerConn{ Endpoint: opts.Endpoint, @@ -34,7 +39,7 @@ func ensureTokenRules(opts CreateOptions) error { } client, cleanup, err := dialLedger(ctx, conn) if err != nil { - return err + return nil, err } defer cleanup() @@ -47,30 +52,29 @@ func ensureTokenRules(opts CreateOptions) error { _ = client.GrantUserActAndReadAs(ctx, exerciseUserID, []string{admin}) } - // Bundle the test-token DARs before any package-name-scoped query: - // findTokenRules filters by the #splice-test-token-v2 package name, - // which the participant rejects with PACKAGE_NAMES_NOT_FOUND until the - // package is vetted. No-op when already vetted. - if err := ensureTokenDARs(ctx, client, opts.Instance, nil); err != nil { - return err + // Vet on every participant before findTokenRules; package-name filters + // fail with PACKAGE_NAMES_NOT_FOUND until the DAR is vetted. + vetted, err := ensureTokenDARs(ctx, client, opts, out) + if err != nil { + return nil, err } existing, err := findTokenRules(ctx, client, admin) if err != nil { - return fmt.Errorf("look up existing TokenRules: %w", err) + return vetted, fmt.Errorf("look up existing TokenRules: %w", err) } if existing != "" { - return nil + return vetted, nil } _, err = createTokenRules(ctx, client, admin) - return err + return vetted, err } // runMintLive performs an asset-specific mint: find the issuer's // TokenRules, exercise TokenRules_OfferMint (controller = admin), then // accept the resulting TokenTransferOffer so the holding lands in the -// receiver's account. The auto-grant covers acting as both admin and -// receiver (LocalNet hosts them on the same participant). +// receiver's account. Receiver is often on app-user; create must vet +// the DAR there before mint can land. // // Not batched (unlike transfer+accept): TokenRules_OfferMint is an // asset-specific choice and BatchingUtilityV2's TokenStandardAction set @@ -96,14 +100,14 @@ func runMintLive(ctx context.Context, out io.Writer, opts MintOptions, ref regst Instance: opts.Instance, Role: opts.Role, } - client, cleanup, err := dialLedger(ctx, conn) + client, cleanup, err := dialSenderFn(ctx, conn) if err != nil { return err } defer cleanup() admin := ref.IssuerParty - tokenRulesCID, tokenRulesDisc, err := findTokenRulesDisclosed(ctx, client, admin) + tokenRulesCID, tokenRulesDisc, err := findTokenRulesDisclosedFn(ctx, client, admin) if err != nil { return fmt.Errorf("look up TokenRules: %w", err) } @@ -112,7 +116,7 @@ func runMintLive(ctx context.Context, out io.Writer, opts MintOptions, ref regst "run `localnet token create --endpoint ...` first", admin) } - offerCID, err := mintViaOfferMint(ctx, client, admin, tokenRulesCID, opts.To, opts.Amount, ref.InstrumentID) + offerCID, err := mintViaOfferMintFn(ctx, client, admin, tokenRulesCID, opts.To, opts.Amount, ref.InstrumentID) if err != nil { return err } @@ -120,10 +124,19 @@ func runMintLive(ctx context.Context, out io.Writer, opts MintOptions, ref regst "offer_cid": offerCID, "to": opts.To, "amount": opts.Amount, }) - // Settle by accepting as the receiver. The self-custodial receiver - // (provider=None) needs no AccountConfig; the accept context carries - // only the TokenRules entry. - if err := acceptMintOffer(ctx, client, opts.To, offerCID, tokenRulesCID, tokenRulesDisc); err != nil { + // Accept on the receiver's own participant; the sender's node cannot + // act as a party it doesn't host. + acceptClient := client + acceptConn := resolveAcceptConn(conn, opts.Instance, opts.To) + if acceptConn.Role != conn.Role { + var acceptCleanup func() + acceptClient, acceptCleanup, err = dialLedgerConcreteFn(ctx, acceptConn) + if err != nil { + return fmt.Errorf("dial receiver participant for mint accept: %w", err) + } + defer acceptCleanup() + } + if err := acceptMintOffer(ctx, acceptClient, opts.To, offerCID, tokenRulesCID, tokenRulesDisc); err != nil { return fmt.Errorf("accept mint offer: %w", err) } emit(out, "mint: accepted", map[string]any{ diff --git a/internal/localnet/token/ledger.go b/internal/localnet/token/ledger.go index 7ebef482..f5d3db60 100644 --- a/internal/localnet/token/ledger.go +++ b/internal/localnet/token/ledger.go @@ -39,6 +39,16 @@ var dialLedgerFn = func(ctx context.Context, conn LedgerConn) (LedgerClient, fun return dialLedger(ctx, conn) } +// dialLedgerConcreteFn is the seam for mint/transfer accept paths that open +// a *ledger.Client. Tests replace it to assert which participant the accept +// step dials without needing a live gRPC server. +var dialLedgerConcreteFn = dialLedger + +// dialSenderFn is the seam for the initial (sender-side) dial in runMintLive +// and runTransferLiveOnLedger. Tests replace it with a stub that returns a +// pre-built no-op client so execution reaches the accept-side dial. +var dialSenderFn = dialLedger + // LedgerConn captures everything a live ledger call needs: the gRPC // endpoint and the Bearer JWT the participant accepts. type LedgerConn struct { @@ -56,6 +66,25 @@ type LedgerConn struct { Role string // "sv" / "app-provider" / "app-user" } +// resolveAcceptConn returns the LedgerConn that the accept step should use. +// When the receiver party lives on a different role than the sender, the +// conn is re-pointed at the receiver's participant; otherwise the sender's +// conn is returned unchanged so the same connection is reused. +func resolveAcceptConn(conn LedgerConn, instance, receiverPartyID string) LedgerConn { + role := receiverRole(instance, receiverPartyID) + if role == "" || role == conn.Role { + return conn + } + ep := ResolveLedgerEndpoint(instance, role) + if ep == "" { + return conn + } + acceptConn := conn + acceptConn.Endpoint = ep + acceptConn.Role = role + return acceptConn +} + // ResolveLedgerEndpoint returns the role's participant ledger gRPC endpoint // (host:port) only while the registry says the instance is running. Stopped, // paused, failed, and mid-transition instances retain their allocated ports, diff --git a/internal/localnet/token/run_transfer_onledger.go b/internal/localnet/token/run_transfer_onledger.go index 5428e711..528671a1 100644 --- a/internal/localnet/token/run_transfer_onledger.go +++ b/internal/localnet/token/run_transfer_onledger.go @@ -68,7 +68,7 @@ func runTransferLiveOnLedger(ctx context.Context, out io.Writer, opts TransferOp Instance: opts.Instance, Role: opts.Role, } - client, cleanup, err := dialLedger(ctx, conn) + client, cleanup, err := dialSenderFn(ctx, conn) if err != nil { return "", err } @@ -171,10 +171,25 @@ func runTransferLiveOnLedger(ctx context.Context, out io.Writer, opts TransferOp // receiver). The self-custodial receiver needs no AccountConfig of its // own, but the accept still references the sender's config. if opts.AutoAccept && !opts.NoWait && instructionID != "" { + // Accept on the receiver's own participant; the sender's node + // cannot act as a party it doesn't host. + acceptClient := client + var acceptCleanup func() + senderConn := LedgerConn{ + Endpoint: opts.Endpoint, Insecure: opts.Insecure, + Instance: opts.Instance, Role: opts.Role, + } + if aconn := resolveAcceptConn(senderConn, opts.Instance, opts.To); aconn.Role != opts.Role { + acceptClient, acceptCleanup, err = dialLedgerConcreteFn(ctx, aconn) + if err != nil { + return instructionID, fmt.Errorf("dial receiver participant for accept: %w", err) + } + defer acceptCleanup() + } receiverParties := accountPartiesOf(admin, opts.To, "") acceptActAs := dedupParties(append(append(append([]string{}, senderParties...), receiverParties...), admin)) if err := acceptTestTokenTransfer( - ctx, client, instructionID, acceptActAs, receiverParties, tokenRulesCID, accountConfigCIDs, + ctx, acceptClient, instructionID, acceptActAs, receiverParties, tokenRulesCID, accountConfigCIDs, ); err != nil { return instructionID, fmt.Errorf("auto-accept on-ledger transfer %s: %w", instructionID, err) } @@ -241,11 +256,28 @@ func runAcceptOnLedgerIfTestToken(ctx context.Context, out io.Writer, opts Accep configCIDs = append(configCIDs, cid) } + // Accept on the receiver's own participant; the initial client dials + // the sender-side role which cannot act as receiver parties on a + // different node. + acceptClient := client + var acceptCleanup func() + initialConn := LedgerConn{ + Endpoint: opts.Endpoint, Insecure: opts.Insecure, + Instance: opts.Instance, Role: opts.Role, + } + if aconn := resolveAcceptConn(initialConn, opts.Instance, receiver.Owner); aconn.Role != opts.Role { + var aerr error + acceptClient, acceptCleanup, aerr = dialLedgerConcreteFn(ctx, aconn) + if aerr != nil { + return true, fmt.Errorf("dial receiver participant for accept: %w", aerr) + } + defer acceptCleanup() + } actors := accountPartiesOf(admin, receiver.Owner, receiver.Provider) actAs := dedupParties(append(append( append([]string{}, accountPartiesOf(admin, sender.Owner, sender.Provider)...), actors...), admin)) - if err := acceptTestTokenTransfer(ctx, client, opts.TransferInstructionID, actAs, actors, tokenRulesCID, configCIDs); err != nil { + if err := acceptTestTokenTransfer(ctx, acceptClient, opts.TransferInstructionID, actAs, actors, tokenRulesCID, configCIDs); err != nil { return true, fmt.Errorf("exercise on-ledger TransferInstruction_Accept: %w", err) } emit(out, "accept: submitted", map[string]any{ diff --git a/internal/localnet/token/token.go b/internal/localnet/token/token.go index da8b28a7..e53010ef 100644 --- a/internal/localnet/token/token.go +++ b/internal/localnet/token/token.go @@ -171,6 +171,7 @@ func RunCreate(out io.Writer, opts CreateOptions) (*CreateResult, error) { // opaque id. instrumentID := opts.Symbol status := "on-ledger" + var vettedRoles []string if opts.Endpoint == "" { var err error if instrumentID, err = newInstrumentID(); err != nil { @@ -181,7 +182,9 @@ func RunCreate(out io.Writer, opts CreateOptions) (*CreateResult, error) { // Ensure the issuer's TokenRules contract exists on-ledger. // Idempotent: one TokenRules per admin anchors every instrument // that admin issues. - if err := ensureTokenRules(opts); err != nil { + var err error + vettedRoles, err = ensureTokenRules(out, opts) + if err != nil { return nil, err } } @@ -211,6 +214,9 @@ func RunCreate(out io.Writer, opts CreateOptions) (*CreateResult, error) { _, _ = fmt.Fprintln(out, "Created on-ledger: a TokenRules contract anchors this instrument "+ "for the issuer. `token mint --endpoint ...` can now mint supply.") + if len(vettedRoles) > 0 { + _, _ = fmt.Fprintf(out, "Vetted test-token DARs on %s.\n", strings.Join(vettedRoles, ", ")) + } } else { _, _ = fmt.Fprintln(out, "Note: instrument is recorded LOCALLY only — pass --endpoint to "+