feat(token): CIP-0112 V2 token features — identity picker, EventLog history, allocations (DvP), batching - #277
Conversation
Session update — token-page tightening (pushed through
|
…types Foundation for CIP-0112 (Token Standard V2): auto-bundle the allocation-v2, transfer-events-v2, and util-token-standard-wallet DARs alongside the test-token DAR, add the V2 interface/choice name constants, and define the schema-pinned API types shared by the CLI --json output and the Web UI REST/SSE payloads so the two surfaces cannot drift.
…tching Four CIP-0112 capabilities on both the CLI and the Web UI Tokens screen, over one shared orchestration layer: - identity/act-as picker: operate as app-user / app-provider / sv, with the role threaded through every token call. - EventLog transaction history: per-instrument activity reconstructed from the V2 EventLog interface, newest-first. - allocations / DvP: allocate, list, settle, withdraw, cancel via the V2 AllocationFactory. - opt-in atomic transfer+accept batching via BatchingUtilityV2.
Fixes found by exercising create -> mint -> transfer -> allocate on a live LocalNet, plus Tokens-screen UX. Each lands on both the CLI and the Web UI: - authorize V2 ops: grant read-any + per-issuer act-as, resolve issuer aliases on create, onboard freshly-allocated parties to the synchronizer. - self-custodial receiver account so V2 mint accept works; reject self-mint (issuer == receiver) with an actionable error. - correct the allocation wire shapes to V2 (drop expectedAdmin; SettlementInfo and TransferLegSide v1 -> v2). - list created-but-unminted instruments (discovery was holdings-only). - drop phantom bare-alias parties from the readable set and matrix. - Holdings matrix filter-by-token; copy party ids; paginate the Activity feed newest-first. - --atomic batching: wire shapes corrected, but closed as experimental (ExecuteBatch does not rebind the accept leg to the transfer leg's intra-batch instruction) -- it fails with a clear error; sequential transfer+accept is the supported default.
Open-source hygiene pass over the token subsystem: cut running commentary and code-restating comments (~1000 fewer comment lines) while keeping the load-bearing wire-shape and Daml-quirk rationale, and remove internal ticket tags and notes. Comment-only -- no behavior change.
d9e7941 to
04fbaeb
Compare
zheli
left a comment
There was a problem hiding this comment.
The V2 foundation is well structured, but this revision has several blocking issues to address. Generic ledger dialing introduces universal execute-as authority, allocation withdraw/cancel omit a required Daml argument, settlement is exposed but unimplemented, and the Web UI does not preserve role or atomic parity. EventLog history also duplicates paired reports and can omit newer activity. The new commands and flag additionally require proposal-deviation documentation.
|
Let's create a new doc page for V2 and add instructions on how to run a localnet with support there. It seems multiple steps are required. |
Manual verification: standalone
|
| Command | Result |
|---|---|
identity |
✅ Pass (text/json, --role, invalid-role rejection) — no instance needed |
party new / party ls |
✅ Pass |
create |
✅ Pass (on-ledger V2 instrument) |
mint |
✅ Pass — the TIA_Accept blocker in the PR description did not reproduce on this image |
balances / balance / summary |
✅ Pass |
activity |
✅ Pass (V2 EventLog path) |
faucet |
✅ Pass |
transfer (+ --no-wait) / transfer accept |
✅ Pass |
transfer --auto-accept |
✅ Pass |
transfer --auto-accept --atomic |
✅ Pass as designed — fails cleanly with the documented experimental message, no partial commit |
burn (+ --yes) |
✅ Pass |
allocations (list/withdraw/cancel) |
✅ Pass on invalid input (clean 400s); list correctly empty |
allocate |
❌ Fails |
settle |
❌ Fails |
demo |
❌ Fails |
New issues found (not mentioned in the PR description)
1. allocate — AllocationFactory_Allocate rejects custom instruments
exercise AllocationFactory_Allocate: ... DAML_FAILURE: ... AssertionFailed:
The requirement 'Instrument-id must match the factory' was not met.
Reproduced with an instrument created via token create (not a network-default instrument). Looks like the registry's allocation factory doesn't resolve issuer-created instruments the way allocate assumes.
2. settle — GetSettlementFactory uses the wrong HTTP verb
internal/canton/registry/allocation_v2.go:157-163 issues a GET to the settlement-factory path:
func (c *Client) GetSettlementFactory(ctx context.Context, path string) (*SettlementFactoryResponse, error) {
var out SettlementFactoryResponse
if err := c.doJSON(ctx, "GET", path, nil, &out); err != nil {Against the live registry this returns HTTP 405: supported methods: POST. This is more fundamental than the documented "not yet proven — batch assembly needs a live V2 instance" TODO in RunSettle — the code never even reaches that TODO because the endpoint call itself fails first.
3. demo — always self-mints, so it always fails on a V2 instance
internal/localnet/token/demo.go:111-122 (runDemoV2) mints the initial supply To: issuer.PartyID:
step("Minting %s %s to the issuer…", opts.InitialSupply, opts.Symbol)
if err := demoMint(ctx, out, MintOptions{
...
To: issuer.PartyID,
...This is exactly the self-mint case the guard added in this same PR ("reject self-mint (issuer == receiver) with an actionable error") rejects:
demo: mint supply: cannot self-mint: the test token cannot mint to the issuer's own party (...) — mint to a distinct party
token demo will fail every time on a tokens-v2 instance until it mints to a distinct holder (or seeds a holder first and mints there).
Minor inconsistency
allocations (list) marks --endpoint as a hard cobra-required flag, unlike its sibling verbs (transfer/allocate/settle/demo), which instead print the ErrNeedsV2LocalNet remediation at runtime when --endpoint is omitted. Worth aligning for consistent UX.
Happy to share the exact command transcript if useful.
The V2 demo minted the initial supply to the issuer party, which the self-mint guard added in this branch rejects (issuer == receiver) — so token demo failed every time on a tokens-v2 instance. Mint the supply to a distinct holder party instead, which both satisfies the guard and lands a transferable balance in one step, dropping the redundant issuer->holder faucet leg. The holder is now intrinsic to the V2 demo, so --seed-holder is removed.
|
Fix for the Addressed the Fix: Verified end-to-end — fresh
Note on the self-mint guard / |
Follow-up (deferred): token
|
mint and burn only took the live path when --endpoint was passed explicitly; otherwise they fell through to ErrUnsupportedOnInstrument, which also meant the self-mint guard (inside runMintLive) never ran. A self-mint attempt without --endpoint therefore surfaced a misleading 'instrument doesn't implement mint/burn' error instead of the actionable guard message. transfer/accept similarly returned ErrNeedsV2LocalNet on a live instance when --endpoint was omitted. Resolve the endpoint from the instance's captured participant_ledger_<role> port (as RunBalance already does) in the shared action layer, so both the CLI and Web UI behave consistently and these commands work flag-free on a running LocalNet. Explicit --endpoint still wins; instance-down still falls back to the correct remediation. Align the CLI --endpoint help text with balances/balance.
Live verification —
|
| Command | Result |
|---|---|
token mint --to <issuer> --amount 1 (self-mint) |
✅ Now rejected with the actionable guard: cannot self-mint: the test token cannot mint to the issuer's own party (…) — mint to a distinct party. Previously returned the misleading ErrUnsupportedOnInstrument. |
token mint --to <demo-holder> --amount 7 |
✅ mint: offered → mint: accepted — flag-free live mint commits end-to-end. |
token balances |
✅ demo-holder holds 1000012 DEMO (1,000,000 demo supply + 5 + 7 from flag-free mints); demo-issuer holds nothing. |
### self-mint WITHOUT --endpoint (expect guard):
cannot self-mint: the test token cannot mint to the issuer's own party (demo-issuer::1220…0e82) — mint to a distinct party
exit=1
### mint to distinct holder WITHOUT --endpoint (expect accepted):
mint: offered: {"amount":"7","offer_cid":"000ca432…56b51","to":"demo-holder::1220…0e82"}
mint: accepted: {"amount":"7","instrument":"DEMO","to":"demo-holder::1220…0e82"}
exit=0
### balances (no --endpoint):
PARTY AMULET DEMO
──────────────────────────────────────────────────────────────────────
app_user_demo_v2fix-localparty-1 23600.1600000000 ·
demo-holder · 1000012.0000000000
demo-issuer · ·
Σ total 23600.1600000000 1000012.0000000000
exit=0
go build + go vet clean; go test ./internal/localnet/token/... ./internal/ui/handlers/... ./internal/cli/localnet/token/... → 485 passed (includes new auto-resolution tests: self-mint hits the guard flag-free, distinct receiver reaches the live dial, no-captured-port keeps the registry fallback, transfer auto-resolves).
Scope note: this covers mint/burn/transfer/transfer accept. Kept the app-user role default; the app-provider role-default alignment is tracked as a separate follow-up.
`token allocate` posted the scan registry's allocation-factory endpoint, which returns the network-default (DSO/Amulet) factory, then exercised AllocationFactory_Allocate against it. For an issuer-created instrument (token create) that fails on-ledger with AssertionFailed: The requirement 'Instrument-id must match the factory' because the DAML impl asserts allocation.admin == tokenRules.admin and the DSO factory's admin is not the issuer. The issuer's own on-ledger TokenRules contract IS the V2 AllocationFactory (interface instance V2.AllocationFactory for TokenRules), with admin == the instrument admin — exactly as it is the TransferFactory the on-ledger transfer path already uses. Resolve TokenRules via findTokenRules(admin) and exercise AllocationFactory_Allocate against it, building the choice context locally (TokenRules + authorizer AccountConfig cids) and acting as the authorizer's account parties plus the admin. The authorizer Account is taken from the picked holdings' own account so it satisfies the impl's inputHolding.account == allocation.authorizer check (self-custodial and provider-scoped). Verified live on a token-standard-v2 LocalNet: the previously-failing allocate now finalizes an Allocation (committed and non-committed), which `token allocations` lists. Scope limited to allocate; withdraw/cancel/ settle unchanged.
Allocation_Withdraw / Allocation_Cancel were malformed: the choice argument omitted the required `actors : [Party]` controller field (AllocationV2.daml), and the choice context was fetched from the scan registry. The TestToken impl reads the local test-token context from extraArgs (unlockTokenAllocationV2 -> getEventLogFromContext, applyAllocationTransitions -> extractAccountConfigMap), so the registry blob was rejected — the same root cause as the allocate fix. Rework runAllocationAction to mirror the allocate factory path: fetch the target Allocation's view to resolve the admin / authorizer account / executors, resolve the issuer's on-ledger TokenRules (event-log + AccountConfig source), build the local choice context, and exercise with the controller `actors` the state machine requires (AccountConfig.daml): withdraw -> the authorizer's account parties, cancel -> the executors. Act as those actors plus the admin (co-signs the unlocked holdings). Extend the Allocation view walker to surface the authorizer account provider/id and the settlement executors. Drop the now-unused registry withdraw/cancel choice-context paths. Live-verified on a token-standard-v2 LocalNet: fresh allocate -> withdraw and allocate -> cancel each consume the Allocation; a committed allocation correctly refuses early withdrawal.
|
Pushed two more allocation-flow fixes addressing the review ( 1. The scan registry returns the network-default (DSO) allocation factory, but an issuer-created test-token instrument requires exercising against the issuer's own on-ledger
2. Both were malformed two ways:
Verification: |
…e, atomic flag Review fixes for the token V2 tightening PR: - activity: keep the newest maxActivityScan events via a sliding ring buffer instead of breaking at the first cap (which returned the oldest slice). Applies to both the EventLog and netting paths. - activity: dedup paired sender/receiver EventLog_HoldingsChange exercises by (updateID, sorted transferLegIds) so a transfer is not double-counted. - activity: fall through to netting when the EventLog path fails in a fallback-safe way (stream open / malformed event); only a cancelled or expired context aborts. - allocations: emit the shared types.AllocationsResponse from both the CLI JSON and the Web UI handler so the two surfaces cannot drift. - allocations: disable the settle action (CLI, HTTP route, Web UI) until SettlementFactory_SettleBatch is functional on LocalNet; keep withdraw/cancel. - ui: thread the active role through the allocations/transfer/allocate flows; add an experimental atomic-transfer control gated on auto-accept. - docs: record the identity/allocations commands, the experimental --atomic flag, and the deferred settle verb in changes-from-proposal.md. Adds tests for newest-first truncation (both paths), paired-side dedup, and EventLog→netting fallback on stream error vs. context cancellation.
|
Addressed the review comments from #277 (review). Pushed as 2dd2d12. Activity feed
DvP allocations
Web UI
Docs (review comment): The execute-grants finding (review comment) and the actors finding (review comment, already threaded in this worktree) were left as-is per discussion. Verification: |
What
Four CIP-0112 (Token Standard V2) features on the Tokens surface, built on a shared foundation that bundles the new V2 DARs (allocation-v2, transfer-events-v2, util-token-standard-wallet), interface constants, and schema-pinned types. All signatures were pinned from the actual Splice 0.6.12 source (commit
17fd29aa).rolethreaded through every token call;GET /api/tokens/identity+dpm localnet token identity.activity.gogains a V2EventLog_HoldingsChange-native path (Amulet declaresinterface instance TransferEventsV2.EventLogat 0.6.12), with the existing netting reconstruction kept as fallback.AllocationFactory_Allocate→ list / withdraw / cancel; new registry client + orchestration +token allocate/allocations/settleCLI + UI panel. Registry endpoints taken from source.--atomictransfer+accept viaBatchingUtility_ExecuteBatch; sequential (partial-recovery) path stays the default.CLI↔Web UI parity maintained throughout (shared
RunXper verb).Static verification (green)
go build ./...,go test ./...,make lint(0 issues).tsc --noEmit,vitest221/221, lint clean.internal/ui/dist/index.htmlplaceholder intact.Live verification (token-standard-v2 alpha) — partial, and why
Brought up a live
token-standard-v2instance and exercised the branch binary:token identityworks live; read/balance path works live.OfferMint → TransferInstruction_Acceptsequence (instrument_v2.go:289, byte-identical tomain) fails against the pinned alpha withDAML_FAILURE: unavailable action TIA_Accept. It reproduces onmain(unit tests use a fake ledger, so it isn't caught there).Because the underlying mint is what fails, the V2-on-ledger paths (batching, and allocations that need holdings) can't be proven end-to-end on this alpha yet. They are statically sound and source-grounded; live proof waits on the mint fix.
Issues discovered during local testing (separate follow-ups)
TIA_Acceptstate-machine mismatch (above). Pre-existing.health-check.shuseswget --spider(HEAD); readyz returns 405 for HEAD / 200 for GET, so the container never flips healthy and status shows "syncing" indefinitely.--port-basemode already pre-checks).Known in-code TODOs
TODO(BIT-ALLOC-SETTLE)— allocation settlement is executor-driven (no registry settle context);RunSettleresolves the settlement-factory context but theFinalizedAllocationbatch wants a live instance.TokenStandardAction/HoldingMapwire-shapes are built from confirmed 0.6.12 signatures, pending live-ledger verification.