- Experimental and not yet supported on current Splice: the accept leg can’t
- reference the transfer leg’s instruction within one batch, so the submit
- errors and nothing commits. Uncheck to use the working sequential offer→accept path.
-
- )}
{plan && (
@@ -1111,9 +1128,20 @@ function AllocationsPanel({
}
// KPI strip from one ACS scan. Circulating == total supply on a UTXO ledger.
+//
+// `token create` records a declared initial supply but mints nothing, so a
+// freshly created instrument reads 0. Annotate the supply tile with what
+// was declared while the two differ, so that zero is explained rather than
+// looking broken; once the declared amount is fully minted the note drops
+// away and the tile is just the number.
function KpiRow({ s }: { s: InstrumentSummary }) {
const cards: Array<{ label: string; value: string; full?: string; hint?: string }> = [
- { label: "Total supply", value: statAmount(s.total_supply), full: s.total_supply },
+ {
+ label: "Total supply",
+ value: statAmount(s.total_supply),
+ full: s.total_supply,
+ hint: supplyNote(s),
+ },
{ label: "In circulation", value: statAmount(s.total_supply), full: s.total_supply, hint: "sum of all holdings" },
{ label: "Holders", value: String(s.holder_count) },
{ label: "Holding contracts", value: String(s.contract_count), hint: "UTXOs" },
@@ -1162,6 +1190,34 @@ function KpiRow({ s }: { s: InstrumentSummary }) {
);
}
+// supplyNote annotates the supply tile while minted and declared disagree:
+// "declared 2,001" under the cap, and an explicit over-supply note for
+// instruments minted past it before the cap was enforced. Returns undefined
+// once the two agree, so a settled instrument is just its number.
+function supplyNote(s: InstrumentSummary): string | undefined {
+ if (!s.declared_supply) return undefined;
+ const declared = Number(s.declared_supply);
+ const minted = Number(s.total_supply || "0");
+ if (!Number.isFinite(declared) || !Number.isFinite(minted)) return undefined;
+ if (minted === declared) return undefined;
+ if (minted > declared) {
+ return `declared ${statAmount(s.declared_supply)} · over by ${statAmount(String(minted - declared))}`;
+ }
+ return `declared ${statAmount(s.declared_supply)}`;
+}
+
+// remainingToMint reports how much of the declared initial supply has not
+// been minted yet, or "" when nothing is outstanding (or no supply was
+// declared). Drives both the supply-tile annotation and the mint CTA.
+function remainingToMint(s: InstrumentSummary): string {
+ if (!s.declared_supply) return "";
+ const declared = Number(s.declared_supply);
+ const minted = Number(s.total_supply || "0");
+ if (!Number.isFinite(declared) || !Number.isFinite(minted)) return "";
+ const rem = declared - minted;
+ return rem > 0 ? String(rem) : "";
+}
+
// Group thousands, cap at two decimals; non-numeric strings pass through.
function statAmount(raw: string): string {
const n = Number(raw);
diff --git a/internal/cli/localnet/token/faucet.go b/internal/cli/localnet/token/faucet.go
index b51d4ef9..97bf7e47 100644
--- a/internal/cli/localnet/token/faucet.go
+++ b/internal/cli/localnet/token/faucet.go
@@ -19,9 +19,10 @@ func buildFaucet() *cobra.Command {
Short: "Fund a party from a well-known source (auto-accepted)",
Long: `Transfer
of an instrument from a funded source party to
, auto-accepting the resulting TransferInstruction so the target is
-funded in one step. The source defaults to the role's own funded party
-(e.g. app-user holds the network's Amulet) — pass --source to fund from a
-different holder, such as a created token's issuer.
+funded in one step. The source defaults to the instrument's largest current
+holder — the network's Amulet party for Amulet, or wherever a created
+token's supply was minted — so pass --source only to fund from a specific
+holder.
and --source accept aliases. Requires --endpoint and --instrument.`,
Args: cobra.ExactArgs(2),
@@ -38,7 +39,7 @@ different holder, such as a created token's issuer.
}
cmd.Flags().StringVar(&opts.Instance, "instance", "", "Instance name. Required.")
cmd.Flags().StringVar(&opts.Instrument, "instrument", "", "Instrument symbol or raw id. Required.")
- cmd.Flags().StringVar(&opts.Source, "source", "", "Funding party (alias or id). Empty defaults to the role's own funded party.")
+ cmd.Flags().StringVar(&opts.Source, "source", "", "Funding party (alias or id). Empty defaults to the instrument's largest holder.")
cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Participant gRPC endpoint (host:port). Required for the live transfer.")
cmd.Flags().StringVar(&opts.Token, "token", "", "Bearer JWT. Empty auto-issues a per-role token.")
cmd.Flags().StringVar(&opts.Role, "role", "app-user", "Role whose JWT authenticates the submit.")
diff --git a/internal/localnet/token/actions.go b/internal/localnet/token/actions.go
index e46694c2..9e638713 100644
--- a/internal/localnet/token/actions.go
+++ b/internal/localnet/token/actions.go
@@ -206,7 +206,8 @@ const (
// ErrUnsupportedOnInstrument: Amulet doesn't implement
// BurnMintFactoryV1 and there's no generic V2 mint interface.
func RunMint(ctx context.Context, out io.Writer, opts MintOptions) error {
- if err := requireFields("mint", opts.Instance, opts.Instrument, opts.To, opts.Amount); err != nil {
+ if err := requireFields("mint", "instance", opts.Instance, "instrument", opts.Instrument,
+ "recipient party", opts.To, "amount", opts.Amount); err != nil {
return err
}
opts.To = ResolveAlias(aliasMapForInstance(opts.Instance), opts.To)
@@ -229,6 +230,9 @@ func RunMint(ctx context.Context, out io.Writer, opts MintOptions) error {
// Live mint only for on-ledger test-token instruments. Amulet and
// registry-only instruments have no asset-specific mint.
if opts.Endpoint != "" && ref.Status == "on-ledger" {
+ if err := enforceSupplyCap(ctx, opts, ref); err != nil {
+ return err
+ }
return runMintLive(ctx, out, opts, ref)
}
emit(out, "mint", map[string]any{
@@ -242,7 +246,8 @@ func RunMint(ctx context.Context, out io.Writer, opts MintOptions) error {
// ErrNeedsV2LocalNet so callers that haven't been updated to thread
// through the endpoint get a clear remediation.
func RunTransfer(ctx context.Context, out io.Writer, opts TransferOptions) error {
- if err := requireFields("transfer", opts.Instance, opts.Instrument, opts.From, opts.To, opts.Amount); err != nil {
+ if err := requireFields("transfer", "instance", opts.Instance, "instrument", opts.Instrument,
+ "sender party", opts.From, "recipient party", opts.To, "amount", opts.Amount); err != nil {
return err
}
aliases := aliasMapForInstance(opts.Instance)
@@ -336,7 +341,8 @@ func runTransferOffLedger(ctx context.Context, out io.Writer, opts TransferOptio
}
func RunAccept(ctx context.Context, out io.Writer, opts AcceptOptions) error {
- if err := requireFields("transfer accept", opts.Instance, opts.TransferInstructionID); err != nil {
+ if err := requireFields("transfer accept", "instance", opts.Instance,
+ "transfer instruction id", opts.TransferInstructionID); err != nil {
return err
}
// Resolve a --party alias to its full party id: both the on-ledger
@@ -373,7 +379,8 @@ func RunAccept(ctx context.Context, out io.Writer, opts AcceptOptions) error {
// account. Amulet / registry-only instruments have no such path and
// yield ErrUnsupportedOnInstrument.
func RunBurn(ctx context.Context, out io.Writer, opts BurnOptions) error {
- if err := requireFields("burn", opts.Instance, opts.Instrument, opts.From, opts.Amount); err != nil {
+ if err := requireFields("burn", "instance", opts.Instance, "instrument", opts.Instrument,
+ "holder party", opts.From, "amount", opts.Amount); err != nil {
return err
}
opts.From = ResolveAlias(aliasMapForInstance(opts.Instance), opts.From)
@@ -606,6 +613,65 @@ func runBalanceLive(ctx context.Context, opts BalanceOptions) ([]BalanceRow, boo
// addDecimal returns a + b for two Daml Decimal strings. Empty is treated
// as "0". Aligns fractional widths ("1.0" + "1" → "2.0") and adds as
// big.Ints so we don't depend on a big-decimal library.
+// ErrSupplyCapExceeded is returned when a mint would push an instrument's
+// circulating supply past the initial supply declared at create. CLI maps
+// it to a user error; the HTTP handler maps it to 422 so the Web UI can
+// render the numbers rather than a generic failure.
+var ErrSupplyCapExceeded = errors.New("mint exceeds the instrument's declared supply")
+
+// enforceSupplyCap rejects a mint that would take total supply past the
+// figure recorded at `token create`. The declared supply is devkit-side
+// bookkeeping, not a ledger invariant — the test token's TokenRules has no
+// cap, so a client minting against it directly still can — but it makes
+// the number the operator typed at create actually mean something.
+//
+// Skipped when the instrument has no declared supply (Amulet and anything
+// not created here), or when either figure isn't a decimal we can compare.
+// A scan failure is not treated as a violation: the mint proceeds rather
+// than being blocked by an unrelated read error.
+func enforceSupplyCap(ctx context.Context, opts MintOptions, ref registry.TokenRef) error {
+ declared, ok := new(big.Rat).SetString(ref.InitialSupply)
+ if !ok {
+ return nil // nothing declared (or unparseable) — no cap to enforce
+ }
+ want, ok := new(big.Rat).SetString(opts.Amount)
+ if !ok {
+ return nil // validateAmount already vetted this; be permissive here
+ }
+ sum, err := RunInstrumentSummary(ctx, BalanceOptions{
+ Instance: opts.Instance, Role: opts.Role, Insecure: opts.Insecure,
+ Endpoint: opts.Endpoint, Instrument: ref.InstrumentID,
+ })
+ if err != nil {
+ return nil // can't measure supply — don't block the mint on a read error
+ }
+ minted, ok := new(big.Rat).SetString(zeroIfEmpty(sum.TotalSupply))
+ if !ok {
+ return nil
+ }
+
+ after := new(big.Rat).Add(minted, want)
+ if after.Cmp(declared) <= 0 {
+ return nil
+ }
+ headroom := new(big.Rat).Sub(declared, minted)
+ if headroom.Sign() < 0 {
+ headroom.SetInt64(0)
+ }
+ return fmt.Errorf("%w: %s declares %s, %s already minted — at most %s more can be minted",
+ ErrSupplyCapExceeded, ref.Symbol, trimDecimal(declared),
+ trimDecimal(minted), trimDecimal(headroom))
+}
+
+// trimDecimal renders a Rat without trailing-zero noise, so an error reads
+// "2001" rather than "2001.0000000000".
+func trimDecimal(r *big.Rat) string {
+ if r.IsInt() {
+ return r.Num().String()
+ }
+ return strings.TrimRight(r.FloatString(10), "0")
+}
+
func addDecimal(a, b string) (string, error) {
if a == "" {
a = "0"
@@ -685,10 +751,14 @@ func resolveInstrument(instance, ident string) (registry.TokenRef, error) {
// requireFields surfaces the same "field X is required" wording as
// the create wizard so every error in the token surface looks alike.
-func requireFields(verb string, fields ...string) error {
- for i, v := range fields {
- if v == "" {
- return fmt.Errorf("%s: field at position %d is required", verb, i)
+// requireFields reports the first missing field by name. Callers pass
+// name/value pairs ("recipient party", opts.To) so the error tells the
+// operator which input to fill — this text surfaces directly in the Web
+// UI's form, where an internal field index means nothing.
+func requireFields(verb string, nameValuePairs ...string) error {
+ for i := 0; i+1 < len(nameValuePairs); i += 2 {
+ if nameValuePairs[i+1] == "" {
+ return fmt.Errorf("%s: %s is required", verb, nameValuePairs[i])
}
}
return nil
diff --git a/internal/localnet/token/actions_test.go b/internal/localnet/token/actions_test.go
index b40b328c..7c9350b2 100644
--- a/internal/localnet/token/actions_test.go
+++ b/internal/localnet/token/actions_test.go
@@ -3,6 +3,7 @@ package token
import (
"context"
"errors"
+ "math/big"
"strings"
"testing"
@@ -395,3 +396,34 @@ func holdingContract(owner, instrumentID, admin, amount string) *lapiv2.GetActiv
},
}
}
+
+// TestTrimDecimal keeps supply figures readable in the cap error — an
+// operator who typed 2001 should see 2001, not 2001.0000000000.
+func TestTrimDecimal(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"2001", "2001"},
+ {"2001.0000000000", "2001"},
+ {"1.5", "1.5"},
+ {"0", "0"},
+ } {
+ r, ok := new(big.Rat).SetString(tc.in)
+ if !ok {
+ t.Fatalf("bad fixture %q", tc.in)
+ }
+ if got := trimDecimal(r); got != tc.want {
+ t.Errorf("trimDecimal(%s) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+// TestEnforceSupplyCap_NoDeclaredSupplyIsUncapped proves instruments with
+// no recorded initial supply (Amulet, anything not created here) are never
+// blocked — the cap is opt-in via `token create`.
+func TestEnforceSupplyCap_NoDeclaredSupply(t *testing.T) {
+ err := enforceSupplyCap(context.Background(),
+ MintOptions{Instance: "nope", Amount: "1"},
+ registry.TokenRef{Symbol: "AMT", InstrumentID: "Amulet", InitialSupply: ""})
+ if err != nil {
+ t.Errorf("uncapped instrument should mint freely, got %v", err)
+ }
+}
diff --git a/internal/localnet/token/faucet.go b/internal/localnet/token/faucet.go
index ef462501..7626e844 100644
--- a/internal/localnet/token/faucet.go
+++ b/internal/localnet/token/faucet.go
@@ -3,6 +3,7 @@ package token
import (
"context"
"io"
+ "math/big"
)
// FaucetOptions funds a party from a well-known source. A thin wrapper
@@ -28,14 +29,51 @@ type FaucetOptions struct {
// defaults to the role's own party (its seeded alias, e.g. "app-user",
// which holds the network's Amulet) — pass Source explicitly to fund from
// a different holder (e.g. the issuer of a created token).
+// defaultFaucetSource picks the party to dispense from when the caller
+// didn't name one: the largest current holder of the instrument.
+//
+// The role's own party is only a sensible default for Amulet, which the
+// LocalNet bootstrap funds. A token created here starts with its supply
+// wherever it was minted — often a dedicated holder — so defaulting to the
+// role party made the faucet fail with "sender holds no units of this
+// instrument" for every user-created instrument. Falls back to the role
+// party when nothing holds the instrument yet, which surfaces that same
+// (now accurate) error.
+func defaultFaucetSource(ctx context.Context, opts FaucetOptions) string {
+ fallback := roleOrDefault(opts.Role)
+ endpoint := opts.Endpoint
+ if endpoint == "" {
+ endpoint = ResolveLedgerEndpoint(opts.Instance, roleOrDefault(opts.Role))
+ }
+ if endpoint == "" {
+ return fallback
+ }
+ ref := instrumentRefOrRaw(opts.Instance, opts.Instrument)
+ sum, err := RunInstrumentSummary(ctx, BalanceOptions{
+ Instance: opts.Instance, Role: roleOrDefault(opts.Role), Insecure: opts.Insecure,
+ Endpoint: endpoint, Instrument: ref.InstrumentID,
+ })
+ if err != nil {
+ return fallback
+ }
+ // Holders come back biggest-first, so the first non-zero entry is the
+ // party best able to cover the requested amount.
+ for _, h := range sum.Holders {
+ if r, ok := new(big.Rat).SetString(zeroIfEmpty(h.Balance)); ok && r.Sign() > 0 {
+ return h.Party
+ }
+ }
+ return fallback
+}
+
func RunFaucet(ctx context.Context, out io.Writer, opts FaucetOptions) error {
- if err := requireFields("faucet", opts.Instance, opts.Instrument, opts.To, opts.Amount); err != nil {
+ if err := requireFields("faucet", "instance", opts.Instance, "instrument", opts.Instrument,
+ "recipient party", opts.To, "amount", opts.Amount); err != nil {
return err
}
source := opts.Source
if source == "" {
- // The role's seeded alias resolves to its own funded party.
- source = roleOrDefault(opts.Role)
+ source = defaultFaucetSource(ctx, opts)
}
return RunTransfer(ctx, out, TransferOptions{
Instance: opts.Instance,
diff --git a/internal/localnet/token/party.go b/internal/localnet/token/party.go
index 5b8a8672..b88731de 100644
--- a/internal/localnet/token/party.go
+++ b/internal/localnet/token/party.go
@@ -53,7 +53,7 @@ type PartyOptions struct {
// the recorded PartyRef.
func RunPartyNew(ctx context.Context, opts PartyOptions) (*registry.PartyRef, error) {
if !validAlias.MatchString(opts.Alias) {
- return nil, fmt.Errorf("%w: %q must be a letter followed by letters, digits or hyphens",
+ return nil, fmt.Errorf("%w: %q must be lowercase — a letter a-z followed by lowercase letters, digits or hyphens",
ErrAliasInvalid, opts.Alias)
}
diff --git a/internal/localnet/token/run_allocation.go b/internal/localnet/token/run_allocation.go
index 959c49df..9b1fb822 100644
--- a/internal/localnet/token/run_allocation.go
+++ b/internal/localnet/token/run_allocation.go
@@ -78,7 +78,8 @@ type ListAllocationsOptions struct {
// AllocationFactory_Allocate. Returns the resulting Allocation (finalized) or
// AllocationInstruction (pending) contract id.
func RunAllocate(ctx context.Context, out io.Writer, opts AllocationOptions) (string, error) {
- if err := requireFields("allocate", opts.Instance, opts.Instrument, opts.From, opts.To, opts.Amount); err != nil {
+ if err := requireFields("allocate", "instance", opts.Instance, "instrument", opts.Instrument,
+ "sender party", opts.From, "receiver party", opts.To, "amount", opts.Amount); err != nil {
return "", err
}
aliases := aliasMapForInstance(opts.Instance)
@@ -346,7 +347,7 @@ func RunAllocationCancel(ctx context.Context, out io.Writer, opts AllocationActi
// STAS_Account authorizer)
// - cancel → the settlement executors (STAS_Parties executors)
func runAllocationAction(ctx context.Context, out io.Writer, opts AllocationActionOptions, verb, choice string) error {
- if err := requireFields(verb, opts.Instance, opts.AllocationID); err != nil {
+ if err := requireFields(verb, "instance", opts.Instance, "allocation id", opts.AllocationID); err != nil {
return err
}
if opts.Endpoint == "" {
diff --git a/internal/localnet/token/workspace.go b/internal/localnet/token/workspace.go
index c34786ef..d65f9243 100644
--- a/internal/localnet/token/workspace.go
+++ b/internal/localnet/token/workspace.go
@@ -429,12 +429,19 @@ type HolderRow struct {
// (= circulating, on a UTXO ledger), holder + contract counts, and
// the per-holder distribution.
type InstrumentSummary struct {
- InstrumentID string `json:"instrument_id"`
- Admin string `json:"admin"`
- TotalSupply string `json:"total_supply"`
- HolderCount int `json:"holder_count"`
- ContractCount int `json:"contract_count"`
- Holders []HolderRow `json:"holders"`
+ InstrumentID string `json:"instrument_id"`
+ Admin string `json:"admin"`
+ TotalSupply string `json:"total_supply"`
+ // DeclaredSupply is the initial supply recorded at `token create`.
+ // Creating an instrument does not mint anything — supply only exists
+ // once minted — so this is the operator's stated intent, not an
+ // on-ledger fact. Surfaced so a created-but-unminted instrument reads
+ // as "0 of 2001 minted" rather than an unexplained zero. Empty when
+ // the instrument isn't in the registry (e.g. Amulet).
+ DeclaredSupply string `json:"declared_supply,omitempty"`
+ HolderCount int `json:"holder_count"`
+ ContractCount int `json:"contract_count"`
+ Holders []HolderRow `json:"holders"`
}
// RunInstrumentSummary scans the workspace and aggregates the holdings
@@ -447,7 +454,29 @@ func RunInstrumentSummary(ctx context.Context, opts BalanceOptions) (*Instrument
if err != nil {
return nil, err
}
- return summarizeInstrument(ws, opts.Instrument), nil
+ sum := summarizeInstrument(ws, opts.Instrument)
+ sum.DeclaredSupply = declaredSupply(opts.Instance, opts.Instrument)
+ return sum, nil
+}
+
+// declaredSupply returns the initial supply recorded for an instrument at
+// create time, matching on symbol or instrument id. Empty when the
+// instance has no record of it (Amulet, or an instrument created
+// elsewhere) — callers treat empty as "nothing was declared".
+func declaredSupply(instance, instrumentID string) string {
+ if instance == "" || instrumentID == "" {
+ return ""
+ }
+ state, err := registry.Read(instance)
+ if err != nil {
+ return ""
+ }
+ for _, ref := range state.Tokens {
+ if ref.InstrumentID == instrumentID || ref.Symbol == instrumentID {
+ return ref.InitialSupply
+ }
+ }
+ return ""
}
// summarizeInstrument pivots a workspace scan into one instrument's
diff --git a/internal/ui/handlers/tokens.go b/internal/ui/handlers/tokens.go
index 0a514c69..612f5062 100644
--- a/internal/ui/handlers/tokens.go
+++ b/internal/ui/handlers/tokens.go
@@ -771,6 +771,7 @@ func sanitize400(msg string) string {
// success for mutations that don't return a body)
// - token.ErrNeedsV2LocalNet → 412 Precondition Failed
// - token.ErrUnsupportedOnInstrument → 422 Unprocessable Entity
+// - token.ErrSupplyCapExceeded → 422 Unprocessable Entity
// - token.ErrSymbolInUse → 409 Conflict
// - other → 400 / 500 with the message
func mapTokenError(w http.ResponseWriter, err error, op string) {
@@ -788,6 +789,9 @@ func mapTokenError(w http.ResponseWriter, err error, op string) {
case errors.Is(err, token.ErrUnsupportedOnInstrument):
writeErrorWithCode(w, http.StatusUnprocessableEntity,
"UNSUPPORTED_ON_INSTRUMENT", err.Error())
+ case errors.Is(err, token.ErrSupplyCapExceeded):
+ writeErrorWithCode(w, http.StatusUnprocessableEntity,
+ "SUPPLY_CAP_EXCEEDED", err.Error())
case errors.Is(err, token.ErrSymbolInUse):
writeErrorWithCode(w, http.StatusConflict,
"SYMBOL_IN_USE", err.Error())