Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .lintstagedrc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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.

// 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.

// single run anyway, so splitting it per package bought a second and two ways to drift.
// Snippets live in the READMEs as well as in the doc blocks, so a doc edit re-runs the compile.
Expand Down
4 changes: 2 additions & 2 deletions dapp/frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ One implementation each, so a second one is a bug and not a choice:
unstyled) and never a hand-rolled bubble. With a string child it dash-underlines the words; with an
element it does not, so an icon trigger is a legal child. Childless it is a `?` badge.
- **A figure: `components/AmountDisplay`.** It owns the grouping, the forced two decimals, and the
Canton Coin mark with its tooltip. `count` is the escape hatch for a tally, which owes neither.
Amulet mark with its tooltip. `count` is the escape hatch for a tally, which owes neither.
Where the surrounding text already spells out the unit, reach for `components/CompactAmount`, the
same figure without the mark: it is what keeps the exact value in a tooltip and in the accessible
name once an outsized amount is abbreviated, so a hand-rolled `formatCCCompact` loses it.
name once an outsized amount is abbreviated, so a hand-rolled `formatFigureCompact` loses it.
- **Button classes on something that is not `components/Button`:** import `buttonClass`. The kit's
own buttons take a `className` but cannot render ours. A button waiting on a submission takes
`pending`, which owns the spinner, the wording and the disable together. The one exception is
Expand Down
12 changes: 8 additions & 4 deletions dapp/frontend/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# @canton-dappbooster/frontend — Canton Coin vesting dApp
# @canton-dappbooster/frontend — Amulet vesting dApp

dApp for **Canton Coin vesting**: propose a grant, the receiver accepts, claim as it
vests, or cancel into a residual claim. Accepting locks the funder's Canton Coin in an
Amulet escrow and each claim releases part of it, so the figures on screen are real
dApp for **Amulet vesting**: propose a grant, the receiver accepts, claim as it
vests, or cancel into a residual claim. Accepting locks the funder's Amulet in
escrow and each claim releases part of it, so the figures on screen are real
holdings; grants render live vested/claimable figures from the pure schedule math in
[`src/utils/schedule.ts`](src/utils/schedule.ts).

Expand Down Expand Up @@ -33,6 +33,10 @@ finds both once a wallet connects: the operator through the rights the bootstrap
factory through an active-contracts read that returns its explicit-disclosure payload, without
which a grant cannot be created. Re-running it supersedes the last one, on any ledger.

Funding a grant takes Amulet, and the account menu has a faucet for it: **Tap Amulet** taps
100 AMT into the connected party. It works on LocalNet and devnet, where the choice exists, and
refuses until the SV has opened the first mining round, roughly ten minutes after a fresh start.

Connect with a CIP-0103 browser wallet; the party it reports is the one you act as, and
the session is restored on reload by the wallet itself. Changing the wallet's primary
account changes the party the dApp acts as. Its two env knobs — the explorer party ids
Expand Down
18 changes: 13 additions & 5 deletions dapp/frontend/architecture.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Architecture — Canton Coin vesting dApp
# Architecture — Amulet vesting dApp

The app's internal seams and the reasoning behind them. What this is and how to run it is in
[`README.md`](README.md); repo-wide rules live in [`../../CLAUDE.md`](../../CLAUDE.md) and the
Expand Down Expand Up @@ -64,6 +64,14 @@ refuse the origin on the preflight and the validator's scan-proxy wants a bearer
went with it: tap resolves the active one itself, and a LocalNet whose SV has not opened the first
round yet fails the call, which is a wait rather than a bug.

The faucet in the account menu is the one place the app taps for real. `LedgerBackend.tap` builds
`AmuletRules_DevNet_Tap` for `TAP_AMOUNT` out of the same record and the same two disclosures every
other write already fetches, and the wallet signs it as the connected party, so the button costs no
extra read. Composed here rather than forwarded: wallet-service's own prepared tap carries a fixed
amount, and this way `TAP_AMOUNT` is the app's to change. The choice exists on LocalNet and devnet
only, and before the SV opens the first round it refuses with `OpenMiningRound active at current
moment not found`, which reaches the user as the failure toast.

