Skip to content

feat: build the token picker's list from the ledger and the registries - #171

Draft
gabitoesmiapodo wants to merge 29 commits into
mainfrom
feat/133
Draft

feat: build the token picker's list from the ledger and the registries#171
gabitoesmiapodo wants to merge 29 commits into
mainfrom
feat/133

Conversation

@gabitoesmiapodo

Copy link
Copy Markdown
Collaborator

Summary

Closes #133

The token picker took a hardcoded one-entry list. It now identifies a token the way Canton does, reads what the party holds off the ledger, asks each registry for names and symbols, and merges every source into one catalogue.

Changes

  • A token is identified by its instrument id: the admin party plus the id that registry gave it
  • The picker offers every token the sources know, not only what the party holds
  • A row carries a spendable figure and a locked one, both in its accessible name
  • Names and symbols come from each token's own registry, logos from the curated asset list
  • The create form offers the picker, and its amount ceiling follows the picked row
  • The list walks on one roving tab stop, now its own hook

Review guide

The issue's objective is the picker, so that is where the depth belongs.

Read closely

  • Token identity: tokenKey, InstrumentId, and every map key, React key and equality built on them
  • mergeTokens: later sources win field by field, and an absent field is silence, never a correction
  • TokenList, TokenRow and useRovingFocus, plus the accessible names the rows carry
  • Which exports landed on the kit's main barrel and which on /connect

Skim

  • useHoldings and readInstruments: plain reads, both covered by tests
  • sumHoldings, and the merge order in the app's Tokens provider
  • The new row styles in the theme
  • The last eight commits: a quality pass, no behaviour change

Skip

  • The dev server's asset list and registry proxy: neither exists in a build
  • Test fixtures and doc updates

Deviations

  • useHoldings lives in the kit, not in canton-connect: the read is token-standard knowledge, and keeping it there duplicated InstrumentId across two published packages
  • Three pure readers sit on the kit's main barrel; anything needing the wallet session stays on /connect
  • Registry metadata is LocalNet only. The dev server proxies it and adds the bearer, so a deployed build reads none
  • The dev server copies the published DevNet rows into a LocalNet section, so a local picker has real rows to show
  • The last eight commits are a quality pass over the branch, not new work

Acceptance criteria

  • The picker offers every token the sources know, each keyed by its instrument id
  • A row shows the party's spendable and locked figures, and both reach its accessible name
  • Names and symbols come from each token's own registry, falling back to the raw id
  • The create form's ceiling comes from the picked row, and says so when the read fails
  • The list stays reachable by keyboard on one roving tab stop

Test plan

Automated tests

  1. Run pnpm install
  2. Run pnpm lint, pnpm typecheck, pnpm test, pnpm build, pnpm knip, pnpm docs:check and pnpm run check:anatomy
  3. Expect all seven to pass

Manual verification

  1. Bring the local stack up as the root README describes, through pnpm run bootstrap
  2. Run pnpm app:dev and open http://localhost:3012
  3. Connect a wallet, then open Create grant
  4. Click the token button in the amount field
  5. Expect about 36 rows with names and symbols, and a figure on the rows the party holds
  6. Type in the search box, then walk the rows with the arrow keys
  7. Expect focus to move one row at a time and the result count to be announced

Breaking changes

Token now carries instrumentId rather than id, useTokenList() returns byKey rather than byId, and <TokenFavorites> takes instrument ids.

Migration: build a key with tokenKey({ admin, id }) and read the map with that.

Checklist

  • Self-reviewed my own diff
  • Tests added or updated
  • Docs updated (if applicable)
  • No unrelated changes bundled in

Screenshots

To be added before review.

…y holds

A token was keyed on a plain `id` string. Canton has no global token address:
what identifies an instrument is the party administering its registry plus the
id it carries there, and two registries can both issue a `USDC`. So `Token`
takes an `instrumentId` and the new `tokenKey` joins the pair wherever one
string is needed - a map key, a React key, an equality check.

The list can now carry figures too:

- `Token` gains `balance`, what the party can spend, and `locked`, what it
  cannot. Both come from a holdings read, so a token without them shows no
  figure and sorts last.
