Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/changes-from-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<splice-commit>/`, 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/<splice-commit>/`, 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.

Expand Down
1 change: 1 addition & 0 deletions internal/api/types/schema_pin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestAllTopLevelResponses_CarrySchemaVersion(t *testing.T) {
SkillsInstallResponse{},
SkillsListResponse{},
Snapshot{},
TokenCreateResponse{},
TokenHoldingsResponse{},
TokenIdentityResponse{},
TokenListResponse{},
Expand Down
10 changes: 10 additions & 0 deletions internal/api/types/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/localnet/dar/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/localnet/token/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
196 changes: 196 additions & 0 deletions internal/localnet/darops/vetting.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
60 changes: 60 additions & 0 deletions internal/localnet/token/create_response_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading