feat: build the token picker's list from the ledger and the registries - #171
Draft
gabitoesmiapodo wants to merge 29 commits into
Draft
feat: build the token picker's list from the ledger and the registries#171gabitoesmiapodo wants to merge 29 commits into
gabitoesmiapodo wants to merge 29 commits into
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Review guide
The issue's objective is the picker, so that is where the depth belongs.
Read closely
tokenKey,InstrumentId, and every map key, React key and equality built on themmergeTokens: later sources win field by field, and an absent field is silence, never a correctionTokenList,TokenRowanduseRovingFocus, plus the accessible names the rows carry/connectSkim
useHoldingsandreadInstruments: plain reads, both covered by testssumHoldings, and the merge order in the app'sTokensproviderSkip
Deviations
useHoldingslives in the kit, not incanton-connect: the read is token-standard knowledge, and keeping it there duplicatedInstrumentIdacross two published packages/connectAcceptance criteria
Test plan
Automated tests
pnpm installpnpm lint,pnpm typecheck,pnpm test,pnpm build,pnpm knip,pnpm docs:checkandpnpm run check:anatomyManual verification
pnpm run bootstrappnpm app:devand open http://localhost:3012Breaking changes
Tokennow carriesinstrumentIdrather thanid,useTokenList()returnsbyKeyrather thanbyId, and<TokenFavorites>takes instrument ids.Migration: build a key with
tokenKey({ admin, id })and read the map with that.Checklist
Screenshots
To be added before review.