Skip to content

chore: merge main into the token-forge migration branch - #200

Merged
lmcorbalan merged 133 commits into
feat/157-token-forge-migrationfrom
chore/199-merge-main
Sep 9, 2026
Merged

chore: merge main into the token-forge migration branch#200
lmcorbalan merged 133 commits into
feat/157-token-forge-migrationfrom
chore/199-merge-main

Conversation

@lmcorbalan

@lmcorbalan lmcorbalan commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Part of #157.

The epic branch had drifted 132 commits behind main and no longer merged. This brings main in and resolves the 33 conflicting files, keeping both sides: the token-forge vesting model from the epic, and the interface rewrite from main.

Changes

  • Grants are funded from the token-forge instrument, through main's Ark UI create dialog and token picker
  • The token catalogue reads instruments from the token-forge registry instead of the Splice scan proxy
  • The wrong-network strip reads the app's network from the registry rather than from a Canton Coin tap
  • The faucet moved to the account menu and taps the instrument's own config
  • Amount figures go through the kit's formatFigure everywhere

Deviations

  • The picker offers tokens no grant can be funded from; funding still refuses anything but the vested instrument. Worth its own issue
  • The wrong-network read is sourced a third way, from the registry, because the epic deleted the Canton Coin path main read it through
  • The Canton Coin faucet button left the dashboard instead of surviving beside the account-menu one
  • VITE_WALLET_RPC_URL is no longer read; nothing calls wallet-service directly any more
  • The Backend provider now publishes the vested instrument, so the catalogue can tell that row from the rest

Acceptance criteria

  • No conflict markers remain and the branch carries both parents
  • The full PR gate passes: lint, typecheck, build, test, knip, docs check, anatomy check
  • Vesting still runs against the token-forge instrument, not Canton Coin
  • Every interface change main shipped is present: Ark UI dialogs, lucide icons, account menu, wrong-network strip, token picker
  • No Canton Coin code path returns

Test plan

Automated tests

  1. Run pnpm install
  2. Run pnpm lint, pnpm typecheck, pnpm build, pnpm test, pnpm knip, pnpm docs:check and pnpm run check:anatomy
  3. Expect all seven to pass, with 815 tests passing across the four packages

Manual verification

  1. Check out this branch and run ./scripts/dev-stack.sh up
  2. Open http://localhost:3012 and connect a CIP-0103 wallet
  3. Open the account menu and choose Tap dAppBooster Token
  4. Expect a success toast and the balance to rise by 1000 DBT
  5. Click Create and expect the amount field to show DBT with that balance
  6. Switch the wallet to another network, then return to the tab
  7. Expect the wrong-network strip above the header

Breaking changes

VITE_WALLET_RPC_URL is no longer read. A deployment setting it can drop the variable; nothing else changes.

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.

fernandomg and others added 30 commits September 1, 2026 22:15
- an unset actAs lets the wallet pick its own primary, which may not be that party
- execute sends the connected party; a caller's own actAs passes through
- execute and signMessage refuse when the session reports no party
`ConnectButton` was one component that swapped between a connect trigger and a
Zag popover holding the account, with a `mode` prop to pin it to one. A host
that wants a dropdown of its own had to take ours or take nothing.

`WalletButton/` replaces it with three exports from `/connect`: `ConnectButton`
and `DisconnectButton`, each a plain button that renders whatever the session
says and calls one method, and `WalletButton`, a chooser that follows
`useWalletStatus().isConnected` and hands its props to the face it picks. The
session and not the party decides that face, because a locked wallet keeps the
session and clears the party.

The popover goes with it, and `@zag-js/popover` with the popover: no component
here needs a popper any more. `LogoutIcon` and `ChevronDownIcon` were rendered
only inside it.
`.cnc-disconnect-button` shares the connect button's rules, since they are the
same object with different words. The popover's own block goes with the
component, taking the second `z-index` value and the only `[hidden]` rule in
the file with it.

Two rules the file was already following go into CLAUDE.md while there is a
case to point at: hoist a shared trailing part into `:is()` where every entry
weighs the same, and never nest with `&`, which hides a class from the regex
`check:anatomy` harvests selectors with.
The kit's account popover was the whole of the connected face, so the app could
show the party id and nothing else. `TopBar/AccountMenu` replaces it with a
dropdown the app owns: a copyable party id, the network the session is on, and
the kit's `DisconnectButton`.

