Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4cdac97
feat(kit): identify a token by its instrument id and show what a part…
gabitoesmiapodo Sep 4, 2026
f277290
refactor(kit): move the token list's roving focus into its own hook
gabitoesmiapodo Sep 4, 2026
05f47a7
style(kit): drop the caret comment
gabitoesmiapodo Sep 4, 2026
38b807f
feat(frontend): offer the token picker in the create form
gabitoesmiapodo Sep 4, 2026
b9eb2b9
feat(kit): read what a party holds off the ledger
gabitoesmiapodo Sep 4, 2026
81d160e
feat(frontend): build the token list from what the party holds
gabitoesmiapodo Sep 4, 2026
4be92c4
feat(kit): read a token registry's instrument metadata
gabitoesmiapodo Sep 4, 2026
f6e0366
feat(frontend): label the token list from the registry
gabitoesmiapodo Sep 4, 2026
9cf301c
feat(frontend): read the curated token list as a third source
gabitoesmiapodo Sep 4, 2026
65436cd
feat(frontend): serve the token list with a LocalNet section
gabitoesmiapodo Sep 4, 2026
31e8009
feat(frontend): ask each token's own registry for its metadata
gabitoesmiapodo Sep 4, 2026
a96af98
feat(kit): merge token sources into one catalogue
gabitoesmiapodo Sep 4, 2026
08e3493
feat(frontend): offer every token the sources know, not only what is …
gabitoesmiapodo Sep 4, 2026
ab3254e
feat(frontend): show the figures this app can act on
gabitoesmiapodo Sep 4, 2026
560f165
fix(frontend): say so when a balance cannot be read
gabitoesmiapodo Sep 4, 2026
668ac32
fix(frontend): keep the picked token's figures up to date
gabitoesmiapodo Sep 4, 2026
7bdd857
fix(frontend): drop a balance that is not this party's, or not readable
gabitoesmiapodo Sep 4, 2026
f15bc76
fix(frontend): re-read the balance when the create dialog opens
gabitoesmiapodo Sep 4, 2026
53ee722
fix(kit): bound how far readInstruments will follow a registry
gabitoesmiapodo Sep 4, 2026
a43298f
fix(frontend): keep the dev server up when a registry key is blank
gabitoesmiapodo Sep 4, 2026
1d85420
docs(frontend): correct what architecture.md says about the amount ce…
gabitoesmiapodo Sep 4, 2026
471604d
refactor(kit): read a JSON reply through one pair of helpers
gabitoesmiapodo Sep 4, 2026
2846dbf
style(kit): bail out with a guard clause, not a ternary
gabitoesmiapodo Sep 4, 2026
7e9214b
style(kit): sort the members of useHoldings' two types
gabitoesmiapodo Sep 4, 2026
ace5fc2
perf(kit): parse each balance once when sorting the token list
gabitoesmiapodo Sep 4, 2026
e3b2634
docs(kit): say where the name and symbol fallback happens
gabitoesmiapodo Sep 4, 2026
c3e817b
refactor(frontend): sum the holdings once per rebuild
gabitoesmiapodo Sep 4, 2026
5bdd0ff
refactor(frontend): give the Canton Coin row one spelling
gabitoesmiapodo Sep 4, 2026
dd97e7e
refactor(frontend): narrow a parsed JSON reply once
gabitoesmiapodo Sep 4, 2026
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
42 changes: 42 additions & 0 deletions canton-dappbooster/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,48 @@ number is arbitrary and human-picked, which is the point — the row wraps and d
without a cap its height is the consumer's to set and the card has no scroll of its own to catch
the spill.

## Where a token's identity and its balance come from

The list is the consumer's, and the kit reads no ledger. What that costs and buys:

- **A token is identified by its `instrumentId`**, the admin party plus the id that registry gave
it, because nothing on Canton is a global address and two registries can both issue a `USDC`.
Nothing takes one string for a token, so `tokenKey` is what makes a map key, a React key or an
equality check out of the pair. Compare keys, never symbols.
- **`balance` sits on the token, beside the metadata.** A party's holdings are private to the
participant hosting it, so only a read through the connected wallet can supply them: `useHoldings`
on the `/connect` sub-path is that read, and `sumHoldings` groups its one-per-contract answer into
one row per instrument. An absent balance means the read has not reached, not that the party holds
nothing, so the row shows no figure and sorts below every one that has a figure.
- **A list is a catalogue, not a balance sheet.** `mergeTokens` builds one row per instrument out of
every source that knows about it, later sources winning field by field and an absent field meaning
a source had nothing to say. So holdings annotate rows rather than create them: a token nobody
holds is still offerable, which is what a swap's buy side needs, and an app that wants only what
it can spend filters the result itself. That filter is a screen's rule, never the list's.
- **Metadata comes from the registry, over plain HTTP.** `readInstruments` reads a registry's
catalogue and stamps its admin party, taken from that registry's own `/info`, onto every id: the
instrument list carries bare ids, and an id identifies a token only together with the party that
issued it. It serves no logo, so artwork stays the app's or a curated list's. No session, so it
sits on the main barrel beside `sumHoldings`.
- **The read lives here rather than in `canton-connect`**, even though it 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, because the exact-decimal summing is here and that package cannot import it.
`useLedger` is the documented escape hatch for a read like this, and this is what it is for.
- **`balance` is what the party can spend, and `locked` is the rest.** Not the total, because
`balance` is also what `<TokenInput>`'s Max fills and what it validates against, so a total would
have Max offer coin the ledger then refuses. The row shows locked as a second, quieter figure
under the first, and both reach the row's accessible name: the lock icon is `aria-hidden` and says
nothing on its own. Nothing locked and no read at all are one case — `getLockedFigure` drops both,
rather than putting a `0` on every row.
- **`TokenListProvider` owns the order**, balance first and then the order given, so the field, the
list and the favourites cannot disagree about which token leads. A query re-ranks on top of that,
by match kind, in `filterTokens`.
- **A token the metadata missed still renders.** `mergeTokens` fills `name` and `symbol` from the
raw instrument id rather than leaving them out: the row, the chip and the logo's initials all
need them, and a holding that renders as nothing is worse than one that renders as its id.

## What `<TokenInput>` does not take

Three props a token field usually has are deliberately absent. **Precision** is not configurable
Expand Down
16 changes: 15 additions & 1 deletion canton-dappbooster/doc-fixtures.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@

type FixtureNode = import('react').ReactNode
type FixtureElement = import('react').ReactElement
type FixtureToken = { id: string; logo?: FixtureNode; name: string; symbol: string }
type FixtureInstrumentId = { admin: string; id: string }
type FixtureToken = {
balance?: string
instrumentId: FixtureInstrumentId
logo?: FixtureNode
name: string
symbol: string
}

/* Identifiers and amounts */

Expand Down Expand Up @@ -40,6 +47,13 @@ declare const darkSheet: string

/* Tokens */

