Skip to content

feat: add an Amulet faucet to the vesting demo app - #145

Open
gabitoesmiapodo wants to merge 11 commits into
mainfrom
feat/134-faucet
Open

feat: add an Amulet faucet to the vesting demo app#145
gabitoesmiapodo wants to merge 11 commits into
mainfrom
feat/134-faucet

Conversation

@gabitoesmiapodo

@gabitoesmiapodo gabitoesmiapodo commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #134

Getting test Amulet meant leaving the dApp for the wallet's Utils panel. The dApp already fetches everything a tap needs on every write, so it now offers the faucet itself.

Review guide

  • Read closely: the faucet backend commit and the over-balance fix. Both change what the ledger is asked to do.
  • Skim: the rename. One look at the token definition, then trust the compiler and the tests.
  • Skip: anything visual. The dropdown rework, the shared copy button, the toast wording. The screenshots cover them, and the deviations least of all need your time.
  • Docs: skip them, or hand them to an LLM to check against the code.

Changes

  • The account menu offers Tap Amulet, which taps 100 AMT into the connected party
  • The token reads Amulet and AMT everywhere a user can see it, not Canton Coin and CC
  • The account dropdown shows an avatar, the party name, the fingerprint with a copy button, and the network with a disconnect icon
  • A grant amount above the balance flags the field and blocks Continue
  • The toast after creating a grant reads "Grant created"
  • The pre-commit hook runs the frontend tests

Deviations

  • No store action for the tap: the store reads only the three amulet-vesting templates, never the Splice Amulet a tap creates
  • No pending state on the item: the click is fire and forget and the toasts carry the outcome
  • The button sits inside the account menu, which the issue named as its eventual home; that dropdown reached main meanwhile
  • Renamed the token to Amulet and AMT, so the faucet's own wording would not say Canton Coin
  • Reworked the account dropdown, because the faucet needed a row in it
  • Shared one copy button between the dropdown and the toaster
  • Fixed an over-balance grant amount drawing no error and leaving Continue enabled
  • Shortened the grant toast and dropped the disclosedBytes it was the only reader of
  • Added the frontend tests to the pre-commit hook, which ran only the two libraries'

Acceptance criteria

  • tap(partyId) on VestingBackend, implemented in LedgerBackend
  • Command built in commands.ts off fetchTransferContext, with no new network call
  • Amount is one named constant in the app
  • Store action submits then refreshes, so the balance moves
  • Button in TopBar, absent with no connected party
  • Pending and failure go through Button's pending prop and the toaster
  • Tests cover the command builder

Test plan

Automated tests

  1. Run pnpm install
  2. Run pnpm test
  3. Expect 617 passing tests across the three packages, plus 10 from the root scripts

Manual verification

  1. Bring the stack up as the root README.md describes, then run pnpm run bootstrap and pnpm app:dev
  2. Connect a CIP-0103 wallet and open the account menu in the header
  3. Press Tap Amulet and approve the wallet prompt
  4. Expect a toast saying the tap went out, then one saying 100 AMT was tapped
  5. Open Create grant and type an amount larger than the balance the field shows
  6. Expect a red border on the amount and Continue disabled

Breaking changes

None.

Checklist

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

Screenshots

Screen.Recording.2026-09-03.at.16.50.39.mov

The dApp called its token Canton Coin, symbol CC. On the ledger it is an
Amulet, and that is the name the wallet and Splice use, so the app now says
Amulet and AMT everywhere a user can read it.

Identifiers moved with the words:

- `CC` in `src/utils/tokens.tsx` is now `AMT`.
- `formatCC`, `formatCCFull` and `formatCCCompact` are now `formatFigure`,
  `formatFigureFull` and `formatFigureCompact`. All three only round and group
  a decimal string, so they no longer carry a unit name at all.

Left alone on purpose, because they name the Canton Coin mark rather than the
token: the artwork in `src/assets/canton-coin.png`, the token id
`canton-coin`, and the note in `PROVENANCE.md`.
Funding a grant needs Amulet, and on LocalNet there is no way to get any from
inside the dApp. Splice's `AmuletRules` carries a faucet choice,
`AmuletRules_DevNet_Tap`, so the backend can now exercise it.

