From a31313d1cca91c1b347fb5b7d6de01f8fc0741ee Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 19 Aug 2026 12:05:13 +0200 Subject: [PATCH 1/6] refactor(splice): add AllRoleNames as the single fan-out role source `dar upload --all-participants` re-spelled the literal {"sv","app-provider","app-user"} while the token DAR path derived the same list from splice.AllRoles(). A comment asserted the two must match, but nothing enforced it, so the two fan-out paths could silently drift in which participants they target or in what order. Both now derive from splice.AllRoleNames(), making the claim structural. --- internal/cli/localnet/dar/upload.go | 3 ++- internal/splice/jwt.go | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/cli/localnet/dar/upload.go b/internal/cli/localnet/dar/upload.go index ee7bd533..5b93a750 100644 --- a/internal/cli/localnet/dar/upload.go +++ b/internal/cli/localnet/dar/upload.go @@ -9,6 +9,7 @@ import ( adminproto "github.com/bitdynamics-ab/canton-devkit/internal/canton/admin/proto" cdkdar "github.com/bitdynamics-ab/canton-devkit/internal/dar" "github.com/bitdynamics-ab/canton-devkit/internal/localnet" + "github.com/bitdynamics-ab/canton-devkit/internal/splice" "github.com/spf13/cobra" ) @@ -167,7 +168,7 @@ func targetRoles(conn *connectFlags, allPpt bool) ([]string, error) { if conn.Instance == "" { return nil, fmt.Errorf("--all-participants requires --instance") } - return []string{"sv", "app-provider", "app-user"}, nil + return splice.AllRoleNames(), nil } // optString returns a *string for proto3 `optional string` fields. diff --git a/internal/splice/jwt.go b/internal/splice/jwt.go index 15abf88b..68764ee5 100644 --- a/internal/splice/jwt.go +++ b/internal/splice/jwt.go @@ -78,6 +78,18 @@ const ( // AllRoles returns the roles a single LocalNet bring-up issues tokens for. func AllRoles() []Role { return []Role{RoleSV, RoleAppProvider, RoleAppUser} } +// AllRoleNames exists so the participant fan-out paths (`dar upload +// --all-participants`, `token create`) cannot drift in which roles they +// target, or in what order. +func AllRoleNames() []string { + roles := AllRoles() + out := make([]string, len(roles)) + for i, r := range roles { + out[i] = string(r) + } + return out +} + // CredentialInputs is the per-role data needed to construct a JWT: // the subject (`AUTH__VALIDATOR_USER_NAME`) and the audience // (`AUTH__AUDIENCE`). Both come from `env/-auth-on.env`. From 63f5a7f321427fabacf1dbb5db66394652a7e31c Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 19 Aug 2026 12:05:37 +0200 Subject: [PATCH 2/6] refactor(token): probe DAR vetting via the admin API through shared darops The token DAR fan-out carried its own dialling and vetting logic parallel to internal/localnet/darops, and its probe was wrong in ways that could report success while the participant could not host the package: - It called the ledger API's ListKnownPackages, which reports upload, not vetting, so "Vetted ..." overstated what had been checked. It now uses the admin API's ListDars, the same probe darops already relies on. - It matched packages by name alone, so the pinned splice-util-token-standard-wallet 1.1.0 was satisfied by an installed 1.0.0 that lacks BatchingUtilityV2. Matching is now name and version. - It issued one list call per package per role (21 per create). It now issues one per role before uploads and one after. - A failing list RPC was indistinguishable from "package absent", causing redundant uploads and a misleading "still not vetted after upload". List errors now propagate with the failing role and host. - An interrupted cache write could leave a truncated DAR that later runs would trust. Cache entries are now written to a temp file in the same directory and renamed into place. Extracting EnsureVetted into darops lets the token path and `dar upload` share one implementation instead of drifting. --- internal/localnet/darops/vetting.go | 196 +++++++++++ internal/localnet/token/dar_bundle.go | 245 +++++--------- internal/localnet/token/dar_bundle_test.go | 362 +++++++++++++++------ internal/localnet/token/instrument_v2.go | 2 +- 4 files changed, 550 insertions(+), 255 deletions(-) create mode 100644 internal/localnet/darops/vetting.go diff --git a/internal/localnet/darops/vetting.go b/internal/localnet/darops/vetting.go new file mode 100644 index 00000000..e7b4dc1f --- /dev/null +++ b/internal/localnet/darops/vetting.go @@ -0,0 +1,196 @@ +package darops + +import ( + "context" + "fmt" + + "github.com/bitdynamics-ab/canton-devkit/internal/canton/admin" + adminproto "github.com/bitdynamics-ab/canton-devkit/internal/canton/admin/proto" + "github.com/bitdynamics-ab/canton-devkit/internal/registry" + "google.golang.org/grpc" +) + +// Fan-out vetting for a fixed set of DARs. `dar upload +// --all-participants` and `token create` both need "make sure these +// DARs are vetted on every participant"; keeping it here stops the two +// from drifting in how they dial, probe, and report. + +// DARRef identifies one DAR by the (name, version) pair Canton reports +// in ListDars. Matching on the pair — not the name alone — is what +// makes a pinned version meaningful: a participant carrying +// splice-util-token-standard-wallet 1.0.0 must still receive the +// pinned 1.1.0. +type DARRef struct { + Name string + Version string +} + +func (d DARRef) String() string { return d.Name + "-" + d.Version } + +// PackageAdmin is the slice of the Canton admin PackageService that +// vetting needs. adminproto.PackageServiceClient satisfies it; tests +// inject a stub. Signatures match the generated client exactly so the +// real client is assignable. +type PackageAdmin interface { + PackageLister + UploadDar(ctx context.Context, in *adminproto.UploadDarRequest, opts ...grpc.CallOption) (*adminproto.UploadDarResponse, error) +} + +var _ PackageAdmin = adminproto.PackageServiceClient(nil) + +// AdminDialer opens a PackageAdmin for a resolved admin.Config. +// Production callers pass DialPackageAdmin; tests pass a stub. +type AdminDialer func(ctx context.Context, cfg admin.Config) (PackageAdmin, func() error, error) + +// DialPackageAdmin is the production AdminDialer. +func DialPackageAdmin(ctx context.Context, cfg admin.Config) (PackageAdmin, func() error, error) { + client, err := admin.Connect(ctx, cfg) + if err != nil { + return nil, func() error { return nil }, err + } + return client.Package, client.Close, nil +} + +// VetStage distinguishes the two progress events EnsureVetted emits. +type VetStage string + +const ( + // StageUploading is emitted before a DAR is pushed to a participant. + StageUploading VetStage = "uploading" + // StageVetted is emitted after the post-upload ListDars confirms the + // DAR is present, and therefore vetted. + StageVetted VetStage = "vetted" +) + +// VetEvent is one progress notification from EnsureVetted. +type VetEvent struct { + Stage VetStage + Role string + Host string + DAR DARRef +} + +// VetRequest is the input to EnsureVetted. +type VetRequest struct { + State *registry.State + // Roles to vet on, in the caller's preferred order. Empty means Roles. + Roles []string + // DARs is the set every role must end up carrying. + DARs []DARRef + // Load returns the DAR bytes for a ref. Called only for a ref a + // participant is actually missing, so an unreachable source costs + // nothing when everything is already vetted. Callers that vet + // several roles should cache; EnsureVetted does not. + Load func(DARRef) ([]byte, error) + // Description is recorded on the uploaded DAR so `dar list` shows + // where it came from. Optional. + Description string + // OnEvent, when non-nil, receives progress events. Optional. + OnEvent func(VetEvent) +} + +// EnsureVetted uploads every DAR a participant is missing, on every +// requested role, and confirms via ListDars that the upload landed. +// Returns the roles vetted, in the order they were processed. +// +// Unlike ListVetting, a per-role failure aborts rather than being +// recorded: a caller that vets only some participants leaves +// cross-participant workflows broken in a way the user cannot see. +func EnsureVetted(ctx context.Context, dial AdminDialer, req VetRequest) ([]string, error) { + roles := req.Roles + if len(roles) == 0 { + roles = Roles + } + done := make([]string, 0, len(roles)) + for _, role := range roles { + cfg, err := ResolveParticipant(req.State, role, "", true) + if err != nil { + return nil, fmt.Errorf("resolve participant %s: %w", role, err) + } + if err := vetOne(ctx, dial, cfg, role, req); err != nil { + return nil, err + } + done = append(done, role) + } + return done, nil +} + +// vetOne brings a single participant up to the requested DAR set. +func vetOne(ctx context.Context, dial AdminDialer, cfg admin.Config, role string, req VetRequest) error { + client, closeFn, err := dial(ctx, cfg) + if err != nil { + return fmt.Errorf("dial %s (%s): %w", role, cfg.Host, err) + } + defer func() { _ = closeFn() }() + + // One ListDars per role, not per DAR: the set answers every + // membership question for this participant. + present, err := vettedSet(ctx, client) + if err != nil { + return fmt.Errorf("list DARs on %s (%s): %w", role, cfg.Host, err) + } + + missing := make([]DARRef, 0, len(req.DARs)) + for _, d := range req.DARs { + if !present[d] { + missing = append(missing, d) + } + } + if len(missing) == 0 { + return nil + } + + for _, d := range missing { + data, err := req.Load(d) + if err != nil { + return err + } + emit(req.OnEvent, VetEvent{Stage: StageUploading, Role: role, Host: cfg.Host, DAR: d}) + upload := &adminproto.UploadDarRequest_UploadDarData{Bytes: data} + if req.Description != "" { + desc := req.Description + upload.Description = &desc + } + if _, err := client.UploadDar(ctx, &adminproto.UploadDarRequest{ + Dars: []*adminproto.UploadDarRequest_UploadDarData{upload}, + VetAllPackages: true, + SynchronizeVetting: true, + }); err != nil { + return fmt.Errorf("upload %s on %s (%s): %w", d, role, cfg.Host, err) + } + } + + present, err = vettedSet(ctx, client) + if err != nil { + return fmt.Errorf("re-list DARs on %s (%s) after upload: %w", role, cfg.Host, err) + } + for _, d := range missing { + if !present[d] { + return fmt.Errorf("%s is not vetted on %s (%s) after upload", d, role, cfg.Host) + } + emit(req.OnEvent, VetEvent{Stage: StageVetted, Role: role, Host: cfg.Host, DAR: d}) + } + return nil +} + +// vettedSet lists the participant's DARs as a (name, version) set. A +// list failure is returned, never folded into "absent": treating an +// unreachable participant as empty turns a connectivity problem into a +// misleading "still not vetted after upload". +func vettedSet(ctx context.Context, lister PackageLister) (map[DARRef]bool, error) { + resp, err := lister.ListDars(ctx, &adminproto.ListDarsRequest{}) + if err != nil { + return nil, err + } + set := make(map[DARRef]bool, len(resp.GetDars())) + for _, d := range resp.GetDars() { + set[DARRef{Name: d.GetName(), Version: d.GetVersion()}] = true + } + return set, nil +} + +func emit(fn func(VetEvent), ev VetEvent) { + if fn != nil { + fn(ev) + } +} diff --git a/internal/localnet/token/dar_bundle.go b/internal/localnet/token/dar_bundle.go index acbfcf45..d710ca21 100644 --- a/internal/localnet/token/dar_bundle.go +++ b/internal/localnet/token/dar_bundle.go @@ -10,9 +10,7 @@ import ( "path/filepath" "time" - adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" - - "github.com/bitdynamics-ab/canton-devkit/internal/canton/ledger" + "github.com/bitdynamics-ab/canton-devkit/internal/localnet/darops" "github.com/bitdynamics-ab/canton-devkit/internal/registry" "github.com/bitdynamics-ab/canton-devkit/internal/splice" ) @@ -22,23 +20,23 @@ import ( // 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. +// tokenBundleDARs are the prebuilt DARs the test token needs, pinned by +// (name, version) — the pair Canton reports in ListDars. // // Verified against the daml/dars/ tree at the 0.6.12 commit. The wallet // DAR ships three versions; pin 1.1.0, which carries BatchingUtilityV2 // with the full V2 allocation action set. -var tokenBundleDARs = []struct{ pkg, file string }{ - {"splice-api-token-burn-mint-v1", "splice-api-token-burn-mint-v1-1.0.0.dar"}, - {"splice-test-token-v2", "splice-test-token-v2-1.0.0.dar"}, +var tokenBundleDARs = []darops.DARRef{ + {Name: "splice-api-token-burn-mint-v1", Version: "1.0.0"}, + {Name: "splice-test-token-v2", Version: "1.0.0"}, // V2 foundation packages for EventLog history, allocations/DvP and the // BatchingUtilityV2 wallet. - {"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"}, - {"splice-util-token-standard-wallet", "splice-util-token-standard-wallet-1.1.0.dar"}, + {Name: "splice-api-token-transfer-instruction-v2", Version: "1.0.0"}, + {Name: "splice-api-token-allocation-v2", Version: "1.0.0"}, + {Name: "splice-api-token-allocation-instruction-v2", Version: "1.0.0"}, + {Name: "splice-api-token-allocation-request-v2", Version: "1.0.0"}, + {Name: "splice-util-token-standard-wallet", Version: "1.1.0"}, } const darFetchMaxBytes = 64 << 20 // 64 MiB — these DARs are well under 1 MiB @@ -46,6 +44,10 @@ 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" +// darUploadDescription is recorded on each uploaded DAR so `dar list` +// attributes it to token create rather than a manual upload. +const darUploadDescription = "canton-devkit token create" + // 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. @@ -61,154 +63,78 @@ 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") -// 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) -} +var dialTokenDARAdmin darops.AdminDialer = darops.DialPackageAdmin -// 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) - } - return out -} +// darFileName maps a pinned ref to its filename in the upstream +// daml/dars/ tree. +func darFileName(d darops.DARRef) string { return d.String() + ".dar" } -// 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}) - } +func tokenDARRoles() []string { return splice.AllRoleNames() } - commit, err := tokenBundleCommit(opts.Instance) +// ensureTokenDARs vets the test-token bundle on every LocalNet +// participant and returns the roles it vetted. Any unreachable +// participant or upload failure fails create — skipping a role leaves +// counterparty participants without the package, which only surfaces +// later as an opaque mint/transfer error. +func ensureTokenDARs(ctx context.Context, opts CreateOptions, out io.Writer) ([]string, error) { + state, err := registry.Read(opts.Instance) + if err != nil { + return nil, fmt.Errorf("read instance state: %w", err) + } + commit, err := tokenBundleCommit(state) if err != nil { return nil, err } + short := commit + if len(short) > 12 { + short = commit[:12] + } fetched := map[string][]byte{} - load := func(file string) ([]byte, error) { + load := func(d darops.DARRef) ([]byte, error) { + file := darFileName(d) 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) - } - 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, + "package": d.Name, "version": d.Version, "commit": short, }) - dar, err := load(d.file) + b, err := loadDAR(ctx, commit, file) if err != nil { if errors.Is(err, errDARNotPublished) { - return darUnavailableError(d.pkg, instanceSpliceVersion(instance)) + return nil, darUnavailableError(d.Name, state.SpliceVersion) } - 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 on %s (%s): %w", d.file, role, endpoint, err) + return nil, fmt.Errorf("fetch %s: %w", file, 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, "role": role, "endpoint": endpoint, - }) + fetched[file] = b + return b, nil } - 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) + vetted, err := darops.EnsureVetted(ctx, dialTokenDARAdmin, darops.VetRequest{ + State: state, + Roles: tokenDARRoles(), + DARs: tokenBundleDARs, + Load: load, + Description: darUploadDescription, + OnEvent: func(ev darops.VetEvent) { + emit(out, "dar bundle: "+string(ev.Stage), map[string]any{ + "package": ev.DAR.Name, "version": ev.DAR.Version, + "role": ev.Role, "endpoint": ev.Host, + }) + }, + }) if err != nil { - return false + return nil, fmt.Errorf("vet test-token DARs on instance %q: %w", opts.Instance, err) } - for _, p := range resp.GetPackageDetails() { - if p.GetName() == name { - return true - } - } - return false + return vetted, nil } -// Cache write failures are ignored; in-memory bytes suffice for this upload. +// loadDAR returns the DAR bytes, populating a content cache under the +// registry root. The cache is written via a temp file and renamed so a +// process killed mid-write cannot leave a truncated DAR that every +// later run would read back as valid. Cache write failures are ignored; +// the 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 { @@ -218,12 +144,33 @@ func loadDAR(ctx context.Context, commit, file string) ([]byte, error) { if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil { - _ = os.WriteFile(path, b, 0o644) - } + writeDARCache(path, b) return b, nil } +func writeDARCache(path string, b []byte) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + // Same directory as the target so the rename stays within one + // filesystem and is therefore atomic. + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") + if err != nil { + return + } + name := tmp.Name() + _, werr := tmp.Write(b) + cerr := tmp.Close() + if werr != nil || cerr != nil { + _ = os.Remove(name) + return + } + if err := os.Rename(name, path); err != nil { + _ = os.Remove(name) + } +} + func darCachePath(commit, file string) string { return filepath.Join(registry.Root(), darCacheDirName, filepath.Base(commit), filepath.Base(file)) } @@ -231,11 +178,7 @@ func darCachePath(commit, file string) string { // 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). -func tokenBundleCommit(instance string) (string, error) { - state, err := registry.Read(instance) - if err != nil { - return "", fmt.Errorf("read instance state: %w", err) - } +func tokenBundleCommit(state *registry.State) (string, error) { tag := state.SpliceVersion if v, ok := splice.SupportedVersions[tag]; ok && v.Commit != "" { return v.Commit, nil @@ -258,15 +201,6 @@ func darUnavailableError(pkg, version string) error { ErrTokenDARUnavailable, pkg, version) } -// instanceSpliceVersion returns the instance's recorded Splice version -// tag for an error message; best-effort ("" on a read failure). -func instanceSpliceVersion(instance string) string { - if st, err := registry.Read(instance); err == nil { - return st.SpliceVersion - } - return "" -} - // fetchDAR downloads a prebuilt DAR from the upstream repo at a pinned // commit. Size-capped; the DARs are tiny but we never trust a remote // Content-Length. @@ -293,6 +227,3 @@ 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 f39d6513..de9ea7d3 100644 --- a/internal/localnet/token/dar_bundle_test.go +++ b/internal/localnet/token/dar_bundle_test.go @@ -10,12 +10,16 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "sync/atomic" "testing" - adminv2 "github.com/digital-asset/dazl-client/v8/go/api/com/daml/ledger/api/v2/admin" + "google.golang.org/grpc" + "github.com/bitdynamics-ab/canton-devkit/internal/canton/admin" + adminproto "github.com/bitdynamics-ab/canton-devkit/internal/canton/admin/proto" + "github.com/bitdynamics-ab/canton-devkit/internal/localnet/darops" "github.com/bitdynamics-ab/canton-devkit/internal/registry" "github.com/bitdynamics-ab/canton-devkit/internal/splice" ) @@ -23,12 +27,7 @@ import ( func TestTokenBundleCommit_FromCuratedCatalogue(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) s := registry.NewState("demo", "token-standard-v2") - s.ProjectDir = t.TempDir() - s.DataDir = t.TempDir() - if err := registry.Write(s); err != nil { - t.Fatal(err) - } - commit, err := tokenBundleCommit("demo") + commit, err := tokenBundleCommit(s) if err != nil { t.Fatalf("resolve commit: %v", err) } @@ -40,12 +39,7 @@ func TestTokenBundleCommit_FromCuratedCatalogue(t *testing.T) { func TestTokenBundleCommit_UnknownVersionErrors(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) s := registry.NewState("demo", "does-not-exist-9.9.9") - s.ProjectDir = t.TempDir() - s.DataDir = t.TempDir() - if err := registry.Write(s); err != nil { - t.Fatal(err) - } - if _, err := tokenBundleCommit("demo"); err == nil { + if _, err := tokenBundleCommit(s); err == nil { t.Error("want error for an unknown Splice version") } } @@ -63,49 +57,38 @@ func TestTokenDARRoles_MatchesAllRoles(t *testing.T) { } } -// Create uploads the bundle on sv, app-provider, and app-user, not only the create client. +// Create uploads the bundle on sv, app-provider, and app-user, not only +// the participant it dialed to create TokenRules. func TestEnsureTokenDARs_FansOutToAllRoles(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) - seedBundleInstance(t, "demo", allLedgerPorts()) + seedBundleInstance(t, "demo", allAdminPorts()) 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) - } - }) + fakes := withDARDial(t, nil) var out bytes.Buffer - opts := bundleCreateOpts("demo") - roles, err := ensureTokenDARs(context.Background(), create, opts, &out) + roles, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), &out) if err != nil { t.Fatalf("ensureTokenDARs: %v", err) } - if want := []string{"sv", "app-provider", "app-user"}; strings.Join(roles, ",") != strings.Join(want, ",") { + if want := "sv,app-provider,app-user"; strings.Join(roles, ",") != 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) + for _, role := range roles { + f := fakes.byRole(t, role) + if f.uploads != wantUploads { + t.Errorf("uploads on %s=%d, want %d", role, f.uploads, wantUploads) + } + // One ListDars before the uploads and one after, never per DAR. + if f.lists != 2 { + t.Errorf("ListDars calls on %s=%d, want 2", role, f.lists) + } } 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()) @@ -113,31 +96,125 @@ func TestEnsureTokenDARs_FansOutToAllRoles(t *testing.T) { } } +// A DAR already present under the pinned version is not re-uploaded. +func TestEnsureTokenDARs_SkipsAlreadyVetted(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allAdminPorts()) + srv, hits := startDARServer(t) + swapDARBase(t, srv.URL) + + fakes := withDARDial(t, func(f *fakeAdmin) { + for _, d := range tokenBundleDARs { + f.dars[d] = true + } + }) + if _, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), io.Discard); err != nil { + t.Fatalf("ensureTokenDARs: %v", err) + } + for _, role := range tokenDARRoles() { + if f := fakes.byRole(t, role); f.uploads != 0 { + t.Errorf("uploads on %s=%d, want 0 (already vetted)", role, f.uploads) + } + } + if hits.Load() != 0 { + t.Errorf("HTTP fetches=%d, want 0 (nothing missing, nothing fetched)", hits.Load()) + } +} + +// A pinned version must not be satisfied by a different version of the +// same package: the wallet DAR ships 1.0.0 and 1.1.0, and only 1.1.0 +// carries BatchingUtilityV2. +func TestEnsureTokenDARs_WrongVersionIsNotVetted(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allAdminPorts()) + srv, _ := startDARServer(t) + swapDARBase(t, srv.URL) + + wallet := darops.DARRef{Name: "splice-util-token-standard-wallet", Version: "1.1.0"} + fakes := withDARDial(t, func(f *fakeAdmin) { + for _, d := range tokenBundleDARs { + f.dars[d] = true + } + delete(f.dars, wallet) + f.dars[darops.DARRef{Name: wallet.Name, Version: "1.0.0"}] = true + }) + if _, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), io.Discard); err != nil { + t.Fatalf("ensureTokenDARs: %v", err) + } + for _, role := range tokenDARRoles() { + if f := fakes.byRole(t, role); f.uploads != 1 { + t.Errorf("uploads on %s=%d, want 1 (pinned wallet version missing)", role, f.uploads) + } + } +} + func TestEnsureTokenDARs_MissingPortFails(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) seedBundleInstance(t, "demo", map[string]int{ - "participant_ledger_app-provider": 3901, + "participant_admin_app-provider": 3902, }) - _, err := ensureTokenDARs(context.Background(), newFakeDAR(), bundleCreateOpts("demo"), io.Discard) + withDARDial(t, nil) + _, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), io.Discard) if err == nil { - t.Fatal("want error when a role has no ledger port") + t.Fatal("want error when a role has no admin port") } - if !strings.Contains(err.Error(), "sv") || !strings.Contains(err.Error(), "participant_ledger_sv") { + if !strings.Contains(err.Error(), "sv") || !strings.Contains(err.Error(), "participant_admin") { t.Errorf("want missing-port error naming sv, got: %v", err) } } +// A failing ListDars must surface as a list error, not be folded into +// "absent" and reported later as a bogus post-upload vetting failure. +func TestEnsureTokenDARs_ListErrorSurfaces(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allAdminPorts()) + srv, _ := startDARServer(t) + swapDARBase(t, srv.URL) + + withDARDial(t, func(f *fakeAdmin) { + if f.role == "app-user" { + f.listErr = errors.New("unavailable") + } + }) + _, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), io.Discard) + if err == nil { + t.Fatal("want error when ListDars fails") + } + if !strings.Contains(err.Error(), "list DARs") || !strings.Contains(err.Error(), "unavailable") { + t.Errorf("want a list error, got: %v", err) + } + if strings.Contains(err.Error(), "after upload") { + t.Errorf("list failure must not be reported as a post-upload vetting failure: %v", err) + } +} + +// An upload that does not land must fail create rather than report the +// role as vetted. +func TestEnsureTokenDARs_UploadNotReflectedFails(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + seedBundleInstance(t, "demo", allAdminPorts()) + srv, _ := startDARServer(t) + swapDARBase(t, srv.URL) + + withDARDial(t, func(f *fakeAdmin) { f.silentUpload = true }) + _, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), io.Discard) + if err == nil { + t.Fatal("want error when an upload does not land") + } + if !strings.Contains(err.Error(), "not vetted") || !strings.Contains(err.Error(), "after upload") { + t.Errorf("want a post-upload vetting error, got: %v", err) + } +} + func TestEnsureTokenDARs_CachesDAROnDisk(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) - seedBundleInstance(t, "demo", allLedgerPorts()) + seedBundleInstance(t, "demo", allAdminPorts()) srv, hits := startDARServer(t) swapDARBase(t, srv.URL) - withDARDial(t, func(_ context.Context, conn LedgerConn) (darClient, func(), error) { - return newFakeDAR(), func() {}, nil - }) + withDARDial(t, nil) opts := bundleCreateOpts("demo") - if _, err := ensureTokenDARs(context.Background(), newFakeDAR(), opts, io.Discard); err != nil { + if _, err := ensureTokenDARs(context.Background(), opts, io.Discard); err != nil { t.Fatalf("first ensureTokenDARs: %v", err) } firstHits := hits.Load() @@ -145,18 +222,25 @@ func TestEnsureTokenDARs_CachesDAROnDisk(t *testing.T) { t.Fatalf("first pass HTTP fetches=%d, want %d", firstHits, len(tokenBundleDARs)) } - commit, err := tokenBundleCommit("demo") + state, err := registry.Read("demo") + if err != nil { + t.Fatal(err) + } + commit, err := tokenBundleCommit(state) if err != nil { t.Fatal(err) } for _, d := range tokenBundleDARs { - p := darCachePath(commit, d.file) + p := darCachePath(commit, darFileName(d)) 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 { + // Fresh participants (nothing vetted) so the second pass would refetch + // were the disk cache not consulted. + withDARDial(t, nil) + if _, err := ensureTokenDARs(context.Background(), opts, io.Discard); err != nil { t.Fatalf("second ensureTokenDARs: %v", err) } if hits.Load() != firstHits { @@ -164,21 +248,45 @@ func TestEnsureTokenDARs_CachesDAROnDisk(t *testing.T) { } } +// The cache is written via rename, so a killed process leaves either no +// file or a complete one — never a truncated DAR a later run trusts. +func TestWriteDARCache_LeavesNoPartialFile(t *testing.T) { + t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) + p := darCachePath("abc123", "splice-test-token-v2-1.0.0.dar") + writeDARCache(p, []byte("dar-bytes")) + + got, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read cache: %v", err) + } + if string(got) != "dar-bytes" { + t.Errorf("cache content=%q, want %q", got, "dar-bytes") + } + entries, err := os.ReadDir(filepath.Dir(p)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + t.Errorf("cache dir holds %v, want only the final file", names) + } +} + func TestEnsureTokenDARs_SecondaryRoleUploadFails(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) - seedBundleInstance(t, "demo", allLedgerPorts()) + seedBundleInstance(t, "demo", allAdminPorts()) 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 + withDARDial(t, func(f *fakeAdmin) { + if f.role == "app-user" { + f.uploadErr = errors.New("boom") } - return newFakeDAR(), func() {}, nil }) - _, err := ensureTokenDARs(context.Background(), newFakeDAR(), bundleCreateOpts("demo"), io.Discard) + _, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), io.Discard) if err == nil { t.Fatal("want error when app-user upload fails") } @@ -189,21 +297,28 @@ func TestEnsureTokenDARs_SecondaryRoleUploadFails(t *testing.T) { func TestEnsureTokenDARs_404IsUnavailable(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) - seedBundleInstance(t, "demo", allLedgerPorts()) + seedBundleInstance(t, "demo", allAdminPorts()) 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) + withDARDial(t, nil) + _, err := ensureTokenDARs(context.Background(), bundleCreateOpts("demo"), io.Discard) if !errors.Is(err, ErrTokenDARUnavailable) { t.Fatalf("want ErrTokenDARUnavailable, got %v", err) } } +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) + } +} + func seedBundleInstance(t *testing.T, name string, ports map[string]int) { t.Helper() s := registry.NewState(name, "0.6.12") @@ -211,16 +326,19 @@ func seedBundleInstance(t *testing.T, name string, ports map[string]int) { s.DataDir = t.TempDir() s.Status = registry.StatusRunning s.Ports = ports + for _, role := range tokenDARRoles() { + s.Credentials[role] = registry.Credential{Role: role, JWT: "jwt-" + role} + } if err := registry.Write(s); err != nil { t.Fatal(err) } } -func allLedgerPorts() map[string]int { +func allAdminPorts() map[string]int { return map[string]int{ - "participant_ledger_sv": 4901, - "participant_ledger_app-provider": 3901, - "participant_ledger_app-user": 2901, + "participant_admin_sv": 4902, + "participant_admin_app-provider": 3902, + "participant_admin_app-user": 2902, } } @@ -251,50 +369,100 @@ func swapDARBase(t *testing.T, url string) { t.Cleanup(func() { darBundleBaseURL = prev }) } -func withDARDial(t *testing.T, fn func(context.Context, LedgerConn) (darClient, func(), error)) { +// adminPortRole inverts allAdminPorts so the dialer, which only sees a +// host:port, can label each fake with the role it stands in for. +func adminPortRole(host string) string { + for role, port := range allAdminPorts() { + if strings.HasSuffix(host, ":"+strconv.Itoa(port)) { + return strings.TrimPrefix(role, "participant_admin_") + } + } + return "" +} + +// darFakes collects the per-role fakes a test run dialed. +type darFakes struct { + byHost map[string]*fakeAdmin +} + +func (d *darFakes) byRole(t *testing.T, role string) *fakeAdmin { t.Helper() - prev := dialDARClient - dialDARClient = fn - t.Cleanup(func() { dialDARClient = prev }) + for _, f := range d.byHost { + if f.role == role { + return f + } + } + t.Fatalf("no participant dialed for role %q", role) + return nil } -type fakeDAR struct { - packages map[string]struct{} - uploads int +// withDARDial swaps the admin dialer for per-role fakes. customise, when +// non-nil, seeds each fake before it serves any call. +func withDARDial(t *testing.T, customise func(*fakeAdmin)) *darFakes { + t.Helper() + fakes := &darFakes{byHost: map[string]*fakeAdmin{}} + prev := dialTokenDARAdmin + dialTokenDARAdmin = func(_ context.Context, cfg admin.Config) (darops.PackageAdmin, func() error, error) { + f, ok := fakes.byHost[cfg.Host] + if !ok { + f = newFakeAdmin(adminPortRole(cfg.Host)) + if customise != nil { + customise(f) + } + fakes.byHost[cfg.Host] = f + } + return f, func() error { return nil }, nil + } + t.Cleanup(func() { dialTokenDARAdmin = prev }) + return fakes +} + +type fakeAdmin struct { + role string + dars map[darops.DARRef]bool + uploads int + lists int + uploadErr error + listErr error + // silentUpload accepts an upload without recording the DAR, standing + // in for a vetting transaction that never lands. + silentUpload bool } -func newFakeDAR() *fakeDAR { - return &fakeDAR{packages: map[string]struct{}{}} +func newFakeAdmin(role string) *fakeAdmin { + return &fakeAdmin{role: role, dars: map[darops.DARRef]bool{}} } -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}) +func (f *fakeAdmin) ListDars(context.Context, *adminproto.ListDarsRequest, ...grpc.CallOption) (*adminproto.ListDarsResponse, error) { + f.lists++ + if f.listErr != nil { + return nil, f.listErr } - return &adminv2.ListKnownPackagesResponse{PackageDetails: details}, nil + dars := make([]*adminproto.DarDescription, 0, len(f.dars)) + for d := range f.dars { + dars = append(dars, &adminproto.DarDescription{ + Main: d.String(), Name: d.Name, Version: d.Version, + }) + } + return &adminproto.ListDarsResponse{Dars: dars}, nil } -func (f *fakeDAR) UploadDarFile(_ context.Context, req *adminv2.UploadDarFileRequest) (*adminv2.UploadDarFileResponse, error) { +func (f *fakeAdmin) UploadDar(_ context.Context, req *adminproto.UploadDarRequest, _ ...grpc.CallOption) (*adminproto.UploadDarResponse, 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{}{} - } + if f.silentUpload { + return &adminproto.UploadDarResponse{}, nil } - 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) + for _, up := range req.GetDars() { + file := strings.TrimPrefix(string(up.GetBytes()), "dar:") + for _, d := range tokenBundleDARs { + if darFileName(d) == file { + f.dars[d] = true + } + } } + return &adminproto.UploadDarResponse{}, nil } diff --git a/internal/localnet/token/instrument_v2.go b/internal/localnet/token/instrument_v2.go index 4d194f82..3bf3e041 100644 --- a/internal/localnet/token/instrument_v2.go +++ b/internal/localnet/token/instrument_v2.go @@ -54,7 +54,7 @@ func ensureTokenRules(out io.Writer, opts CreateOptions) ([]string, error) { // 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) + vetted, err := ensureTokenDARs(ctx, opts, out) if err != nil { return nil, err } From 8f40088f0e3800758d668f8a28a1e1193efdc0f0 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 19 Aug 2026 12:05:48 +0200 Subject: [PATCH 3/6] fix(token): keep the auth token on the accept-leg connection Both auto-accept paths rebuilt a LedgerConn from the options to target the receiver's participant, but the rebuilt value omitted Token. The accept leg therefore dialled unauthenticated whenever the caller had supplied a JWT, failing against an auth-on LocalNet. Both blocks now pass the in-scope conn, which already carries Token. --- internal/localnet/token/run_transfer_onledger.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/internal/localnet/token/run_transfer_onledger.go b/internal/localnet/token/run_transfer_onledger.go index 528671a1..40e19d47 100644 --- a/internal/localnet/token/run_transfer_onledger.go +++ b/internal/localnet/token/run_transfer_onledger.go @@ -175,11 +175,7 @@ func runTransferLiveOnLedger(ctx context.Context, out io.Writer, opts TransferOp // 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 { + if aconn := resolveAcceptConn(conn, 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) @@ -261,11 +257,7 @@ func runAcceptOnLedgerIfTestToken(ctx context.Context, out io.Writer, opts Accep // 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 { + if aconn := resolveAcceptConn(conn, opts.Instance, receiver.Owner); aconn.Role != opts.Role { var aerr error acceptClient, acceptCleanup, aerr = dialLedgerConcreteFn(ctx, aconn) if aerr != nil { From 26350e9562e73d50ee2c4571dcda1b7aca5c743b Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 19 Aug 2026 12:06:02 +0200 Subject: [PATCH 4/6] feat(token): expose vetted_roles on both token create JSON surfaces Which participants a create vetted was only ever printed as prose, so neither `token create --format json` nor POST /api/tokens could report it. Scripts and the Web UI had no way to tell a fully vetted create from a registry-only one. Both surfaces now encode a shared types.TokenCreateResponse carrying schema_version and vetted_roles, so they cannot drift. TokenRef stays embedded rather than nested, keeping the instrument's fields where they have always been on the wire. vetted_roles is omitted for a registry-only create, which vets nothing. --- internal/api/types/schema_pin_test.go | 1 + internal/api/types/tokens.go | 10 ++++ internal/cli/localnet/token/create.go | 2 +- .../localnet/token/create_response_test.go | 60 +++++++++++++++++++ internal/localnet/token/token.go | 15 ++++- internal/ui/handlers/tokens.go | 2 +- 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 internal/localnet/token/create_response_test.go diff --git a/internal/api/types/schema_pin_test.go b/internal/api/types/schema_pin_test.go index 3e4ddfba..4bf5d243 100644 --- a/internal/api/types/schema_pin_test.go +++ b/internal/api/types/schema_pin_test.go @@ -30,6 +30,7 @@ func TestAllTopLevelResponses_CarrySchemaVersion(t *testing.T) { SkillsInstallResponse{}, SkillsListResponse{}, Snapshot{}, + TokenCreateResponse{}, TokenHoldingsResponse{}, TokenIdentityResponse{}, TokenListResponse{}, diff --git a/internal/api/types/tokens.go b/internal/api/types/tokens.go index cc8d3e11..690e2143 100644 --- a/internal/api/types/tokens.go +++ b/internal/api/types/tokens.go @@ -38,6 +38,16 @@ type TokenRef struct { Status string `json:"status"` } +// TokenCreateResponse is POST /api/tokens and `token create --format +// json`. TokenRef is embedded, not nested, so the instrument's fields +// stay where they have always been on the wire. +type TokenCreateResponse struct { + SchemaVersion int `json:"schema_version"` + TokenRef + // Absent on a registry-only create (no endpoint), which vets nothing. + VettedRoles []string `json:"vetted_roles,omitempty"` +} + // InstrumentRef is a token instrument discovered on-ledger (ACS) for the // instrument list. Field order/types mirror // internal/localnet/token.InstrumentRef so the two convert directly. diff --git a/internal/cli/localnet/token/create.go b/internal/cli/localnet/token/create.go index 8d402230..a2b8b1af 100644 --- a/internal/cli/localnet/token/create.go +++ b/internal/cli/localnet/token/create.go @@ -89,7 +89,7 @@ POST /api/tokens.`, if format == "json" { enc := json.NewEncoder(out) enc.SetIndent("", " ") - if err := enc.Encode(res.TokenRef); err != nil { + if err := enc.Encode(res.Response()); err != nil { return fmt.Errorf("encode result: %w", err) } } diff --git a/internal/localnet/token/create_response_test.go b/internal/localnet/token/create_response_test.go new file mode 100644 index 00000000..bab78938 --- /dev/null +++ b/internal/localnet/token/create_response_test.go @@ -0,0 +1,60 @@ +package token + +import ( + "encoding/json" + "testing" + + "github.com/bitdynamics-ab/canton-devkit/internal/api/types" + "github.com/bitdynamics-ab/canton-devkit/internal/registry" +) + +// The vetted participants must reach both surfaces, not only the text +// output: a CI consumer of `--format json` or the Web UI has no other +// way to see which participants received the DARs. +func TestCreateResult_ResponseCarriesVettedRoles(t *testing.T) { + res := &CreateResult{ + TokenRef: registry.TokenRef{Symbol: "RTK", InstrumentID: "RTK"}, + VettedRoles: []string{"sv", "app-provider", "app-user"}, + } + raw, err := json.Marshal(res.Response()) + if err != nil { + t.Fatal(err) + } + var got struct { + SchemaVersion int `json:"schema_version"` + Symbol string `json:"symbol"` + InstrumentID string `json:"instrument_id"` + VettedRoles []string `json:"vetted_roles"` + } + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if got.SchemaVersion != types.SchemaVersion { + t.Errorf("schema_version=%d, want %d", got.SchemaVersion, types.SchemaVersion) + } + // TokenRef is embedded, so its fields stay at the top level where + // existing consumers already read them. + if got.Symbol != "RTK" || got.InstrumentID != "RTK" { + t.Errorf("TokenRef fields not flattened: %s", raw) + } + if len(got.VettedRoles) != 3 { + t.Errorf("vetted_roles=%v, want 3 roles", got.VettedRoles) + } +} + +// A registry-only create vets nothing, so the key is omitted rather +// than emitted as an empty list that reads like a failed vet. +func TestCreateResult_ResponseOmitsEmptyVettedRoles(t *testing.T) { + res := &CreateResult{TokenRef: registry.TokenRef{Symbol: "RTK"}} + raw, err := json.Marshal(res.Response()) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if _, present := got["vetted_roles"]; present { + t.Errorf("vetted_roles present on a registry-only create: %s", raw) + } +} diff --git a/internal/localnet/token/token.go b/internal/localnet/token/token.go index e53010ef..942f4923 100644 --- a/internal/localnet/token/token.go +++ b/internal/localnet/token/token.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/bitdynamics-ab/canton-devkit/internal/api/types" "github.com/bitdynamics-ab/canton-devkit/internal/registry" ) @@ -62,6 +63,18 @@ type CreateOptions struct { // registry.TokenRef shape (which mirrors api/types.TokenRef). type CreateResult struct { TokenRef registry.TokenRef + // Empty for a registry-only create (no --endpoint), which vets nothing. + VettedRoles []string +} + +// Response is the wire shape both surfaces emit, so `token create +// --format json` and POST /api/tokens cannot drift. +func (r *CreateResult) Response() types.TokenCreateResponse { + return types.TokenCreateResponse{ + SchemaVersion: types.SchemaVersion, + TokenRef: types.TokenRef(r.TokenRef), + VettedRoles: r.VettedRoles, + } } // RunCreate validates the inputs, generates a deterministic @@ -224,7 +237,7 @@ func RunCreate(out io.Writer, opts CreateOptions) (*CreateResult, error) { "uploaded). Subsequent commands can resolve --instrument by symbol.") } } - return &CreateResult{TokenRef: ref}, nil + return &CreateResult{TokenRef: ref, VettedRoles: vettedRoles}, nil } // ListTokens reads the per-instance Tokens registry. Returns an empty diff --git a/internal/ui/handlers/tokens.go b/internal/ui/handlers/tokens.go index f8fe62c2..a3fc6f73 100644 --- a/internal/ui/handlers/tokens.go +++ b/internal/ui/handlers/tokens.go @@ -432,7 +432,7 @@ func handleTokensCreate(w http.ResponseWriter, r *http.Request) { mapTokenError(w, err, "create") return } - writeJSON(w, http.StatusCreated, res.TokenRef) + writeJSON(w, http.StatusCreated, res.Response()) } // handleTokensDemo provisions a live demo token in one call (issuer → From 113af019f467f0f76d842788b184667cfaa7857f Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 19 Aug 2026 12:06:14 +0200 Subject: [PATCH 5/6] docs: record the corrected vetting claim and the vetted_roles field AGENTS.md requires the proposal-deviation entry to track user-facing behaviour. The existing `token create` entry described a ledger-port failure mode that is now an admin-port one, and predates both the vetted_roles JSON field and the atomic DAR cache write. --- docs/changes-from-proposal.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/changes-from-proposal.md b/docs/changes-from-proposal.md index e8afc123..60ee5975 100644 --- a/docs/changes-from-proposal.md +++ b/docs/changes-from-proposal.md @@ -451,11 +451,12 @@ The embedded skill docs are the same artifacts that back the Web UI's Agent Skil **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: +**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. Four 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 create --format json` and `POST /api/tokens` return the same body, which now carries `schema_version` and a `vetted_roles` array. `vetted_roles` is omitted on a registry-only create (no `--endpoint`), which vets nothing. The instrument's own fields keep their existing top-level position. +- The DARs are cached under `~/.canton-devkit/localnet/.dar-cache//`, so repeat runs (and offline runs after the first) do not re-download them. Each cached file is written to a temporary name and renamed into place, so an interrupted run cannot leave a truncated DAR that later runs would trust. +- `token create` now fails with an actionable error when any role's participant admin 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. From 4ba3b55a0969720b15460c640425e50d938a6010 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 19 Aug 2026 13:48:52 +0200 Subject: [PATCH 6/6] fix(token): claim "known" where only ListKnownPackages was checked resolvePackageID and discoverTokenSurfaces both call the ledger API's ListKnownPackages, which proves a package was uploaded to the participant. It does not prove the package is vetted -- vetting is a topology fact, and darops.EnsureVetted reads it through the admin API. Both functions and the four user-facing errors that inherit from them nonetheless told the operator a package was or was not "vetted". That sends someone debugging a routing failure to look at vetting state that was never actually checked, which is the opposite of what the message should do. Reword the claims to match what the call proves, and record on discoverTokenSurfaces which API to reach for when the vetting state itself is what matters. No behaviour change: the same packages are found by the same call. --- internal/localnet/token/instrument_v2.go | 6 +++--- internal/localnet/token/ledger.go | 13 +++++++------ internal/localnet/token/run_transfer.go | 4 ++-- internal/localnet/token/workspace.go | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/localnet/token/instrument_v2.go b/internal/localnet/token/instrument_v2.go index 3bf3e041..628015b3 100644 --- a/internal/localnet/token/instrument_v2.go +++ b/internal/localnet/token/instrument_v2.go @@ -569,8 +569,8 @@ func boolValue(b bool) *lapiv2.Value { return &lapiv2.Value{Sum: &lapiv2.Value_Bool{Bool: b}} } -// resolvePackageID returns the concrete package id of the vetted package -// with the given package-name. Creates and template-filtered ACS queries +// resolvePackageID returns the concrete package id for the given +// package-name. Creates and template-filtered ACS queries // need the concrete id (the `#name` form resolves only for // interface-choice exercises); resolving at runtime stays robust across // the V2 alpha's weekly snapshot rotation. @@ -584,7 +584,7 @@ func resolvePackageID(ctx context.Context, client *ledger.Client, name string) ( return p.GetPackageId(), nil } } - return "", fmt.Errorf("package %q not vetted on this participant — "+ + return "", fmt.Errorf("package %q not known to this participant — "+ "upload it first (`localnet dar upload <%s.dar>`)", name, name) } diff --git a/internal/localnet/token/ledger.go b/internal/localnet/token/ledger.go index f5d3db60..5082adb1 100644 --- a/internal/localnet/token/ledger.go +++ b/internal/localnet/token/ledger.go @@ -367,26 +367,27 @@ func (g Generation) String() string { } // Surfaces is the set of token-standard generations whose Holding -// interface package is vetted on a participant. A participant can carry +// interface package is known to a participant. A participant can carry // both at once during the V1→V2 transition. type Surfaces struct { HasV1 bool HasV2 bool // HasEventLog is set when splice-api-token-transfer-events-v2 (the - // EventLog interface) is vetted. When present, the activity feed can + // EventLog interface) is known. When present, the activity feed can // read the admin's authoritative change history instead of netting // HoldingV2 create/archive deltas itself. HasEventLog bool } -// Any reports whether any token-standard holding package is vetted. +// Any reports whether any token-standard holding package is known. func (s Surfaces) Any() bool { return s.HasV1 || s.HasV2 } // discoverTokenSurfaces checks which Holding interface packages the -// participant has vetted — the basis for per-instrument generation -// routing. A generation is available only when its package is present (no -// implicit fallback). +// participant knows — the basis for per-instrument generation routing. A +// generation is available only when its package is present (no implicit +// fallback). ListKnownPackages proves upload, not synchronizer vetting; +// use darops.EnsureVetted when the vetting state itself matters. func discoverTokenSurfaces(ctx context.Context, client LedgerClient) (Surfaces, error) { resp, err := client.ListKnownPackages(ctx) if err != nil { diff --git a/internal/localnet/token/run_transfer.go b/internal/localnet/token/run_transfer.go index 8a577aba..3784424e 100644 --- a/internal/localnet/token/run_transfer.go +++ b/internal/localnet/token/run_transfer.go @@ -272,7 +272,7 @@ func instructionGeneration(ctx context.Context, client *ledger.Client, instructi return 0, err } if !surfaces.Any() { - return 0, fmt.Errorf("no token-standard package vetted on this participant") + return 0, fmt.Errorf("no token-standard package known to this participant") } end, err := client.LedgerEnd(ctx) if err != nil { @@ -328,7 +328,7 @@ func listSenderHoldings(ctx context.Context, client *ledger.Client, sender, inst return nil, err } if !surfaces.Any() { - return nil, fmt.Errorf("no token-standard Holding package vetted on this participant") + return nil, fmt.Errorf("no token-standard Holding package known to this participant") } end, err := client.LedgerEnd(ctx) if err != nil { diff --git a/internal/localnet/token/workspace.go b/internal/localnet/token/workspace.go index e1be6139..641921dd 100644 --- a/internal/localnet/token/workspace.go +++ b/internal/localnet/token/workspace.go @@ -106,7 +106,7 @@ func scanWorkspace(ctx context.Context, opts BalanceOptions) (*Workspace, error) return nil, err } if !surfaces.Any() { - return nil, fmt.Errorf("instance %q has no token-standard Holding package vetted (neither V1 nor V2) — token operations are unavailable", opts.Instance) + return nil, fmt.Errorf("instance %q has no token-standard Holding package (neither V1 nor V2) — token operations are unavailable", opts.Instance) } end, err := client.LedgerEnd(ctx)