- A row shows the balance with the locked amount under it, in muted text with a
  lock mark. Both figures reach the row's accessible name, because the mark is
  `aria-hidden` and says nothing on its own.
- `TokenListProvider` orders the list by balance, so the field, the list and the
  favourites cannot disagree about which token leads.
- The search matches symbol, then name, then the instrument id or the admin
  party by prefix, and the placeholder now says so.

Balance is deliberately the spendable amount rather than the total: it is also
what the amount field's Max fills and validates against, so a total would have
Max offer coin the ledger then refuses.

Three breaking changes for consumers: `Token.id` is now `Token.instrumentId`,
`favoriteIds` takes instrument ids, and `useTokenList()` returns `byKey`.
`TokenList` was 176 lines, and most of them were not about rendering a list.
Windowing unmounts a row that scrolls out of view, and the browser drops focus
to `body` when the focused element disappears, so the component had to notice
and hand it back. That bookkeeping sat inline among the markup.

- `useRovingFocus` now owns which row carries the tab stop, the keys that move
  it, the reset when the query changes, and the focus hand-back. It takes the
  tokens, the row height, the scroll ref and `scrollRowIntoView`, and returns
  `active`, `onKeyDown` and `onRowFocus`.
- `nextIndex` moved out with its own test, which is where the deliberate lack of
  clamping is now written down: it may run past either end, and the caller
  clamps against a list length it cannot see.
- `TokenList` is down to 96 lines: the filter, the two hooks, the row and the
  markup.

No behaviour change, and the focus tests in `TokenList.test.tsx` still cover it
end to end. A hook-level test would need a fake list DOM to assert the same
things worse.
The code says it: `countDigitsAfter` counts from the caret to the end of the
string, and nothing anchors on the digits before it.
The amount field rendered its symbol as a static mark, because passing no
`onTokenSelect` is what tells the kit to. It now holds the picked token in its
own state and passes the handler, so the dialog, its search field and the
balance figures can be seen in the app.

- `AMT` carries a real `instrumentId`, since the kit now keys on one. The id is
  `Amulet`, which is what the registry serves; the admin is the network's DSO
  party and stays empty, because the app only learns it once a wallet connects.
- The field reads its own list rather than the app's, so Canton Coin shows the
  balance already read for the Max button.

The pick is display-only for now: a grant is Canton Coin whatever the field
shows. `Claim` keeps the static mark, and will, because what a grant pays out is
fixed by the contract.
A token list had no way to learn a balance. `useHoldings` is that read: one
request filtered by the standard `Holding` interface answers for every registry
at once, so no token list is needed to find what a party owns. It returns one
entry per contract, because coin arrives in separate pieces, and `sumHoldings`
groups those into one row per instrument with the spendable amount apart from
the locked one.

Both live here rather than in `canton-connect`, even though the read needs that
package's session. What it knows is the token standard - an interface id, the
shape of a holding view, an instrument id - and `canton-connect` is a layer over
the wallet SDK, kept thin enough to delete. Putting it there duplicated
`InstrumentId` across two published packages and split one operation in half,
since the exact-decimal summing is here and that package cannot import it.
`useLedger` is the documented escape hatch for a read like this.

`useHoldings` is on the `/connect` sub-path, so the Canton SDK stays out of the
main barrel. `sumHoldings` and `Holding` are on the main one: they are pure, and
a consumer merging their own data needs neither a session nor the SDK.

The ledger answers with an instrument id and an amount and nothing else. Names,
symbols and logos live at the registry, which this does not read yet, so a
holding the app has no metadata for is listed under its raw id.
The list was a hardcoded one-entry array with an empty admin party, since the
app could not know the network's DSO without asking the ledger. `Tokens` now
reads `useHoldings`, groups it with `sumHoldings`, and hands the result to
`TokenListProvider`. Every row is an instrument the connected party actually
holds, carrying its own admin party, its spendable balance and whatever is
locked.

- `utils/tokens.tsx` is down to the artwork, the name and the symbol, matched by
  instrument id. A token this deployment does not know is listed under its raw
  id rather than dropped.