The top bar picks the face rather than mounting the kit's `WalletButton`, on the
party or the lock and not on `isConnected` alone. A standing session reports no
party while the account read is in flight and again after it fails, and the
connect face answers both — pending copy for the first, a retry for the second.
A lock is the one state that clears the party for good, and there the dropdown
is replaced whole by a disabled button. `useParty` reports that lock beside the
party, so nothing else in the app reaches for a canton-connect hook.

Dismissal is `hooks/useDismissable`, shared with `RoleSelect`: the focusout test
against the root, the Escape close, the focus returned to the trigger, and the
mousedown guard without which Safari unmounts the panel before the click lands.
The shell ended in a hardcoded status strip. `Footer` ports dappbooster.dev's:
the BootNode credit, the four social links and the two resource links, over one
outbound anchor and six new icons.
`ConnectButton` was one component in every doc that named it, and its popover
was the example for how Zag earns its place and how depth is decided. Both
seams move: the kit's is three components behind `/connect` that require a
provider ancestor, the app's is a top bar picking a face and an account menu
holding the session.

The shared pieces the app now has one of each of are listed with them, and the
published reference is at its own domain rather than the Vercel one.
Adds the shared writing style section to the issue and PR wizards and bumps
the starter kit stamp.
A wallet reports a lock and its own disconnect as the same push, so the
header cannot know which it is. The disabled button also disagreed with
the refresh path, which lands on the connect face for the same wallet
state.
TopBar already picks between the two faces on party, so the menu
re-reading the hook only to return null was a second gate on the same
condition.
- toParty mints a new object per push, so execute churned on a re-push
- the dApp then rebuilt its backend and re-read the ACS
- useParty follows the primary among usable accounts, not the wallet's
- a status with no party is legal; a party with no session is not
- the result types and README omitted the no-party refusal
…ayed-party