Where that call goes is `VITE_WALLET_RPC_URL`. Locally it is wallet-service itself; a deployed build
sets it to `/api/rpc`, [the app's own function](api/rpc.ts), because an https page cannot call a
plain-http wallet-service and Node's fetch has no such policy. The function forwards `amulet.tap`
Expand Down Expand Up @@ -236,9 +244,9 @@ total and [`Claim`](src/components/Claim.tsx)'s withdrawal are both the kit's
in [`src/utils/amountErrorText.ts`](src/utils/amountErrorText.ts), again an exhaustive `Record` so a
code added upstream fails the build here.

**Neither field offers the token picker, and that is deliberate.** Both pass `token={CC}` and no
**Neither field offers the token picker, and that is deliberate.** Both pass `token={AMT}` and no
`onTokenSelect`, which is what makes the kit render the symbol as a static mark rather than a button.
[`src/utils/tokens.tsx`](src/utils/tokens.tsx) holds `CC` and nothing else, because that is the only
[`src/utils/tokens.tsx`](src/utils/tokens.tsx) holds `AMT` and nothing else, because that is the only
instrument this deployment knows, so a picker over it would open a dialog to choose the value already
chosen. The claim dialog has a second reason it will keep: what a grant pays out is fixed by the
contract, so there is nothing there to pick.
Expand All @@ -248,11 +256,11 @@ Turning the create field back into a real picker takes three things, none of the
- **A list to choose from.** `TOKENS` in `src/utils/tokens.tsx` is a hardcoded one-entry array. It
becomes whatever enumerates the instruments a deployment actually holds, and the kit's
`TokenListProvider` is what the picker reads it through.
- **A selection to hold.** The field re-grows its own `useState<TokenMeta>(CC)` and passes
- **A selection to hold.** The field re-grows its own `useState<TokenMeta>(AMT)` and passes
`onTokenSelect`. Per-field rather than lifted, unless by then two amounts on one page must agree.
- **The rest of the app told about it.** Today the pick would be display-only: the re-lock floor's
wording, the claim toast, `AmountDisplay`'s coin mark and the grant that gets created all say
Canton Coin in their own right. Each has to take the chosen token instead, or a pick would relabel
Amulet in their own right. Each has to take the chosen token instead, or a pick would relabel
one field and silently mean nothing.

Both pages re-derive that code with the kit's own `validateAmount` rather than storing the one
Expand Down
30 changes: 24 additions & 6 deletions dapp/frontend/src/backend/LedgerBackend.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { encodeSchedule } from '@/backend/commands'
import { encodeSchedule, TAP_AMOUNT } from '@/backend/commands'
import type { Deployment } from '@/backend/config'
import { LedgerBackend } from '@/backend/LedgerBackend'
import type { DisclosedContract, LedgerCommand, WalletFns } from '@/backend/wallet'
Expand Down Expand Up @@ -264,13 +264,13 @@ describe('LedgerBackend.createVesting', () => {
},
})

await expect(backend.createVesting(grant)).rejects.toThrow(/only 400 CC is free/)
await expect(backend.createVesting(grant)).rejects.toThrow(/only 400 AMT is free/)
})