- The provider moved inside `CantonConnectProvider`, because the read needs the
  session.
- `CreateGrant` dropped the local list it had been given to show a balance.

The field's Max still comes from `balanceOf`, which is the free Amulet total:
coin an outstanding grant has pledged is excluded, and no holdings read knows
that rule. So the picker shows what the party holds and the field caps what this
app may spend, and the two legitimately differ.
The ledger answers a holdings read with an instrument id and an amount. Names
and symbols live off-ledger, at the registry that issued the token, behind the
token-standard metadata API. `readInstruments` reads that: `/registry/metadata/
v1/instruments`, following every page, so the answer is the whole catalogue
rather than the first 25 of it.

The admin party comes from the registry's own `/info` and is stamped onto every
id, because the catalogue carries bare ids and an id names a token only together
with the party that issued it. That is also what makes the result mergeable with
a holdings read, which always carries both.

No logo: the metadata API serves none, so artwork stays the app's or a curated
list's. No session either, so this sits on the main barrel beside `sumHoldings`
rather than on `/connect`.

Verified against the LocalNet registry, which answers `Amulet` / `AMT` at ten
decimals for the DSO party.
The list carried ids and amounts off the ledger and nothing readable. It now
also reads the registry's catalogue and merges it in, so a row is named by
whoever issued the token.

Three sources, in that precedence: the raw instrument id, then whatever the
registry calls it, then the app's own entry, which wins because it is the only
one carrying artwork. A registry that will not answer costs labels and no rows,
since the holdings list either way.

Reaching the registry needs a hop. LocalNet serves it under the validator's
authenticated prefix, so a browser gets a 401 and no bearer belongs in a page.
The dev server proxies `/registry` to `SPLICE_REGISTRY_API_URL` and adds the
header, which keeps the token in the config and out of the bundle.

`REGISTRY_URL` is a constant, not a `VITE_` variable: every build has the same
value for it today. A deployment pointing somewhere else is what would earn the
variable, and that deployment does not work yet, since the proxy is the dev
server's alone.
Names and symbols now come from the registry, but it serves no artwork at all,
and nothing maps an admin party to the registry that answers for it. The
curated `assets.json` in the Canton wallet repo carries both, so the app reads
it as one more source.

It lives here and not in the kit on purpose. The metadata API is a standard
with a spec; that file is one repository's convention - no CIP, no schema, no
versioning, and its shape is whoever maintains it to change. So the kit ships
no reader for it, and this one trusts an entry for its artwork and the symbol
it publishes, never for identity or amounts.

Four sources merge by instrument id, in precedence: the raw id, the registry,
the curated list, then the app's own entry. A source that will not answer costs
labels and no rows, since the holdings list either way.

Off by default. `ASSET_LIST_NETWORK` picks a key of that file and `undefined`
skips the source, which is the local case: the published file covers MainNet,
TestNet and DevNet only. A LocalNet's DSO party is minted at `dev-stack.sh up`
and lives until a `reset`, so trying this locally means serving a file of your
own with that party in it.
The curated source was off locally, because the published `assets.json` covers
MainNet, TestNet and DevNet and nothing else. A LocalNet mints its own DSO party
with the stack, so no published entry can name one.

The dev server now serves the list itself, at `/assets.json`: the published file
as it stands, plus a `LocalNet` section built from the party its own scan
reports. Generated rather than committed on purpose - a party written into a
file is one developer's, and wrong for everyone else after the next reset.

Either half failing costs labels and nothing else. GitHub unreachable still
serves the LocalNet section, and a stack that is down still serves the published
list.

Verified against a running LocalNet: four keys, 35 entries per published
network, and an Amulet entry carrying the local DSO party.
The metadata read went to one address, which only works while one registry
answers for everything. It now takes the admin parties the holdings name, looks
each one up in the curated list, and reads the registry that list publishes for
it. Several registries are read in parallel and their answers merged.

`REGISTRY_URL` stays as the fallback for an admin no list mentions, which is
what a LocalNet is: the generated entry publishes that same path, so locally
nothing changes.

A registry that will not answer costs labels and no rows, and it fails per
registry rather than for the whole read.

