From d33425a9f557310bd1eaf334ebffcbf71df29927 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:58:01 +0530 Subject: [PATCH 1/3] fix(token): explain declared-but-unminted supply, and name missing fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues an operator hits on a freshly created instrument: Creating a token records an initial supply but mints nothing, so the instrument reads "Total supply 0" with no explanation and looks broken. Surface the recorded figure as declared_supply on the instrument summary and annotate the tile ("0 · declared 2,001") only while it differs from what is actually minted, alongside a "Mint N remaining" action that prefills the outstanding amount. Once fully minted the note and the action disappear, so a settled instrument is just its number. requireFields could only report an index — a missing recipient surfaced in the Web UI as "mint: field at position 2 is required". Take name/value pairs so the error names the input ("mint: recipient party is required"); this text goes straight into the form. The party-alias error said an alias "must be a letter followed by letters, digits or hyphens" while the pattern requires lowercase, so "Zheholder" looked legal. Say lowercase. --- frontend/src/api.ts | 4 ++ frontend/src/screens/TokensScreen.tsx | 46 ++++++++++++++++++++++- internal/localnet/token/actions.go | 24 ++++++++---- internal/localnet/token/faucet.go | 3 +- internal/localnet/token/party.go | 2 +- internal/localnet/token/run_allocation.go | 5 ++- internal/localnet/token/workspace.go | 43 +++++++++++++++++---- 7 files changed, 106 insertions(+), 21 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d5c2269b..0f0bb634 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1859,6 +1859,10 @@ export interface InstrumentSummary { instrument_id: string; admin: string; total_supply: string; + /** Initial supply recorded at create. Creating mints nothing, so this + * is stated intent, not an on-ledger fact — shown only while it + * differs from total_supply. Absent for unregistered instruments. */ + declared_supply?: string; holder_count: number; contract_count: number; holders: HolderRow[]; diff --git a/frontend/src/screens/TokensScreen.tsx b/frontend/src/screens/TokensScreen.tsx index b72fb729..58fb3178 100644 --- a/frontend/src/screens/TokensScreen.tsx +++ b/frontend/src/screens/TokensScreen.tsx @@ -144,7 +144,7 @@ export function TokensScreen() { const [showCreate, setShowCreate] = useState(false); const [modal, setModal] = useState< - | { kind: "mint"; symbol: string } + | { kind: "mint"; symbol: string; amount?: string } | { kind: "transfer"; symbol: string } | { kind: "burn"; symbol: string } | { kind: "faucet"; symbol: string } @@ -607,6 +607,23 @@ export function TokensScreen() { {detailTab === "overview" && ( <> {summary && } + {summary && remainingToMint(summary) && ( +
+ + + creating an instrument records its supply; minting is what issues it + +
+ )} {summary && summary.holders.length > 0 && }

@@ -733,6 +750,7 @@ export function TokensScreen() { instance={instance} role={role} parties={parties} + initial={modal.amount ? { amount: modal.amount } : undefined} onPartiesChanged={() => bump()} onClose={() => setModal(null)} submit={(v) => mintToken(instance, modal.symbol, v.to, v.amount, role)} @@ -1111,9 +1129,21 @@ 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 unminted = remainingToMint(s); 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: unminted ? `declared ${statAmount(s.declared_supply!)}` : undefined, + }, { 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 +1192,18 @@ function KpiRow({ s }: { s: InstrumentSummary }) { ); } +// 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/localnet/token/actions.go b/internal/localnet/token/actions.go index e46694c2..5e695be8 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) @@ -242,7 +243,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 +338,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 +376,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) @@ -685,10 +689,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/faucet.go b/internal/localnet/token/faucet.go index ef462501..f832e968 100644 --- a/internal/localnet/token/faucet.go +++ b/internal/localnet/token/faucet.go @@ -29,7 +29,8 @@ type FaucetOptions struct { // which holds the network's Amulet) — pass Source explicitly to fund from // a different holder (e.g. the issuer of a created token). 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 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 From f84eec9885b09436733b03b286c65b8fca868b12 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:07:44 +0530 Subject: [PATCH 2/3] feat(token): enforce declared supply as a mint cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial supply recorded at create was never checked, so an instrument declaring 2001 could be minted to 200,004 — the figure the operator typed meant nothing. Reject a mint that would push circulating supply past the declared amount, naming the numbers and the headroom left: mint exceeds the instrument's declared supply: ZHE declares 2001, 199994 already minted — at most 0 more can be minted The cap lives in RunMint, so the CLI and the Web UI enforce it identically; the handler maps it to 422 SUPPLY_CAP_EXCEEDED so the form can show the numbers. Instruments with no declared supply (Amulet, anything not created here) stay uncapped, and a scan failure lets the mint through rather than blocking on an unrelated read error. This is devkit bookkeeping, not a ledger invariant — the test token's TokenRules has no cap, so a client minting against it directly still can. The supply tile therefore also reports an overshoot ('declared 2,001 · over by 197,993') instead of hiding it, which is the state instruments minted before this change are already in. Comparisons use big.Rat: float drifts past ~15 digits, and supply figures carry ten decimal places. --- frontend/src/screens/TokensScreen.tsx | 19 +++++++- internal/localnet/token/actions.go | 62 +++++++++++++++++++++++++ internal/localnet/token/actions_test.go | 32 +++++++++++++ internal/ui/handlers/tokens.go | 4 ++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/frontend/src/screens/TokensScreen.tsx b/frontend/src/screens/TokensScreen.tsx index 58fb3178..53957178 100644 --- a/frontend/src/screens/TokensScreen.tsx +++ b/frontend/src/screens/TokensScreen.tsx @@ -1136,13 +1136,12 @@ function AllocationsPanel({ // 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 unminted = remainingToMint(s); const cards: Array<{ label: string; value: string; full?: string; hint?: string }> = [ { label: "Total supply", value: statAmount(s.total_supply), full: s.total_supply, - hint: unminted ? `declared ${statAmount(s.declared_supply!)}` : undefined, + 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) }, @@ -1192,6 +1191,22 @@ 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. diff --git a/internal/localnet/token/actions.go b/internal/localnet/token/actions.go index 5e695be8..9e638713 100644 --- a/internal/localnet/token/actions.go +++ b/internal/localnet/token/actions.go @@ -230,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{ @@ -610,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" 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/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()) From a9f92a55c69c52aade65dfbfce1eca9fb91474b1 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:12:47 +0530 Subject: [PATCH 3/3] fix(token): faucet from the actual holder; stop offering atomic batching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The faucet defaulted its source to the role's own party. That holds for Amulet, which the LocalNet bootstrap funds, but a token created here starts with its supply wherever it was minted — so funding from a created instrument always failed with "sender holds no units of this instrument" even though supply existed. Default to the instrument's largest current holder instead, which is what a faucet should dispense from; --source still overrides, and an instrument nobody holds yields the same (now accurate) error. CLI help updated to match. The transfer modal offered an enabled "Atomic" checkbox whose own warning said the submit would fail: ExecuteBatch on this Splice version cannot rebind the accept leg to the instruction the transfer leg creates, so every attempt errors with CONTRACT_DOES_NOT_IMPLEMENT_INTERFACE. A control whose only outcome is an error should not be armable — it stays visible, and labelled unavailable, so the gap against the CLI's --atomic is still documented. --- frontend/src/screens/TokensScreen.tsx | 23 +++++++-------- internal/cli/localnet/token/faucet.go | 9 +++--- internal/localnet/token/faucet.go | 41 +++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/frontend/src/screens/TokensScreen.tsx b/frontend/src/screens/TokensScreen.tsx index 53957178..706d4485 100644 --- a/frontend/src/screens/TokensScreen.tsx +++ b/frontend/src/screens/TokensScreen.tsx @@ -873,7 +873,6 @@ function TransferModal({ const [amount, setAmount] = useState(""); const [reason, setReason] = useState(""); const [autoAccept, setAutoAccept] = useState(true); - const [atomic, setAtomic] = useState(false); const [busy, setBusy] = useState(false); const [plan, setPlan] = useState(null); @@ -895,7 +894,7 @@ function TransferModal({ e.preventDefault(); setBusy(true); try { - const res = await transferToken(instance, symbol, from, to, amount, reason || undefined, role, autoAccept, atomic && autoAccept); + const res = await transferToken(instance, symbol, from, to, amount, reason || undefined, role, autoAccept, false); // A non-auto-accept Offer returns an instruction id; anything settled just closes. onDone(!res.settled && res.transferInstructionId ? { instructionId: res.transferInstructionId, receiver: to } @@ -922,17 +921,17 @@ function TransferModal({ setAutoAccept(e.target.checked)} /> Auto-accept (settle in one step. You own the receiver on LocalNet.) -