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. 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/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/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/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/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/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..628015b3 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 } @@ -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/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 { 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/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) 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`. 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 →