feat: add an Amulet faucet to the vesting demo app - #145
feat: add an Amulet faucet to the vesting demo app#145gabitoesmiapodo wants to merge 11 commits into
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| // 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. |
There was a problem hiding this comment.
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}> |
There was a problem hiding this comment.
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' | |||
There was a problem hiding this comment.
Only some refactoring / simplification work here.
More to come...
| @@ -181,9 +181,9 @@ export const CreateGrant = ({ onClose }: { onClose: () => void }): React.JSX.Ele | |||
|
|
|||
There was a problem hiding this comment.
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`, |
There was a problem hiding this comment.
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.
| 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', |
There was a problem hiding this comment.
Just another thing I thought we had already in place and usually would belong into another PR.
| '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 |
There was a problem hiding this comment.
Claude REALLY likes putting comments everywhere, eh?
Well, me too, that's what I'm doing right now.
Fuck you, Claude.
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
Changes
Deviations
disclosedBytesit was the only reader ofAcceptance criteria
tap(partyId)onVestingBackend, implemented inLedgerBackendcommands.tsofffetchTransferContext, with no new network callTopBar, absent with no connected partyButton'spendingprop and the toasterTest plan
Automated tests
pnpm installpnpm testManual verification
README.mddescribes, then runpnpm run bootstrapandpnpm app:devBreaking changes
None.
Checklist
Screenshots
Screen.Recording.2026-09-03.at.16.50.39.mov