- `buildTapCommand` and `TAP_AMOUNT` in `src/backend/commands.ts` build the
  choice for one fixed amount, so a tap can be a button rather than a form.
- `tap(partyId)` joins the `VestingBackend` interface and `LedgerBackend`
  implements it.
- `submitWithContext` now hands its callback the resolved `AmuletRules`
  template id alongside the transfer context. It was already fetching it and
  throwing it away. The four existing callers take one argument and are
  unchanged.

A tap reuses the same transfer context and the same two disclosed contracts
every other write already fetches, so it costs no extra read. The choice only
exists on LocalNet and devnet; anywhere else the ledger rejects it.
The panel showed one truncated party id and a full-width Disconnect button.
It now reads like an account card.

- A 40px party avatar on the left, beside the display name. `PartyAvatar`
  takes an optional `size`, and its wrapper is a grid box so the inline SVG
  stops leaving descender space under the circle.
- The name is the party hint, and the line under it is the fingerprint alone.
  Copying still writes the whole `hint::fingerprint`, because a bare
  fingerprint addresses nobody.
- A network row with a green dot and the network id. The dot is the only sign
  of the connection, so a visually hidden "Connected to" carries the same
  state for a screen reader.
- Disconnect is now a bare power icon on the right of that row, with an
  aria-label. The kit's border, height, background and padding are overridden
  by Tailwind utilities, which win because the utilities layer is declared
  after the kit's own layer.

Separators run the full width of the panel through a negative margin against
its padding. That is deliberate, not a stray class.

A wallet name and icon would fit here but cannot be read: the CIP-0103 status
reply carries only a provider id, version and url, and the SDK does not
re-export the announcement API. The party hint fills that slot instead.
Closes #134.

The account menu now offers **Tap Amulet**, which taps 100 AMT into the
connected party so a grant can be funded without leaving the dApp. A new
`DropletIcon` marks it.

The click is fire and forget. The panel closes, an info toast says the tap
went out, and a success or error toast follows when it settles, so the menu is
not held open for the wallet prompt and the round trip behind it. There is no
pending state on the item.

It calls `backend.tap` directly rather than going through the store. Every
store action exists to pair a write with a refresh, and a tap creates a Splice
Amulet, which the store never reads: it reads the three amulet-vesting
templates only.

The item shows whenever a backend is connected, on every network. Gating it on
the network id would risk hiding it on LocalNet, since that string comes from
the wallet with no fallback. A ledger without the choice rejects the
submission and the user sees the error toast.
The README gains a paragraph telling a reader the faucet exists, what it
gives them, and that it needs the SV to have opened the first mining round,
which takes about ten minutes after a fresh LocalNet start.

`architecture.md` records the two decisions behind `LedgerBackend.tap`: that
it reuses the transfer context and disclosures every other write already
fetches, and that the command is built in the app rather than taking
wallet-service's own prepared tap, whose amount is fixed. Building it here is
what makes `TAP_AMOUNT` the app's to change.
`AccountMenu` and `Toaster/ToastRow` had each hand-rolled the same control:
`useCopyToClipboard`, an icon that swaps to a check, and an aria-label that
swaps with it. That is two implementations of one accessibility contract to
keep in step, and a second consumer is what promotes a module to
`components/`.

`components/CopyButton.tsx` now owns it. Both accessible names are unchanged:
the caller passes a capitalised `label` and the button lowercases it for the
"Copy x" wording.

The failure callback is `onOutcome`, not `onCopy`. A button already carries a
DOM `onCopy` clipboard event, and the prop spread collided with it.

`ToastRow` gains nothing and loses nothing here. `AccountMenu` keeps its own
error toast, since a rejected clipboard write leaves the icon unchanged and
would otherwise be silent.
Typing an amount far above the balance drew no error and left Continue
enabled. The grant then failed at submit with "only N AMT is free to fund this
grant".

The kit's `TokenInput` already checks this. Given a `balance` it runs
`validateAmount(value, { max: balance })` itself and flags the field. The app
was throwing that away twice:

- It passed `aria-invalid={amountError !== undefined}`, and its own
  `amountError` had no `max`. For an over-balance amount that prop was
  `false`, which beats the kit's own flag and erased the border. Removing the
  prop lets the field flag itself.
- `amountValid` read the same ceiling-less error, so the button never saw the
  problem either. The app now makes the same call the field makes, so both
  read one rule.

The ceiling is the balance as of when it was read. The holding fee moves the
true figure, so the field can occasionally refuse an amount the ledger would
have taken. That case is known and left for later. When the balance read
fails there is no ceiling at all, which is the right way round.
The toast after creating a grant read "Grant created · delivered via explicit
disclosure · 620 bytes". The mechanic is real but it is not news to the person
who just made a grant, and the number meant nothing to them. It now reads
"Grant created". The link, icon and tone are unchanged.

That was the only reader of `disclosedBytes`, so the field goes too. It ran
through `VestingBackend`, `LedgerBackend`, `useVestingStore` and both test
doubles to reach one toast. `createVesting` now returns nothing.

`factoryBlob` stays. That is the disclosure itself, still sent with every
grant; only its measured size is gone.
`.lintstagedrc.mjs` ran the test suites for `canton-connect` and
`canton-dappbooster` but not for `dapp/frontend`, so a broken frontend test
could be committed and survive until CI.

Not a decision anyone made. Each library added its own line when it was
scaffolded, and `dapp/frontend` predates both of them: it moved into place
with no tests at all and gained its first one much later. Nobody went back.

Biome already covered the app through the other pass, `.lintstagedrc.format.mjs`,
so only the read-only gate was missing.
@gabitoesmiapodo gabitoesmiapodo self-assigned this Sep 3, 2026
@vercel

vercel Bot commented Sep 3, 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 3, 2026 8:28pm UTC
docs.canton-dappbooster Ready Ready Preview Sep 3, 2026 8:28pm UTC

Request Review

// One fixed amount, so a tap is a button and not a form.
export const TAP_AMOUNT = '100'

// LocalNet and devnet only: the DSO does not carry this choice on a real network.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is obvious unless they're gifting CC and we don't know about it, but oh well...

}

return (
<div className="relative" ref={root} {...closers}>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dropdown and other common elements will be replaced by Ark UI's later, so don't pay too much attention to this implementation and constant reinventing of the wheel.

@@ -0,0 +1,36 @@
import { type CopyOutcome, useCopyToClipboard } from '@bootnodedev/canton-dappbooster'

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only some refactoring / simplification work here.

More to come...

@@ -181,9 +181,9 @@ export const CreateGrant = ({ onClose }: { onClose: () => void }): React.JSX.Ele

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this work in a separate PR seemed overkill and where in a hurry, so...

Probably part of a regression, this used to work just fine.

})
onClose()
toast.success(
`Grant created · delivered via explicit disclosure · ${result.disclosedBytes} bytes`,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100% unrelated to this PR, but...

I don't think this info was useful in any way, just the usual LLM fluff, so I removed it.

Comment thread .lintstagedrc.mjs
export default {
'canton-connect/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-connect test',
'canton-dappbooster/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-dappbooster test',
'dapp/frontend/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C dapp/frontend test',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just another thing I thought we had already in place and usually would belong into another PR.

Comment thread .lintstagedrc.mjs
'canton-connect/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-connect test',
'canton-dappbooster/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C canton-dappbooster test',
'dapp/frontend/src/**/*.{ts,tsx,js,jsx}': () => 'pnpm -C dapp/frontend test',
// One task for the whole doc gate, under the name CI uses: typedoc resolves both packages in a

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude REALLY likes putting comments everywhere, eh?

Well, me too, that's what I'm doing right now.

Fuck you, Claude.

@gabitoesmiapodo
gabitoesmiapodo marked this pull request as ready for review September 3, 2026 19:53
@gabitoesmiapodo gabitoesmiapodo moved this from Backlog to In review in Canton - dAppBooster (#390) Sep 3, 2026
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.

Add a Canton Coin faucet to the vesting demo app

1 participant