it('exercises the factory choice with the composed note and schedule, disclosing only it', async () => {
const { backend, submissions } = harness()

const result = await backend.createVesting(grant)
await backend.createVesting(grant)

expect(submissions[1]?.commands).toEqual([
{
Expand Down Expand Up @@ -299,7 +299,6 @@ describe('LedgerBackend.createVesting', () => {
synchronizerId: 'sync::1',
},
])
expect(result.disclosedBytes).toBe(deployment.factoryBlob.length)
})

it('omits the synchronizer id when the config carries none', async () => {
Expand All @@ -311,10 +310,10 @@ describe('LedgerBackend.createVesting', () => {
expect(submissions[1]?.disclosedContracts?.[0]).not.toHaveProperty('synchronizerId')
})

it('refuses a grant the funder holds no Canton Coin for', async () => {
it('refuses a grant the funder holds no Amulet for', async () => {
const { backend } = harness({ acs: {} })

await expect(backend.createVesting(grant)).rejects.toThrow(/only 0 CC is free/)
await expect(backend.createVesting(grant)).rejects.toThrow(/only 0 AMT is free/)
})

// A disclosure is an opaque blob, so the split reads the DSO off an Amulet it consumes.
Expand Down Expand Up @@ -398,6 +397,25 @@ describe('LedgerBackend submissions', () => {
])
})

it('taps AmuletRules for the connected party, on the same two disclosures', async () => {
const { backend, submissions } = harness()

await backend.tap('funder::1')

const command = submissions[0]?.commands?.[0]?.ExerciseCommand
expect([command?.templateId, command?.choice, command?.contractId]).toEqual([
transferContext.rulesTemplateId,
'AmuletRules_DevNet_Tap',
transferContext.ctx.amuletRules,
])
expect(command?.choiceArgument).toEqual({
receiver: 'funder::1',
amount: TAP_AMOUNT,
openRound: transferContext.ctx.openMiningRound,
})
expect(submissions[0]?.disclosedContracts).toEqual(onSync(transferContext.disclosed))
})

it('carries the transfer context into every choice argument that takes one', async () => {
const { backend, submissions } = harness()

Expand Down
25 changes: 17 additions & 8 deletions dapp/frontend/src/backend/LedgerBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildClaimResidualCommand,
buildCreateVestingCommand,
buildSplitCommand,
buildTapCommand,
buildWithdrawCommand,
} from '@/backend/commands'
import type { Deployment } from '@/backend/config'
Expand Down Expand Up @@ -189,9 +190,8 @@ export class LedgerBackend implements VestingBackend {
// and one Daml transaction cannot feed a contract that one command creates into the next. So the
// funder splits exactly `totalAmount` off its unpledged holdings, and the grant names only what
// the split produced. The factory is the operator's and observer-less, so the funder cannot read
// it and its disclosure comes from the deployment; the blob size is what lets the UI surface that
// mechanic.
async createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> {
// it and its disclosure comes from the deployment.
async createVesting(args: CreateVestInput): Promise<void> {
const escrow = await this.splitOff(args.proposer, args.totalAmount)
const command = buildCreateVestingCommand(this.factory.templateId, this.factory.contractId, {
proposer: args.proposer,
Expand All @@ -206,7 +206,6 @@ export class LedgerBackend implements VestingBackend {
// Amulet no grant is waiting on. Appended rather than replacing, because every outstanding
// grant's own Amulet has to stay disclosable.
localStorage.setItem(AMULET_STORE_KEY, JSON.stringify([...storedAmulets(), escrow]))
return { disclosedBytes: this.factory.createdEventBlob.length }
}

// A transfer consumes everything it is given, so an Amulet an outstanding grant pledged has to
Expand All @@ -226,11 +225,11 @@ export class LedgerBackend implements VestingBackend {
// invariant is held here rather than re-spelled per choice; `extra` is what only Accept adds.
private async submitWithContext(
actAs: string,
build: (ctx: AppTransferContext) => LedgerCommand,
build: (ctx: AppTransferContext, rulesTemplateId: string) => LedgerCommand,
extra: DisclosedContract[] = [],
): Promise<void> {
const { ctx, disclosed } = await fetchTransferContext(actAs)
await this.submit(actAs, build(ctx), [...disclosed, ...extra])
const { ctx, disclosed, rulesTemplateId } = await fetchTransferContext(actAs)
await this.submit(actAs, build(ctx, rulesTemplateId), [...disclosed, ...extra])
}

// Self-transfers `amount` into an Amulet of the funder's own and returns it, disclosure blob
Expand All @@ -244,7 +243,7 @@ export class LedgerBackend implements VestingBackend {
const freeTotal = addAmounts(...free.map(amuletValue))
if (compareAmounts(freeTotal, amount) < 0) {
throw new Error(
`only ${freeTotal} CC is free to fund this grant — the rest is pledged to a pending one`,
`only ${freeTotal} AMT is free to fund this grant — the rest is pledged to a pending one`,
)
}
const dso = free.map(amuletDso).find((party) => party !== undefined)
Expand Down Expand Up @@ -369,4 +368,14 @@ export class LedgerBackend implements VestingBackend {
buildClaimResidualCommand(this.tid('AmuletVestedClaim'), args.claimCid, args.amount, ctx),
)
}

// The only write not on an amulet-vesting template: it exercises AmuletRules itself.
async tap(partyId: string): Promise<void> {
await this.submitWithContext(partyId, (ctx, rulesTemplateId) =>
buildTapCommand(rulesTemplateId, ctx.amuletRules, {
openMiningRound: ctx.openMiningRound,
receiver: partyId,
}),
)
}
}
3 changes: 2 additions & 1 deletion dapp/frontend/src/backend/VestingBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export interface VestingBackend {
cancel(args: { creator: string; contractCid: string }): Promise<void>
claimHistory(partyId: string, contractCid: string): Promise<ClaimRecord[]>
claimResidual(args: { receiver: string; claimCid: string; amount: string }): Promise<void>
createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }>
createVesting(args: CreateVestInput): Promise<void>
tap(partyId: string): Promise<void>
viewAs(partyId: string): Promise<VestingView>
withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise<void>
}
Expand Down
18 changes: 18 additions & 0 deletions dapp/frontend/src/backend/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
buildCancelCommand,
buildClaimResidualCommand,
buildCreateVestingCommand,
buildTapCommand,
buildWithdrawCommand,
decodeSchedule,
encodeSchedule,
TAP_AMOUNT,
} from '@/backend/commands'
import type { AppTransferContext } from '@/backend/transferContext'
import type { VestingSchedule } from '@/utils/schedule'
Expand Down Expand Up @@ -159,6 +161,22 @@ describe('command builders', () => {
})
})

it('buildTapCommand exercises AmuletRules itself, for the fixed amount', () => {
expect(
buildTapCommand('RULESTID', ctx.amuletRules, {
openMiningRound: ctx.openMiningRound,
receiver: 'P',
}),
).toEqual({
ExerciseCommand: {
templateId: 'RULESTID',
contractId: 'rules-cid',
choice: 'AmuletRules_DevNet_Tap',
choiceArgument: { receiver: 'P', amount: TAP_AMOUNT, openRound: 'round-cid' },
},
})
})

it('canonicalizes a trailing-dot amount before it reaches the payload', () => {
// A Daml Numeric literal has no trailing-dot form; the input filters upstream let '1000.' through.
const cmd = buildCreateVestingCommand('TID', 'fcid', {
Expand Down
15 changes: 15 additions & 0 deletions dapp/frontend/src/backend/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ export const buildSplitCommand = (
expectedDso: args.dso,
})

// 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...

export const buildTapCommand = (
templateId: string,
amuletRulesCid: string,
args: { openMiningRound: string; receiver: string },
) =>
exercise(templateId, amuletRulesCid, 'AmuletRules_DevNet_Tap', {
receiver: args.receiver,
amount: TAP_AMOUNT,
openRound: args.openMiningRound,
})

export const buildAcceptCommand = (
templateId: string,
pendingCid: string,
Expand Down
4 changes: 2 additions & 2 deletions dapp/frontend/src/components/AmountDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import cantonCoin from '@/assets/canton-coin.png'
import { CompactAmount } from '@/components/CompactAmount'
import { InfoTip } from '@/components/InfoTip'
import { cn } from '@/utils/cn'
import { CC } from '@/utils/tokens'
import { AMT } from '@/utils/tokens'

interface AmountDisplayProps {
className?: string
Expand All @@ -14,7 +14,7 @@ interface AmountDisplayProps {
}

// The token mark is the only thing naming the unit, so it carries the name rather than an empty alt.
const UNIT = `${CC.name} (${CC.symbol})`
const UNIT = `${AMT.name} (${AMT.symbol})`

// Neither varies, so hoisting them lets React skip the subtree by element identity. The dashboard
// re-renders every amount once a second off the live clock, and each mark carries a `useId` tooltip.
Expand Down
2 changes: 1 addition & 1 deletion dapp/frontend/src/components/CancelGrant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const CancelGrant = ({
{!floorOk && (
<FieldError
id="cancel-residual-floor"
message={`The residual must be 0 or at least ${MIN_GRANT_AMOUNT} CC. Cancel once more has vested, or let the receiver claim it down to zero.`}
message={`The residual must be 0 or at least ${MIN_GRANT_AMOUNT} AMT. Cancel once more has vested, or let the receiver claim it down to zero.`}
/>
)}
<Button
Expand Down
10 changes: 5 additions & 5 deletions dapp/frontend/src/components/Claim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { Modal } from '@/components/Modal'
import { isPositive } from '@/utils/amount'
import { AMOUNT_ERROR_TEXT } from '@/utils/amountErrorText'
import { errorText } from '@/utils/errorText'
import { formatCCFull } from '@/utils/format'
import { formatFigureFull } from '@/utils/format'
import { MIN_GRANT_AMOUNT, meetsRelockFloor } from '@/utils/schedule'
import { toast } from '@/utils/toast'
import { CC } from '@/utils/tokens'
import { AMT } from '@/utils/tokens'

interface ClaimProps {
available: string
Expand Down Expand Up @@ -42,7 +42,7 @@ export const Claim = ({
amountError !== undefined
? AMOUNT_ERROR_TEXT[amountError]
: !floorOk && isPositive(raw)
? `Remainder must be 0 or at least ${MIN_GRANT_AMOUNT} CC (re-lock floor).`
? `Remainder must be 0 or at least ${MIN_GRANT_AMOUNT} AMT (re-lock floor).`
: undefined

const valid = amountError === undefined && isPositive(raw) && floorOk
Expand All @@ -56,7 +56,7 @@ export const Claim = ({
await onConfirm(raw)
// Exact, not abbreviated: this is the only record of what the ledger took and it carries no
// tooltip to recover the digits from.
toast.success(`Claimed ${formatCCFull(raw)} ${CC.symbol}`)
toast.success(`Claimed ${formatFigureFull(raw)} ${AMT.symbol}`)
onClose()
} catch (err) {
toast.error(errorText(err))
Expand All @@ -74,7 +74,7 @@ export const Claim = ({
id="claim-amount"
label="Available to claim"
onChange={(next) => setRaw(next)}
token={CC}
token={AMT}
usdValue="Not Available"
value={raw}
/>
Expand Down
14 changes: 7 additions & 7 deletions dapp/frontend/src/components/CompactAmount.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { InfoTip } from '@/components/InfoTip'
import { formatCCCompact, formatCCFull, isCompacted } from '@/utils/format'
import { CC } from '@/utils/tokens'
import { formatFigureCompact, formatFigureFull, isCompacted } from '@/utils/format'
import { AMT } from '@/utils/tokens'

// A Canton Coin figure, abbreviated past 10,000 with the exact one still reachable. Carries no unit
// and no mark, so it suits a line that already spells out CC; `AmountDisplay` wraps it for the rest.
// An Amulet figure, abbreviated past 10,000 with the exact one still reachable. Carries no unit and
// no mark, so it suits a line that already spells out AMT; `AmountDisplay` wraps it for the rest.
// `plain` drops the tooltip for a caller that cannot legally nest its trigger, which is a button:
// the exact figure then reaches a reader by ear only, and its unit comes from the caller's own text.
export const CompactAmount = ({
Expand All @@ -13,11 +13,11 @@ export const CompactAmount = ({
plain?: boolean
value: string
}): React.JSX.Element => {
const figure = formatCCCompact(value)
const figure = formatFigureCompact(value)
if (!isCompacted(value)) {
return <>{figure}</>
}
const exact = formatCCFull(value)
const exact = formatFigureFull(value)
if (plain) {
return (
<>
Expand All @@ -26,7 +26,7 @@ export const CompactAmount = ({
</>
)
}
const labelled = `${exact} ${CC.symbol}`
const labelled = `${exact} ${AMT.symbol}`
return (
<>
<InfoTip label={labelled}>
Expand Down
Loading