declare const instrumentId: FixtureInstrumentId
declare const registryUrl: string
declare const holdings: readonly {
amount: string
instrumentId: FixtureInstrumentId
isLocked: boolean
}[]
declare const token: FixtureToken
declare const tokens: readonly FixtureToken[]
declare const mockTokens: readonly FixtureToken[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ const favorites = (
)

const many = Array.from({ length: MAX_FAVORITES + 3 }, (_, index) => ({
id: `token-${index}`,
instrumentId: { admin: 'registry::1220ab', id: `token-${index}` },
name: `Token ${index}`,
symbol: `TK${index}`,
}))

const [CC, USDC] = TOKENS
const GONE = { admin: 'nobody::1220ff', id: 'GONE' }

describe('TokenFavorites', () => {
it('renders the tokens its ids resolve to, in the order given', () => {
render(favorites({ ids: ['usdc', 'canton-coin'], onSelect: vi.fn() }))
render(favorites({ ids: [USDC.instrumentId, CC.instrumentId], onSelect: vi.fn() }))

const symbols = screen
.getAllByRole('button')
Expand All @@ -39,17 +42,17 @@ describe('TokenFavorites', () => {
})

it('drops an id the list does not hold', () => {
render(favorites({ ids: ['canton-coin', 'gone'], onSelect: vi.fn() }))
render(favorites({ ids: [CC.instrumentId, GONE], onSelect: vi.fn() }))
expect(screen.getAllByRole('button')).toHaveLength(1)
})

it('renders nothing without an id that resolves', () => {
const { container } = render(favorites({ ids: ['gone'], onSelect: vi.fn() }))
const { container } = render(favorites({ ids: [GONE], onSelect: vi.fn() }))
expect(container).toBeEmptyDOMElement()
})

it('renders no more chips than the cap, keeping the first', () => {
render(favorites({ ids: many.map((token) => token.id), onSelect: vi.fn() }, many))
render(favorites({ ids: many.map((token) => token.instrumentId), onSelect: vi.fn() }, many))

const chips = screen.getAllByRole('button')
expect(chips).toHaveLength(MAX_FAVORITES)
Expand All @@ -58,7 +61,7 @@ describe('TokenFavorites', () => {

it('hands the whole token to onSelect', () => {
const onSelect = vi.fn()
render(favorites({ ids: ['canton-coin'], onSelect }))
render(favorites({ ids: [CC.instrumentId], onSelect }))
fireEvent.click(screen.getByRole('button', { name: 'Canton Coin CC' }))

expect(onSelect).toHaveBeenCalledWith(TOKENS[0])
Expand Down
17 changes: 9 additions & 8 deletions canton-dappbooster/src/components/TokenInput/TokenFavorites.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { ReactElement } from 'react'
import { dialogAnatomy as anatomy } from '#src/components/TokenInput/anatomy'
import { MAX_FAVORITES } from '#src/components/TokenInput/constants'
import { tokenLabel } from '#src/components/TokenInput/tokenLabel'
import { getTokenLabel } from '#src/components/TokenInput/getTokenLabel'
import { TokenLogo } from '#src/components/TokenLogo'
import type { Token } from '#src/providers/TokenListProvider/context'
import type { InstrumentId, Token } from '#src/providers/TokenListProvider/context'
import { useTokenList } from '#src/providers/TokenListProvider/useTokenList'
import { tokenKey } from '#src/utils/tokenKey'

interface TokenFavoritesProps {
ids?: readonly string[]
ids?: readonly InstrumentId[]
onSelect: (token: Token) => void
}

Expand All @@ -16,15 +17,15 @@ interface TokenFavoritesProps {
* the order given, and drops the rest.
*
* @example
* <TokenFavorites ids={['canton-coin']} onSelect={setToken} />
* <TokenFavorites ids={[{ admin: 'DSO::1220ab', id: 'Amulet' }]} onSelect={setToken} />
*/
export const TokenFavorites = ({
ids = [],
onSelect,
}: TokenFavoritesProps): ReactElement | null => {
const { byId } = useTokenList()
const { byKey } = useTokenList()
const favorites = ids
.map((id) => byId.get(id))
.map((id) => byKey.get(tokenKey(id)))
.filter((token) => token !== undefined)
.slice(0, MAX_FAVORITES)

Expand All @@ -34,9 +35,9 @@ export const TokenFavorites = ({
<section aria-label="Favorite tokens" className={anatomy.parts.favorites}>
{favorites.map((token) => (
<button
aria-label={tokenLabel(token)}
aria-label={getTokenLabel(token)}
className={anatomy.parts.favorite}
key={token.id}
key={tokenKey(token.instrumentId)}
onClick={() => onSelect(token)}
type="button"
>
Expand Down
43 changes: 42 additions & 1 deletion canton-dappbooster/src/components/TokenInput/TokenList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { dialogAnatomy as anatomy } from '#src/components/TokenInput/anatomy'
import { ROW_HEIGHT_REM } from '#src/components/TokenInput/constants'
import { formatFigure } from '#src/components/TokenInput/formatFigure'
import { TokenList } from '#src/components/TokenInput/TokenList'
import { TokenListProvider } from '#src/providers/TokenListProvider'
import type { Token } from '#src/providers/TokenListProvider/context'
Expand All @@ -13,7 +14,7 @@ const ROW = ROW_HEIGHT_REM * 16
const VIEWPORT = ROW * 4

const tokens: Token[] = Array.from({ length: 100 }, (_, index) => ({
id: `token-${index}`,
instrumentId: { admin: 'registry::1220ab', id: `token-${index}` },
name: `Token ${index}`,
symbol: `TK${index}`,
}))
Expand All @@ -39,6 +40,13 @@ const setup = (query?: string) => {
return { container, onSelect, scroller, search: (next?: string) => rerender(list(next)) }
}

const one = (figures: Partial<Token>) =>
renderList(
<TokenListProvider tokens={[{ ...tokens[0], ...figures }]}>
<TokenList onSelect={vi.fn()} />
</TokenListProvider>,
)

const rows = (): HTMLElement[] => screen.getAllByRole('button')

const row = (index: number): HTMLElement =>
Expand Down Expand Up @@ -89,6 +97,39 @@ describe('TokenList', () => {
expect(row(0)).toBeInTheDocument()
})

it('shows the balance and carries it into the row name', () => {
const balance = formatFigure('1234.5')
const { container } = one({ balance: '1234.5' })

expect(container.querySelector(`.${anatomy.parts.rowBalance}`)).toHaveTextContent(balance)
expect(
screen.getByRole('button', { name: `Token 0 TK0, balance ${balance}` }),
).toBeInTheDocument()
})

it('renders no figure for a token whose balance is unknown', () => {
const { container } = setup()
expect(container.querySelector(`.${anatomy.parts.rowBalance}`)).not.toBeInTheDocument()
})

it('shows what is locked under the balance, named and not left to the icon', () => {
const { container } = one({ balance: '900', locked: '100' })

expect(container.querySelector(`.${anatomy.parts.rowLocked}`)).toHaveTextContent('100')
expect(container.querySelector(`.${anatomy.parts.rowLocked} svg`)).toHaveAttribute(
'aria-hidden',
)
expect(
screen.getByRole('button', { name: 'Token 0 TK0, balance 900.00, 100.00 locked' }),
).toBeInTheDocument()
})

// A row per token, so a lock nothing holds is a lock on every row.
it('leaves the locked line out when nothing is locked', () => {
const { container } = one({ balance: '900', locked: '0' })
expect(container.querySelector(`.${anatomy.parts.rowLocked}`)).not.toBeInTheDocument()
})

// Windowed, so the rows out of view are not in the DOM to tab to: one tab stop and the arrow keys
// are what reach them, not a tab stop per token.
it('carries a single tab stop, starting on the first row', () => {
Expand Down
Loading