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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
84 changes: 70 additions & 14 deletions frontend/src/screens/TokensScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -607,6 +607,23 @@ export function TokensScreen() {
{detailTab === "overview" && (
<>
{summary && <KpiRow s={summary} />}
{summary && remainingToMint(summary) && (
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "10px 0 2px", flexWrap: "wrap" }}>
<Button
variant="secondary"
size="sm"
icon={<IcArrowUp />}
disabled={!!mintDisabledReason(active)}
title={mintDisabledReason(active) ?? "Mint the declared supply that hasn't been issued yet"}
onClick={() => setModal({ kind: "mint", symbol: sym, amount: remainingToMint(summary) })}
>
Mint {statAmount(remainingToMint(summary))} remaining
</Button>
<span style={{ color: W.dim, fontSize: fs.meta }}>
creating an instrument records its supply; minting is what issues it
</span>
</div>
)}
{summary && summary.holders.length > 0 && <HolderDistribution s={summary} aliases={aliases} />}

<h4 style={{ color: W.text2, margin: "16px 0 8px" }}>
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -855,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<import("../api").TransferPlan | null>(null);

Expand All @@ -877,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 }
Expand All @@ -904,17 +921,17 @@ function TransferModal({
<input type="checkbox" checked={autoAccept} onChange={(e) => setAutoAccept(e.target.checked)} />
Auto-accept (settle in one step. You own the receiver on LocalNet.)
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, color: autoAccept ? W.text2 : W.dim, fontSize: fs.meta, cursor: autoAccept ? "pointer" : "not-allowed" }}>
<input type="checkbox" checked={atomic && autoAccept} disabled={!autoAccept} onChange={(e) => setAtomic(e.target.checked)} />
Atomic (experimental) — batch transfer + accept into one all-or-nothing transaction
{/* Atomic batching is shown but not selectable: on this Splice version
ExecuteBatch cannot rebind the accept leg to the instruction the
transfer leg creates, so every submit fails. Offering an enabled
control whose only outcome is an error is worse than showing it
unavailable, so the checkbox stays visible (the CLI has --atomic,
and this documents the gap) but cannot be armed. */}
<label style={{ display: "flex", alignItems: "center", gap: 8, color: W.dim, fontSize: fs.meta, cursor: "not-allowed" }}>
<input type="checkbox" checked={false} disabled title="Unavailable on this Splice version" readOnly />
Atomic — batch transfer + accept into one all-or-nothing transaction
<span style={{ color: W.faint }}>(unavailable on this Splice version)</span>
</label>
{atomic && autoAccept && (
<div role="status" style={{ ...notice("warn") }}>
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.
</div>
)}

{plan && (
<div style={{ background: W.surface2, border: `1px solid ${W.border}`, borderRadius: 4, padding: "10px 12px" }}>
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions internal/cli/localnet/token/faucet.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ func buildFaucet() *cobra.Command {
Short: "Fund a party from a well-known source (auto-accepted)",
Long: `Transfer <amount> of an instrument from a funded source party to
<party>, 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.

<party> and --source accept aliases. Requires --endpoint and --instrument.`,
Args: cobra.ExactArgs(2),
Expand All @@ -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.")
Expand Down
86 changes: 78 additions & 8 deletions internal/localnet/token/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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{
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions internal/localnet/token/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package token
import (
"context"
"errors"
"math/big"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
Loading
Loading