A CNS lookup is the authoritative way to resolve these urls, and stays out while
one file answers.
The list a picker offers was whatever a party held, which is a balance sheet
rather than a catalogue. A swap makes that obvious: the sell side comes from
your holdings and the buy side cannot.

`mergeTokens` takes any number of sources and builds one row per instrument out
of every source that knows about it, later sources winning field by field. An
absent field means a source had nothing to say, never a correction, so a source
carrying only balances cannot wipe a name. A token nothing named is a row under
its own id.

So holdings annotate rows instead of creating them, and a screen wanting only
what it can spend filters the result. That is the screen's rule, never the
list's.

The row's figures also carry two decimals now, both on screen and in the
accessible name: a bare `0` beside `2,134.78` reads as a different kind of
number. The exact value stays the field's to show.
…held

Rows were built from the holdings, so a token the party owns none of could
never be picked. They are now the union of four sources merged by instrument
id: the curated list, the registries, the balances, and the app's own artwork.
Nothing is filtered, so picking a token you hold none of simply leaves you
unable to grant it.

Two things follow:

- Registries are no longer chosen by the admins you hold. Every url the curated
  list publishes is read, plus `REGISTRY_URL`, because a catalogue is the point.
- Once the holdings read answers it has enumerated everything, so a token
  missing from it is one the party holds none of. Those rows show `0.00` rather
  than no figure. Before the read answers they stay blank, since nobody checked.

For development the dev server now drops the published DevNet entries into the
LocalNet section, so a local picker has 36 real rows to search and scroll
instead of the one instrument this stack issues.
The picker showed what the ledger holds while the amount field showed what a
grant can spend, so the same token carried two numbers on one screen. Both are
the app's to choose, since the app builds every row.

Canton Coin's row now reports what `balanceOf` reports, the coin free to fund a
grant, and counts the rest as locked: the escrowed coin plus whatever a pending
grant has pledged. The three still sum to everything held, so the row hides
nothing and splits it the way this app can use it. Every other row keeps the
ledger's own split, and moving more than Canton Coin will need that rule again.

The create field follows the pick instead of reading a balance of its own. Max
and the ceiling are the figure the row showed, so picking a token you hold none
of disables Max, which is the correct dead end.

Two structural changes fall out. `Tokens` moved inside `Backend`, because the
free total is the backend's to report, and `CreateGrant` lost its own
`balanceOf` read, so there is still exactly one.

One thing goes with it: the field's `Balance: N/A` face. A failed read now
leaves the row without a figure, which reads as still loading.
A row carries a figure or it does not, so once the balance read moved into the
provider, a read that failed looked exactly like one still running: the field
sat on `Balance: …` forever and Max stayed disabled with no reason given.

`Tokens` now publishes whether either read failed, the holdings one and the
free-balance one, and `useTokenFigures` is how a field asks. The create form
turns that into the kit's `balanceState="error"`, which is the `Balance: N/A`
face it used to show before the read moved.

Neither failure is likely in a local loop, but both go over the network, so the
face earns its keep.
The create form stored the whole row the picker handed back, so it kept
whatever figures that row carried at the moment of the pick. Pick a token
before the balance reads answer and the field never caught up: it stayed on
"loading", Max stayed disabled, and the amount was validated against no
ceiling at all, so any figure passed.

It now stores the token's key and looks the row up in the list on every
render, which is where the reads land.
Two ways a wrong ceiling reached the amount field.

The spendable Canton Coin figure was never cleared, so switching wallet
party showed the previous party's balance until the new read landed, and a
read that failed left the old number in place behind a "Balance: N/A"
label. It is now cleared before every read.

When that read fails the row still carries the ledger's own unlocked total,
which counts coin a pending grant has pledged. The form was passing it to
the field as the ceiling. It now passes no balance at all, which is what
"N/A" already said.
Moving the read into the token provider made it run once per session. A
grant created through the form pledges coin, so reopening the dialog showed
the balance and the Max button from before that grant existed, and an amount
the split would refuse now passed validation.