fix(connect): act as the party the kit shows, or refuse
- Node 26 ships localStorage as a lazy global; vitest 4 keeps a global it finds over jsdom's
- Node 26 failed 40/186 connect, 274/274 dappbooster and 18/155 frontend tests; Node 24 passes
- --no-experimental-webstorage in the workers drops Node's global, so jsdom's lands as on Node 24
- Fixed in vitest 5 (vitest-dev/vitest#10293), refused for v4 (#10873); drop the line on that bump
- engines.node has no upper bound, but every job pins .nvmrc, so Node 26 was never exercised
- A separate job keeps the `3 - test` name the ruleset requires; add this one there after merge
canton-barebones 0.3.0 -> 0.5.0, which moves the Splice checkout from 0.6.11
to 0.7.5, so an existing .canton-localnet/ re-scaffolds and the stack needs a
reset before the next run.

wallet-service v0.1.4 -> v0.2.1, which reads holdings off one paged
active-contracts snapshot instead of replaying the update stream. The old read
fired thousands of requests per balance and tripped the validator ingress rate
limit, which answered 503.

The allowBuilds key carries the resolved tarball id, so it moves to the new
commit sha with the ref.
…llet-service

chore: update localnet and wallet-service to their latest releases
- `connect.cancel` retires the sdk and lands in the `retiring` variant a
  closed picker would have taken, so the resume rules are shared
- per-child, since a parent transition cannot tell `new` from `changing`
  and a wallet change must keep its session on the way back
- no timeout anywhere: a wallet login takes as long as it takes, and a
  bound firing mid-approval would invite a second, colliding connect
- docs say the connect wait ends on an answer or a cancel, not a clock
- `guardedConnect` takes the abort signal xstate fires when it stops the
  connect actor, so leaving `connecting` needs no extra plumbing
- on abort it closes the window it captured, forges the picker's own
  result so a pre-pick listener settles, and rejects
- rejecting is not cosmetic: the poll and the `window.open` borrow are
  released in the race's `finally`, which an unsettled race never reaches
- a cancel skips the `status()` recovery a closed picker skips, for the
  same reason: both leave a connect running on an sdk about to be retired
- `stranded`, `walked-out` and `orphan` all named the same thing: a
  `sdk.connect()` still running with no listener and no way to stop it
- `abandoned` wins because it cannot move: it is already in
  `settleAbandonedConnect`, in `abandonOn`, and in the wire value
  `providerId: 'abandoned'`
- comments, test titles, two local names, the README line and the
  popup-close-guard chapter, heading included
- fire-and-forget, no bridge: the pending `connect()` is what reports the
  outcome, rejecting on the `connect.cancelled` it already waited for
- on the context value too, so the fake session double answers it
- `hangingPicker`, a picker that never answers, is the wallet this
  exists for; suite-local, not on the testing barrel
- docs: three actions became four
- `aria-disabled` and the click gate are gone: while pending the click
  routes to `cancelConnect` instead of `connect`
- the pending state names the cancel in `aria-label`, since nothing
  visible here says a click ends the attempt
- no visible hint: one outside the button is a consumer's, and one
  positioned over it is clipped by the `overflow-hidden` a consumer's
  own button classes carry
- `composeAction` takes a sync action, wrapping it because `.catch` on a
  void return throws
- `[data-pending]` stops sharing the `:disabled` look and rejoins
  `:hover` and `:active`
`formatFigureCompact` already answers `N/A` for an amount it cannot read,
while `formatFigureFull` threw, so which of the two crashed a render was
something a caller had to remember.

It now checks with the kit's `parseAmount` first and answers `N/A` too.
The exposed caller was the success toast in `Claim.tsx`, which formats
whatever the backend returned after a claim.
…an reach

The dev server folds the published DevNet entries into the LocalNet asset
list, so the token list is a real catalogue rather than the one coin a
LocalNet issues. Those entries name registries on remote hosts, and the
app asked each of them for metadata straight from the browser.

Every one of those reads failed:

- The hosts send no `Access-Control-Allow-Origin`, so the browser blocked
  the reply whatever it held.
- The replies were 403 or 404 anyway, since the DevNet scan is gated and
  that host serves none of those registrars.

`readInstruments` already catches the failure, so the cost was a console
full of red rather than a missing row. Filter the list down to same-origin
addresses instead, which is `/registry` and whatever else Vite proxies.
Those tokens keep the symbol and logo the curated list carries; only the
name and decimals go, and those never arrived.
feat: build the token picker's list from the ledger and the registries
… switch

The strip only reflected what was true at connect. Nothing re-read either
synchronizer id, so switching networks in the wallet left it stale, which made
the warning useless in the case it exists for.

- useWrongNetwork re-reads on three triggers: the party changing, the page
  regaining focus, and every 30 seconds. Focus catches a switch as it happens,
  because using the wallet takes focus off the page. visibilitychange misses it,
  since an extension popup draws over the tab rather than hiding it.
- synchronizer.ts reads /v2/state/connected-synchronizers in one request. The
  wallet's side no longer comes from the factory row in config.ts: that read is
  three round trips and its answer rebuilds the backend, so polling it would
  re-run every ledger read.
- wrongNetwork tests membership instead of equality. A participant can be
  connected to several synchronizers, and reaching the app's one is what decides
  whether a write lands.
- A failed read stays silent and leaves the last answer standing.

It has to poll: CIP-0103 defines no network-change event and the SDK pushes
accounts only.
The strip claimed to name a target network to switch to, and nothing can
name one a reader could act on. CIP-0103 defines `networkId` for a wallet
only, and the label wallet-service could supply from its NETWORK variable
is typed by hand on both sides, so the two read the same for different
networks as easily as differently for one.

- The strip now reads "Wrong network: wallet is connected to
  bootnode-devnet, switch networks to proceed." It names the wallet's own
  label and no target, and drops the synchronizer ids, which were unreadable
  and told the user nothing they could act on.
- networkLabel drops the CAIP-2 namespace, since every network this app sees
  is under canton. A label with no namespace stays whole.
- wrongNetwork returns a boolean. Nothing rendered the ids any more, so the
  result payload went with them.
- architecture.md records why there is no target label and that a wallet
  quiet about its network reads as canton:local.
The connected-synchronizers read put the party in the resource string. A wallet
that checks the resource against the ledger API's own route list rejects that,
and the hook swallows the failure, so the strip never appears. The party now
travels in `query`, the way every other read passes one.

Three fixes in `useWrongNetwork`:

- Only the last check started may write, so a slow older read cannot overwrite a
  newer verdict.
- The verdict carries the party it was read for, so the previous party's answer
  is not shown against the new one's network.
- The app's synchronizer id is read once and kept. wallet-service answers for one
  network, so every later check is a single participant read rather than another
  `amulet.tap` every 30 seconds.

On the wrong network `config.ts` finds no operator and throws its
`run pnpm run bootstrap` advice, which filled the page with a symptom. The card
now names the network, and the advice stays for a ledger that really has no
deployment.

Adds `synchronizer.test.ts`: the happy path, the request shape, the empty cases.
Split `fetchAppNetwork` out of `fetchTransferContext`. That builder needs an
AmuletRules and an open mining round both, and the SV opens the first round
minutes after a LocalNet start, so the check could not answer until then. The
network id sits on the AmuletRules disclosure alone.

Also:

- Box the cached app network, so an answer carrying no id counts as read.
  Before, the ref held the bare id, so every later check re-ran the tap.
- Read the wallet's synchronizers through `call` in config.ts instead of a
  second inline cast.
- Simplify `networkLabel` to one expression and drop a redundant test row.
- Move the rule about passing a filter in `query` into dapp/frontend/CLAUDE.md,
  where the next author looks for it.
- Trim comments that restated the code or copied architecture.md.
# Conflicts:
#	dapp/frontend/architecture.md
ConnectButton cancelled the connect when clicked while it read
"Connecting...". A double-click therefore connected and cancelled itself,
and a button whose label says one thing and whose click does another is a
trap.

- ConnectButton connects only, and is inert while an attempt is in flight
- CancelButton is new: it cancels only, and is inert while there is none
- Either button ignores every click past the first

Both buttons carry aria-disabled rather than the native disabled
attribute, so they stay focusable and announced. That leaves the click
live, so resolveInert pairs the attribute with a handler that calls
preventDefault, and a caller's type="submit" cannot submit a form from an
inert button.

CancelButton spins while the attempt runs and keeps its accessible name on
the action. The spinner is aria-hidden, so the wait reaches a screen
reader through a live region beside the button, filled after the first
paint: a region that arrives already filled is never read out.

The theme dims either button on :disabled or aria-disabled, which is the
look #142 removed.
The prompt used to show the connect button plus a "(click to cancel)"
hint, because that button did both jobs. Now that each button does one
thing, the connect button is swapped for the cancel one while an attempt
is in flight, so a single button is on screen at a time and the hint has
nothing left to explain.

ConnectFace is the swap, rendered by both the prompt and the top bar. An
attempt started in the top bar can now be abandoned there rather than only
from the prompt further down the page.

It is a leaf on purpose, holding the only useConnect subscription of the
two, so an attempt re-renders one button instead of the whole header or
the empty state around it.

A swap costs the keyboard its place, since the focused button is the one
being unmounted. ConnectFace hands focus to whichever button took over,
and only after a real swap, so nothing moves focus on first paint.
Step 5 of the dapp-sdk walkthrough told the reader to click Connect twice,
which was how a connect was cancelled before the split. The cancel is its
own button now, and it replaces the connect one, so the step names what to
click instead.
WalletButton follows the session, and a connect in flight is a session
state, but it only knew two faces. Since the connect button went inert
while pending, a consumer taking WalletButton had no way out of a wallet
prompt that never answers.

- Cancel face while a connect is in flight
- Disconnect face once a session stands, connect face otherwise
- Focus moves to whichever face took over, since a swap unmounts the
  focused button

The three faces publish a ref for that focus move, which is what
WalletButton hands them. WalletButton itself takes none, because which
element it landed on would depend on the session.

The vesting dApp still builds its own swap. Its connected face is a
dropdown rather than a plain disconnect button, and its prompt must never
offer to disconnect.
The span existed only to give the focus handoff something to run
querySelector on. The kit's wallet buttons publish a ref now, so
ConnectFace puts it straight on the face it renders and the span, its
inline-flex class and the DOM query all go.

The top bar's button is a direct flex item of the header row again, which
is where it sat before this branch.
What is left is only what the code cannot carry: the live region filling a
paint late, the focus move skipped on first paint, why aria-disabled needs
its click refused, and the two test-fixture constraints. Every barrel
export keeps its JSDoc, which pnpm docs:check requires.
The inert helper paired aria-disabled with its refused click so neither
could be written without the other. There is no third inert button to
protect, and the module cost more lines than the two it saved in each
button, so each one now writes the attribute and the handler itself.
feat: split the cancel out of the connect button
feat: warn when the connected wallet is on a different network than the dApp
@lmcorbalan lmcorbalan self-assigned this Sep 9, 2026
@vercel

vercel Bot commented Sep 9, 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 9, 2026 6:35pm UTC
docs.canton-dappbooster Ready Ready Preview Sep 9, 2026 6:35pm UTC

Request Review

@fernandomg fernandomg moved this from Backlog to In review in Canton - dAppBooster (#390) Sep 9, 2026
@lmcorbalan
lmcorbalan merged commit 7577d0a into feat/157-token-forge-migration Sep 9, 2026
9 checks passed
@lmcorbalan
lmcorbalan deleted the chore/199-merge-main branch September 9, 2026 19:45
@github-project-automation github-project-automation Bot moved this from In review to Done in Canton - dAppBooster (#390) Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants