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