The provider publishes a `refresh` beside the failure flag and the dialog
calls it on mount, which is what the old per-dialog read did. Holdings are
refetched with it, so the picker's other rows are fresh too.
The reader followed page tokens until the registry stopped handing them
out. A registry address comes from whatever list the app trusts, so one
that repeats a token, or invents a new one every time, held the caller in
a loop that never ended.

It now stops at a token it has already followed, since that token can only
serve the page it served before, and at 100 pages whatever the tokens say.
`loadEnv` reads a key that is present but set to nothing as an empty
string, and `SPLICE_REGISTRY_API_URL=` in the root `.env` then reached
`new URL('')`, which threw before the server ever listened. The only clue
was a bare `TypeError: Invalid URL`.

A blank value now means the LocalNet default, the same as leaving the key
out. `SPLICE_SCAN_API_URL` reads the same way, so the asset list serves
too.
…iling

Three statements in the amounts section contradicted the code beside them.

- The registry paragraph ended by saying every row is an instrument the
  party holds, which is the opposite of the catalogue rule three paragraphs
  above it.
- "It does not move the ceiling" said the field's balance stays
  `backend.balanceOf`. The pick does move it: the field takes the figure
  the picked row carries.
- "Only the claim dialog has a ceiling" and "the field's `aria-invalid` is
  passed in" were both already wrong before the picker landed. Both forms
  hand the field a `balance`, and neither passes `aria-invalid`.

Also records where the balance read now lives and when it runs.
`asRecord` was written out twice in this package, once in `useHoldings.ts`
and once in `readInstruments.ts`. The repo's placement rule promotes a
module on its second consumer, so both now import it from a new
`src/utils/json.ts`, along with `valueAt`, which walks a path of keys.

The copy in `dapp/frontend/src/utils/assetList.ts` stays where it is: the
kit does not export these, and the app cannot reach an internal module.
Three places read a bail-out as a two-way choice. The repo rule is the
other way round: a ternary picks between two things to render, a guard
clause leaves early when there is nothing to do.

- `TokenFavorites` returns null for an empty row.
- `TokenList`'s `announce` had grown to three nested levels.
- `useHoldings`' `holdingsFromAcsRows` wrapped its whole body in the
  second arm.
Type and interface members are alphabetical across both libraries, and
these two were the only new ones that were not. The state objects they
describe are reordered to match.
`byBalance` called `parseAmount` on both sides of every comparison, so a
sort of 36 rows parsed about 370 balances and a sort of 500 rows about
9000. The list is windowed precisely because a consumer's can be long.

The provider now reads each balance once, sorts on the number, and drops
it again. Same order out, one parse per token.
The architecture bullet told the consumer to fill `name` and `symbol`
from the instrument id in its own list source. `mergeTokens` already
does it, so a consumer following the bullet would write it twice.
The token memo called `sumHoldings` twice, once for the source list and
once for the vesting figures, so every rebuild grouped and formatted the
whole holdings read twice over. It is read once now and passed to both.

`fromVesting` also takes `InstrumentBalance[]`, which is what it was
always given. Its `balance !== undefined` filter and its two `'0'`
defaults could never fire, and they read as if a figureless row could
reach it.
`instrumentId.id === AMULET_ID` was written out at three call sites, and
the fit-to-slot `<img>` the kit's logo slot needs was written at two.
Both now live in `utils/tokens.tsx`, as `isAmulet` and `tokenLogo`, so
the comment explaining the sizing sits on the one thing that does it.

`fromCurated` also sets `logo` to undefined rather than spreading an
empty object: `mergeTokens` already skips a field a source left unset.
Two places read the same parsed value more than once.

- `assetList.ts` called `asRecord` on `instrumentId` for the admin and
  again for the id.
- `vite.config.ts` cast the published list three ways in six lines. It
  is typed once now, where it is awaited.
@gabitoesmiapodo gabitoesmiapodo self-assigned this Sep 4, 2026
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
demo.canton-dappbooster Ready Ready Preview Sep 4, 2026 11:42pm UTC
docs.canton-dappbooster Ready Ready Preview Sep 4, 2026 11:42pm UTC

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

Token select config

1 participant