From 0c98f28e5260450937ae7c56e664bf06920f6435 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Wed, 9 Sep 2026 13:55:08 +0000 Subject: [PATCH 1/9] feat(dapp): build the proposal cancel and reject commands --- dapp/frontend/src/backend/commands.test.ts | 24 ++++++++++++++++++++++ dapp/frontend/src/backend/commands.ts | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/dapp/frontend/src/backend/commands.test.ts b/dapp/frontend/src/backend/commands.test.ts index 410796d7..8738eb1f 100644 --- a/dapp/frontend/src/backend/commands.test.ts +++ b/dapp/frontend/src/backend/commands.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest' import { buildAcceptCommand, buildCancelCommand, + buildCancelProposalCommand, buildClaimResidualCommand, buildCreateVestingCommand, + buildRejectProposalCommand, buildTapCommand, buildWithdrawCommand, decodeSchedule, @@ -113,6 +115,28 @@ describe('command builders', () => { }) }) + it("builds the funder's proposal cancel with no argument at all", () => { + expect(buildCancelProposalCommand('pkg:Vesting:VestingProposal', 'p1')).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingProposal', + contractId: 'p1', + choice: 'VestingProposal_Cancel', + choiceArgument: {}, + }, + }) + }) + + it("builds the receiver's proposal reject with no argument at all", () => { + expect(buildRejectProposalCommand('pkg:Vesting:VestingProposal', 'p1')).toEqual({ + ExerciseCommand: { + templateId: 'pkg:Vesting:VestingProposal', + contractId: 'p1', + choice: 'VestingProposal_Reject', + choiceArgument: {}, + }, + }) + }) + it('builds Withdraw with a canonical amount beside the config', () => { expect(buildWithdrawCommand('pkg:Vesting:VestingContract', 'c1', '10.5', CONFIG_CID)).toEqual({ ExerciseCommand: { diff --git a/dapp/frontend/src/backend/commands.ts b/dapp/frontend/src/backend/commands.ts index eb5df816..0abd09aa 100644 --- a/dapp/frontend/src/backend/commands.ts +++ b/dapp/frontend/src/backend/commands.ts @@ -104,6 +104,14 @@ export const buildCreateVestingCommand = ( export const buildAcceptCommand = (templateId: string, pendingCid: string, configCid: string) => exercise(templateId, pendingCid, 'VestingProposal_Accept', { configCid }) +// Neither exit takes the config: both choices are bodyless and move no holding, so a proposal is +// archived on its controller's authority alone. +export const buildCancelProposalCommand = (templateId: string, pendingCid: string) => + exercise(templateId, pendingCid, 'VestingProposal_Cancel', {}) + +export const buildRejectProposalCommand = (templateId: string, pendingCid: string) => + exercise(templateId, pendingCid, 'VestingProposal_Reject', {}) + // No nowMicros: the choice reads on-ledger getTime. export const buildWithdrawCommand = ( templateId: string, From 2daafe0e06bdcd1e159cc5a81dbf368e2d8a08cf Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Wed, 9 Sep 2026 14:00:55 +0000 Subject: [PATCH 2/9] feat(dapp): end an unaccepted grant from either side and forget what it left behind --- .../src/backend/LedgerBackend.test.ts | 132 ++++++++++++++++++ dapp/frontend/src/backend/LedgerBackend.ts | 56 +++++++- dapp/frontend/src/backend/VestingBackend.ts | 4 +- 3 files changed, 187 insertions(+), 5 deletions(-) diff --git a/dapp/frontend/src/backend/LedgerBackend.test.ts b/dapp/frontend/src/backend/LedgerBackend.test.ts index a44c0675..8ecceca4 100644 --- a/dapp/frontend/src/backend/LedgerBackend.test.ts +++ b/dapp/frontend/src/backend/LedgerBackend.test.ts @@ -106,6 +106,11 @@ const reserving = (tokenCid: string): unknown => const storedTokens = (): DisclosedContract[] => JSON.parse(localStorage.getItem('vesting.tokenDisclosures') ?? '[]') +// The other half of what a grant leaves behind: how many blob-bearing reads this browser has spent +// looking for a holding it never found. +const readMisses = (): Record => + JSON.parse(localStorage.getItem('vesting.tokenReadMisses') ?? '{}') + const CONFIG = { templateId: '20d54824:Canton.TokenForge.Registry:InstrumentConfig', contractId: '00cfg', @@ -622,6 +627,133 @@ describe('LedgerBackend.accept', () => { onSync([CONFIG, disclosedToken('funding-t1')]), ) }) + + // A miss counted before the blob was finally found used to outlive the grant entirely: `accept` + // dropped the blob and left the count behind for a contract id that can never come back. + it('drops a stale miss count when the grant is finally accepted', async () => { + const missed = harness({ + acs: { [TOKEN]: [tokenRow('unrelated', '500')], [PENDING]: [reserving('funding-t1')] }, + }) + await missed.backend.viewAs('funder::1') + expect(readMisses()['funding-t1']).toBe(1) + const found = harness({ + acs: { [TOKEN]: [tokenRow('funding-t1', '1000')], [PENDING]: [reserving('funding-t1')] }, + }) + await found.backend.viewAs('funder::1') + + await found.backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-funding-t1' }) + + expect(storedTokens()).toEqual([]) + expect(readMisses()).toEqual({}) + }) +}) + +// Both exits are bodyless and archive the proposal on the controller's own authority, so the +// interesting part is not the ledger but what stops being kept in this browser afterwards. +describe('LedgerBackend.cancelProposal and rejectProposal', () => { + const grant = (title = 'Advisor grant') => ({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title, + }) + + it('cancels as the funder, disclosing nothing', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const { backend, submissions } = harness({ acs }) + await backend.createVesting(grant()) + + await backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }) + + const submission = submissions.at(-1) + expect(submission?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Cancel') + expect(submission?.actAs).toEqual(['funder::1']) + // Empty, not the config: nothing is disclosed, which is also what proves the registry was + // never asked. + expect(submission?.disclosedContracts).toEqual([]) + }) + + it('rejects as the receiver, disclosing nothing', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting(grant()) + const { backend, submissions } = harness({ acs }) + + await backend.rejectProposal({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Reject') + expect(submissions[0]?.actAs).toEqual(['receiver::1']) + expect(submissions[0]?.disclosedContracts).toEqual([]) + }) + + it('drops the blob of the grant it ended and keeps every other one', async () => { + const acs: Record = { + [TOKEN]: [tokenRow('t1', '1500'), tokenRow('t2', '1500')], + } + const { backend } = harness({ acs }) + await backend.createVesting(grant('First grant')) + await backend.createVesting(grant('Second grant')) + + await backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }) + + expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t2']) + }) + + it('drops the read misses counted against the grant it ended', async () => { + const { backend } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '500')], [PENDING]: [reserving('archived-elsewhere')] }, + }) + await backend.viewAs('funder::1') + expect(readMisses()['archived-elsewhere']).toBe(1) + + await backend.cancelProposal({ + proposer: 'funder::1', + pendingCid: 'pending-archived-elsewhere', + }) + + expect(readMisses()).toEqual({}) + }) + + it('refuses to end a grant that is no longer outstanding', async () => { + const { backend, submissions } = harness() + + await expect( + backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }), + ).rejects.toThrow(/no longer outstanding/) + expect(submissions).toHaveLength(0) + }) + + // A prompt the wallet declines leaves a grant that is still outstanding and still acceptable, so + // forgetting its blob would break the one thing this browser is holding for it. + it('keeps the blob when the wallet declines the cancel', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting(grant()) + const declined = harness({ acs, declines: true }) + + await expect( + declined.backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }), + ).rejects.toThrow(/rejected/) + + expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t1']) + }) + + // Criterion 3, and it costs no ledger work: the holding is excluded only while a proposal names + // it, so archiving the proposal is what returns it. + it('returns the reserved holding to the funder’s balance', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const { backend } = harness({ acs }) + await backend.createVesting(grant()) + expect(await backend.balanceOf('funder::1')).toBe('500') + + await backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }) + // The harness settles only the factory choice, so the archive the participant would do is done + // here. + acs[PENDING] = [] + + expect(await backend.balanceOf('funder::1')).toBe('1500') + }) }) describe('LedgerBackend.viewAs', () => { diff --git a/dapp/frontend/src/backend/LedgerBackend.ts b/dapp/frontend/src/backend/LedgerBackend.ts index 970ea46b..6b32e971 100644 --- a/dapp/frontend/src/backend/LedgerBackend.ts +++ b/dapp/frontend/src/backend/LedgerBackend.ts @@ -4,8 +4,10 @@ import { buildAcceptCommand, buildCancelCommand, + buildCancelProposalCommand, buildClaimResidualCommand, buildCreateVestingCommand, + buildRejectProposalCommand, buildTapCommand, buildWithdrawCommand, } from '@/backend/commands' @@ -171,6 +173,21 @@ const recordMisses = (wanted: Set, stored: Set): void => { localStorage.setItem(MISS_STORE_KEY, JSON.stringify({ ...misses, ...bumped })) } +// What a grant leaves in this browser once it can no longer be accepted: the blob its Accept would +// have disclosed, and the count of reads spent looking for that holding. Both are keyed by the +// holding, so both go whichever way the grant ended. +const forgetFunding = (tokenCid: string | undefined): void => { + if (tokenCid === undefined) { + return + } + localStorage.setItem( + TOKEN_STORE_KEY, + JSON.stringify(storedTokens().filter((one) => one.contractId !== tokenCid)), + ) + const kept = Object.entries(readMisses()).filter(([contractId]) => contractId !== tokenCid) + localStorage.setItem(MISS_STORE_KEY, JSON.stringify(Object.fromEntries(kept))) +} + export class LedgerBackend implements VestingBackend { private readonly wallet: WalletFns private readonly factory: DisclosedContract @@ -454,10 +471,41 @@ export class LedgerBackend implements VestingBackend { buildAcceptCommand(this.tid('VestingProposal'), args.pendingCid, configCid), [token], ) - // The submission archived it, so its blob can only mislead a later Accept from here on. - localStorage.setItem( - TOKEN_STORE_KEY, - JSON.stringify(storedTokens().filter((one) => one.contractId !== wanted)), + // The submission archived the proposal, so nothing this browser kept for it can do anything but + // mislead a later Accept. + forgetFunding(wanted) + } + + // Neither exit moves anything, so neither takes the config or discloses a contract: the proposal + // is archived on its controller's own authority and the holding it reserved is already an + // ordinary Token of the funder's, back in their balance as soon as nothing names it. The proposal + // is read first for the holding it names, since reading it after the archive would be too late. + private async endProposal( + party: string, + pendingCid: string, + build: (templateId: string) => LedgerCommand, + ): Promise { + const offset = await this.ledgerEnd() + const rows = await this.readAcs(party, vesting('VestingProposal'), offset) + const proposal = rows.find((row) => cidOf(row) === pendingCid) + if (proposal === undefined) { + throw new Error('this grant is no longer outstanding: reload to see where it went') + } + await this.submit(party, build(this.tid('VestingProposal')), []) + // Only once the submission has landed: a prompt the wallet declines leaves a grant that is + // still outstanding and still acceptable. + forgetFunding(reservedToken(proposal)) + } + + async cancelProposal(args: { proposer: string; pendingCid: string }): Promise { + await this.endProposal(args.proposer, args.pendingCid, (templateId) => + buildCancelProposalCommand(templateId, args.pendingCid), + ) + } + + async rejectProposal(args: { receiver: string; pendingCid: string }): Promise { + await this.endProposal(args.receiver, args.pendingCid, (templateId) => + buildRejectProposalCommand(templateId, args.pendingCid), ) } diff --git a/dapp/frontend/src/backend/VestingBackend.ts b/dapp/frontend/src/backend/VestingBackend.ts index 3ebe2a56..6b832b20 100644 --- a/dapp/frontend/src/backend/VestingBackend.ts +++ b/dapp/frontend/src/backend/VestingBackend.ts @@ -37,9 +37,11 @@ export interface VestingBackend { accept(args: { receiver: string; pendingCid: string }): Promise balanceOf(partyId: string): Promise cancel(args: { creator: string; contractCid: string }): Promise + cancelProposal(args: { proposer: string; pendingCid: string }): Promise claimHistory(partyId: string, contractCid: string): Promise claimResidual(args: { receiver: string; claimCid: string; amount: string }): Promise createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> + rejectProposal(args: { receiver: string; pendingCid: string }): Promise tap(args: { amount: string; party: string }): Promise viewAs(partyId: string): Promise withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise @@ -275,7 +277,7 @@ export const fundedBy = (row: AcsRow, party: string): boolean => argOf(row).prop // can report by how much the funder is short. export const selectHoldings = (rows: AcsRow[], total: string): AcsRow[] | undefined => { // The empty set covers a non-positive total, and a grant submitted with no inputs aborts at - // Accept while `VestingProposal` offers the receiver no way to clear it. + // Accept, leaving the receiver to decline it for nothing. if (compareAmounts(total, '0') <= 0) { return undefined } From 3f67b168ee576acd8696e9a708df2a6718cbdc27 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Wed, 9 Sep 2026 14:05:32 +0000 Subject: [PATCH 3/9] feat(dapp): wire the proposal cancel and decline through the store --- .../src/store/useVestingStore.test.ts | 49 ++++++++++++++++++- dapp/frontend/src/store/useVestingStore.ts | 12 +++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/dapp/frontend/src/store/useVestingStore.test.ts b/dapp/frontend/src/store/useVestingStore.test.ts index 57f6d888..22eab9a0 100644 --- a/dapp/frontend/src/store/useVestingStore.test.ts +++ b/dapp/frontend/src/store/useVestingStore.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { VestingBackend, VestingView } from '@/backend/VestingBackend' -import type { Grant } from '@/store/types' +import type { Grant, PendingGrant } from '@/store/types' import { deriveGrant, grantBacking, grantLineage, useVestingStore } from '@/store/useVestingStore' import { toNumber } from '@/utils/amount' @@ -233,3 +233,50 @@ describe('useVestingStore.withdraw', () => { expect(next).toBe('g2') }) }) + +describe('useVestingStore.cancelProposal and rejectProposal', () => { + const pending = (): PendingGrant => ({ + id: 'p1', + title: 'Advisor grant', + provider: 'p::1', + proposer: 'c::1', + receiver: 'r::1', + totalAmount: '1000', + schedule: { + cliff: '2025-06-01T00:00:00Z', + curve: { kind: 'linear', start: '2025-01-01T00:00:00Z', end: '2026-01-01T00:00:00Z' }, + }, + }) + + // The ledger archived the proposal, so the view read after the write no longer carries it. + const endedBackend = (calls: string[]): VestingBackend => + ({ + viewAs: async () => ({ grants: [], pendingGrants: [], claims: [] }), + cancelProposal: async (args: { proposer: string; pendingCid: string }) => { + calls.push(`cancel ${args.proposer} ${args.pendingCid}`) + }, + rejectProposal: async (args: { receiver: string; pendingCid: string }) => { + calls.push(`reject ${args.receiver} ${args.pendingCid}`) + }, + }) as unknown as VestingBackend + + it('cancels as the funder and re-reads the view', async () => { + const calls: string[] = [] + useVestingStore.setState({ pendingGrants: [pending()] }) + + await useVestingStore.getState().cancelProposal(endedBackend(calls), 'c::1', 'p1') + + expect(calls).toEqual(['cancel c::1 p1']) + expect(useVestingStore.getState().pendingGrants).toEqual([]) + }) + + it('rejects as the receiver and re-reads the view', async () => { + const calls: string[] = [] + useVestingStore.setState({ pendingGrants: [pending()] }) + + await useVestingStore.getState().rejectProposal(endedBackend(calls), 'r::1', 'p1') + + expect(calls).toEqual(['reject r::1 p1']) + expect(useVestingStore.getState().pendingGrants).toEqual([]) + }) +}) diff --git a/dapp/frontend/src/store/useVestingStore.ts b/dapp/frontend/src/store/useVestingStore.ts index 0d789511..868e68fd 100644 --- a/dapp/frontend/src/store/useVestingStore.ts +++ b/dapp/frontend/src/store/useVestingStore.ts @@ -123,6 +123,7 @@ interface VestingState { accept: (backend: VestingBackend, partyId: string, pendingCid: string) => Promise cancel: (backend: VestingBackend, partyId: string, contractCid: string) => Promise + cancelProposal: (backend: VestingBackend, partyId: string, pendingCid: string) => Promise claimResidual: ( backend: VestingBackend, partyId: string, @@ -136,6 +137,7 @@ interface VestingState { input: CreateVestInput, ) => Promise<{ disclosedBytes: number }> refresh: (backend: VestingBackend, partyId: string) => Promise + rejectProposal: (backend: VestingBackend, partyId: string, pendingCid: string) => Promise withdraw: ( backend: VestingBackend, partyId: string, @@ -194,6 +196,11 @@ export const useVestingStore = create((set, get) => ({ await get().refresh(backend, partyId) }, + cancelProposal: async (backend, partyId, pendingCid) => { + await backend.cancelProposal({ proposer: partyId, pendingCid }) + await get().refresh(backend, partyId) + }, + // Returns the successor's contract id, since the claim replaced the one the caller passed. withdraw: async (backend, partyId, contractCid, amount) => { const successor = trackSuccessor(get().grants, contractCid, grantLineage) @@ -215,6 +222,11 @@ export const useVestingStore = create((set, get) => ({ await get().refresh(backend, partyId) return successor(get().claims) }, + + rejectProposal: async (backend, partyId, pendingCid) => { + await backend.rejectProposal({ receiver: partyId, pendingCid }) + await get().refresh(backend, partyId) + }, })) // Wires the store to the context backend + acting party and re-reads the ACS whenever either From f06c2e0a237eea944f8f20625511b0e6b9184319 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Wed, 9 Sep 2026 14:10:07 +0000 Subject: [PATCH 4/9] feat(dapp): offer the funder a cancel and the receiver a decline on a pending grant --- .../pages/PendingGrants/EndPendingGrant.tsx | 72 +++++++++++++++++++ .../pages/PendingGrants/PendingGrantCard.tsx | 17 +++-- .../src/pages/PendingGrants/index.tsx | 20 +++++- 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 dapp/frontend/src/pages/PendingGrants/EndPendingGrant.tsx diff --git a/dapp/frontend/src/pages/PendingGrants/EndPendingGrant.tsx b/dapp/frontend/src/pages/PendingGrants/EndPendingGrant.tsx new file mode 100644 index 00000000..2d164296 --- /dev/null +++ b/dapp/frontend/src/pages/PendingGrants/EndPendingGrant.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { AmountDisplay } from '@/components/AmountDisplay' +import { Button } from '@/components/Button' +import { Modal } from '@/components/Modal' +import type { PendingGrant, Role } from '@/store/types' +import { errorText } from '@/utils/errorText' +import { toast } from '@/utils/toast' + +interface EndPendingGrantProps { + onClose: () => void + onConfirm: () => Promise + pendingGrant: PendingGrant + role: Role +} + +// The two ways an outstanding grant ends without being accepted, in one component: the ledger +// choices differ only in who exercises them, and neither moves anything. Like CancelGrant it owns +// the submit, toast, error and submitting lifecycle so the page does not. +export const EndPendingGrant = ({ + onClose, + onConfirm, + pendingGrant, + role, +}: EndPendingGrantProps): React.JSX.Element => { + const [submitting, setSubmitting] = useState(false) + const funder = role === 'funder' + + const submit = async (): Promise => { + setSubmitting(true) + try { + await onConfirm() + toast.success(funder ? 'Grant cancelled' : 'Grant declined') + onClose() + } catch (err) { + toast.error(errorText(err)) + } finally { + setSubmitting(false) + } + } + + return ( + +
+
+
+ + {funder ? 'Back to your balance' : 'Stays with the funder'} + + +
+
+ +
+
+ ) +} diff --git a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx b/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx index ae397ae8..3159c353 100644 --- a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx +++ b/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx @@ -16,6 +16,7 @@ interface PendingGrantCardProps { direction: 'incoming' | 'outgoing' nowMs: number onAccept: (pendingGrant: PendingGrant) => void + onEnd: (pendingGrant: PendingGrant) => void pendingGrant: PendingGrant } @@ -24,6 +25,7 @@ export const PendingGrantCard = ({ direction, nowMs, onAccept, + onEnd, }: PendingGrantCardProps): React.JSX.Element => { const curve = pendingGrant.schedule.curve const milestones = curve.kind === 'milestone' ? curve.points.map((p) => p.fraction) : undefined @@ -71,11 +73,18 @@ export const PendingGrantCard = ({ /> {direction === 'incoming' ? ( - +
+ + +
) : ( - awaiting acceptance + )} diff --git a/dapp/frontend/src/pages/PendingGrants/index.tsx b/dapp/frontend/src/pages/PendingGrants/index.tsx index 0eaabaa4..63dbbff6 100644 --- a/dapp/frontend/src/pages/PendingGrants/index.tsx +++ b/dapp/frontend/src/pages/PendingGrants/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { Button } from '@/components/Button' import { ConnectPrompt } from '@/components/ConnectPrompt' import { EmptyState } from '@/components/EmptyState' @@ -8,6 +8,7 @@ import { RoleSelect } from '@/components/RoleSelect' import { useCreateGrant } from '@/hooks/useCreateGrant' import { useDocumentTitle } from '@/hooks/useDocumentTitle' import { useRoleLens } from '@/hooks/useRoleLens' +import { EndPendingGrant } from '@/pages/PendingGrants/EndPendingGrant' import { PendingGrantCard } from '@/pages/PendingGrants/PendingGrantCard' import type { PendingGrant } from '@/store/types' import { useVesting, useVestingStore } from '@/store/useVestingStore' @@ -24,6 +25,9 @@ export const PendingGrants = (): React.JSX.Element => { const pendingGrants = useVestingStore((s) => s.pendingGrants) const loading = useVestingStore((s) => s.loading) const accept = useVestingStore((s) => s.accept) + const [ending, setEnding] = useState(undefined) + const cancelProposal = useVestingStore((s) => s.cancelProposal) + const rejectProposal = useVestingStore((s) => s.rejectProposal) const direction = role === 'receiver' ? 'incoming' : 'outgoing' const visible = useMemo( @@ -74,10 +78,24 @@ export const PendingGrants = (): React.JSX.Element => { direction={direction} nowMs={nowMs} onAccept={(p) => void onAccept(p)} + onEnd={(p) => setEnding(p)} /> ))} )} + + {ending !== undefined && ( + setEnding(undefined)} + pendingGrant={ending} + role={role} + onConfirm={() => + role === 'funder' + ? cancelProposal(backend, partyId, ending.id) + : rejectProposal(backend, partyId, ending.id) + } + /> + )} ) } From b5dc7c70d89e846358898dd98a30aebd5cfaf6b7 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Wed, 9 Sep 2026 14:14:50 +0000 Subject: [PATCH 5/9] docs(dapp): say that an unaccepted grant can now be ended from either side --- dapp/frontend/README.md | 8 ++++---- dapp/frontend/architecture.md | 11 +++++++++++ dapp/frontend/src/components/CreateGrant/index.tsx | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md index 8a971fd9..17d52bba 100644 --- a/dapp/frontend/README.md +++ b/dapp/frontend/README.md @@ -1,9 +1,9 @@ # @canton-dappbooster/frontend: vesting dApp -dApp for **vesting a canton-token-forge instrument**: propose a grant, the receiver accepts, claim -as it vests, or cancel into a residual claim. Accepting locks the funder's `DBT` in a `LockedToken` -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 +dApp for **vesting a canton-token-forge instrument**: propose a grant, the receiver accepts or +declines it, claim as it vests, or cancel into a residual claim. Accepting locks the funder's `DBT` +in a `LockedToken` 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). Every read and every write goes through the connected CIP-0103 wallet, so the app acts as diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index 8f814ba1..8a86ad1c 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -138,6 +138,17 @@ submitting a rejection. A grant that has left the receiver's view says something stale dashboard and a missing blob are different problems and pointing the first at the blob store sends the reader to a browser that was never involved. +Accept is no longer the only exit. `VestingProposal_Cancel` and `VestingProposal_Reject` are +bodyless and move no holding, so the funder's cancel and the receiver's decline take neither the +config nor a disclosure: a consuming choice archives on its controller's own authority. Nothing has +to hand the reserved holding back either, since it is an ordinary unlocked `Token` throughout and +`freeTokens` subtracts only what an outstanding proposal names. Both go through one `endProposal`, +which reads the proposal before the write because the blob this browser kept is keyed by the holding +that proposal names, and forgets that blob and its read-miss count after the write, never before: a +prompt the wallet declines leaves a grant that is still acceptable. A grant ended from another +browser still leaves its blob behind, since a stored blob records no owner and one party's view +cannot safely prune another's. + The escrow needs no such hand-off. A `LockedToken` is `signatory admin, owner, holders` and the escrow's holders are the provider and the receiver, so both ends of a grant can read it. Only the config is disclosed on withdraw, cancel and residual claim. diff --git a/dapp/frontend/src/components/CreateGrant/index.tsx b/dapp/frontend/src/components/CreateGrant/index.tsx index ff6e3e37..dd364640 100644 --- a/dapp/frontend/src/components/CreateGrant/index.tsx +++ b/dapp/frontend/src/components/CreateGrant/index.tsx @@ -334,7 +334,7 @@ export const CreateGrant = ({ onClose }: { onClose: () => void }): React.JSX.Ele />

An outstanding grant reserves exactly this amount; the rest of your balance stays - spendable until the receiver accepts. + spendable. Cancel the grant from Pending to release it before the receiver accepts.

From d10451904769a534cb59c00c510169dc94dd5bf5 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Wed, 9 Sep 2026 14:28:12 +0000 Subject: [PATCH 6/9] fix(dapp): pin the ending grant's role and give each pending-grant button its own name --- dapp/frontend/README.md | 7 ++++--- dapp/frontend/src/backend/LedgerBackend.ts | 19 +++++++++--------- .../pages/PendingGrants/PendingGrantCard.tsx | 20 ++++++++++++++++--- .../src/pages/PendingGrants/index.tsx | 20 +++++++++++-------- dapp/frontend/src/store/useVestingStore.ts | 10 +++++----- 5 files changed, 47 insertions(+), 29 deletions(-) diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md index 17d52bba..f6161d0d 100644 --- a/dapp/frontend/README.md +++ b/dapp/frontend/README.md @@ -1,9 +1,10 @@ # @canton-dappbooster/frontend: vesting dApp dApp for **vesting a canton-token-forge instrument**: propose a grant, the receiver accepts or -declines it, claim as it vests, or cancel into a residual claim. Accepting locks the funder's `DBT` -in a `LockedToken` 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 +declines it and the funder can withdraw it until they do, claim as it vests, or cancel into a +residual claim. Accepting locks the funder's `DBT` in a `LockedToken` 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). Every read and every write goes through the connected CIP-0103 wallet, so the app acts as diff --git a/dapp/frontend/src/backend/LedgerBackend.ts b/dapp/frontend/src/backend/LedgerBackend.ts index 6b32e971..4c582178 100644 --- a/dapp/frontend/src/backend/LedgerBackend.ts +++ b/dapp/frontend/src/backend/LedgerBackend.ts @@ -86,6 +86,9 @@ const TOKEN_STORE_KEY = 'vesting.tokenDisclosures' const MISS_STORE_KEY = 'vesting.tokenReadMisses' const MISS_LIMIT = 3 +// Shared by every exit that finds its proposal already gone, so the wording cannot drift between them. +const PROPOSAL_GONE_MESSAGE = 'this grant is no longer outstanding: reload to see where it went' + const storedTokens = (): DisclosedContract[] => { try { const stored = JSON.parse(localStorage.getItem(TOKEN_STORE_KEY) ?? '[]') @@ -458,7 +461,7 @@ export class LedgerBackend implements VestingBackend { // Two different failures, and pointing a stale view at the blob store would send the receiver // hunting for a browser that never had anything to do with it. if (proposal === undefined) { - throw new Error('this grant is no longer outstanding: reload to see where it went') + throw new Error(PROPOSAL_GONE_MESSAGE) } const wanted = reservedToken(proposal) const token = storedTokens().find((one) => one.contractId === wanted) @@ -483,30 +486,26 @@ export class LedgerBackend implements VestingBackend { private async endProposal( party: string, pendingCid: string, - build: (templateId: string) => LedgerCommand, + build: (templateId: string, pendingCid: string) => LedgerCommand, ): Promise { const offset = await this.ledgerEnd() const rows = await this.readAcs(party, vesting('VestingProposal'), offset) const proposal = rows.find((row) => cidOf(row) === pendingCid) if (proposal === undefined) { - throw new Error('this grant is no longer outstanding: reload to see where it went') + throw new Error(PROPOSAL_GONE_MESSAGE) } - await this.submit(party, build(this.tid('VestingProposal')), []) + await this.submit(party, build(this.tid('VestingProposal'), pendingCid), []) // Only once the submission has landed: a prompt the wallet declines leaves a grant that is // still outstanding and still acceptable. forgetFunding(reservedToken(proposal)) } async cancelProposal(args: { proposer: string; pendingCid: string }): Promise { - await this.endProposal(args.proposer, args.pendingCid, (templateId) => - buildCancelProposalCommand(templateId, args.pendingCid), - ) + await this.endProposal(args.proposer, args.pendingCid, buildCancelProposalCommand) } async rejectProposal(args: { receiver: string; pendingCid: string }): Promise { - await this.endProposal(args.receiver, args.pendingCid, (templateId) => - buildRejectProposalCommand(templateId, args.pendingCid), - ) + await this.endProposal(args.receiver, args.pendingCid, buildRejectProposalCommand) } async withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise { diff --git a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx b/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx index 3159c353..24ca7b6d 100644 --- a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx +++ b/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx @@ -74,15 +74,29 @@ export const PendingGrantCard = ({ {direction === 'incoming' ? (
- -
) : ( - )} diff --git a/dapp/frontend/src/pages/PendingGrants/index.tsx b/dapp/frontend/src/pages/PendingGrants/index.tsx index 63dbbff6..bdb8957a 100644 --- a/dapp/frontend/src/pages/PendingGrants/index.tsx +++ b/dapp/frontend/src/pages/PendingGrants/index.tsx @@ -10,7 +10,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle' import { useRoleLens } from '@/hooks/useRoleLens' import { EndPendingGrant } from '@/pages/PendingGrants/EndPendingGrant' import { PendingGrantCard } from '@/pages/PendingGrants/PendingGrantCard' -import type { PendingGrant } from '@/store/types' +import type { PendingGrant, Role } from '@/store/types' import { useVesting, useVestingStore } from '@/store/useVestingStore' import { useNow } from '@/utils/clock' import { errorText } from '@/utils/errorText' @@ -25,7 +25,11 @@ export const PendingGrants = (): React.JSX.Element => { const pendingGrants = useVestingStore((s) => s.pendingGrants) const loading = useVestingStore((s) => s.loading) const accept = useVestingStore((s) => s.accept) - const [ending, setEnding] = useState(undefined) + // Captured at open, not read live off `role`: a URL search param can flip under an open dialog + // (browser Back/Forward), and this is what keeps the dialog's title and its write pinned together. + const [ending, setEnding] = useState<{ pendingGrant: PendingGrant; role: Role } | undefined>( + undefined, + ) const cancelProposal = useVestingStore((s) => s.cancelProposal) const rejectProposal = useVestingStore((s) => s.rejectProposal) @@ -78,7 +82,7 @@ export const PendingGrants = (): React.JSX.Element => { direction={direction} nowMs={nowMs} onAccept={(p) => void onAccept(p)} - onEnd={(p) => setEnding(p)} + onEnd={(p) => setEnding({ pendingGrant: p, role })} /> ))} @@ -87,12 +91,12 @@ export const PendingGrants = (): React.JSX.Element => { {ending !== undefined && ( setEnding(undefined)} - pendingGrant={ending} - role={role} + pendingGrant={ending.pendingGrant} + role={ending.role} onConfirm={() => - role === 'funder' - ? cancelProposal(backend, partyId, ending.id) - : rejectProposal(backend, partyId, ending.id) + ending.role === 'funder' + ? cancelProposal(backend, partyId, ending.pendingGrant.id) + : rejectProposal(backend, partyId, ending.pendingGrant.id) } /> )} diff --git a/dapp/frontend/src/store/useVestingStore.ts b/dapp/frontend/src/store/useVestingStore.ts index 868e68fd..7072678c 100644 --- a/dapp/frontend/src/store/useVestingStore.ts +++ b/dapp/frontend/src/store/useVestingStore.ts @@ -201,6 +201,11 @@ export const useVestingStore = create((set, get) => ({ await get().refresh(backend, partyId) }, + rejectProposal: async (backend, partyId, pendingCid) => { + await backend.rejectProposal({ receiver: partyId, pendingCid }) + await get().refresh(backend, partyId) + }, + // Returns the successor's contract id, since the claim replaced the one the caller passed. withdraw: async (backend, partyId, contractCid, amount) => { const successor = trackSuccessor(get().grants, contractCid, grantLineage) @@ -222,11 +227,6 @@ export const useVestingStore = create((set, get) => ({ await get().refresh(backend, partyId) return successor(get().claims) }, - - rejectProposal: async (backend, partyId, pendingCid) => { - await backend.rejectProposal({ receiver: partyId, pendingCid }) - await get().refresh(backend, partyId) - }, })) // Wires the store to the context backend + acting party and re-reads the ACS whenever either From 75fec7eed7f759c2fe8359728054a024016be138 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Wed, 9 Sep 2026 15:10:15 +0000 Subject: [PATCH 7/9] fix(dapp): survive a browser that cannot record an ended grant, and pin the party it submits as --- dapp/frontend/README.md | 4 ++-- .../src/backend/LedgerBackend.test.ts | 20 ++++++++++++++++++ dapp/frontend/src/backend/LedgerBackend.ts | 21 ++++++++++++------- .../pages/PendingGrants/PendingGrantCard.tsx | 2 ++ .../src/pages/PendingGrants/index.tsx | 17 ++++++++------- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md index f6161d0d..63e648e3 100644 --- a/dapp/frontend/README.md +++ b/dapp/frontend/README.md @@ -1,8 +1,8 @@ # @canton-dappbooster/frontend: vesting dApp dApp for **vesting a canton-token-forge instrument**: propose a grant, the receiver accepts or -declines it and the funder can withdraw it until they do, claim as it vests, or cancel into a -residual claim. Accepting locks the funder's `DBT` in a `LockedToken` escrow and each claim +declines it and the funder can cancel it until they do, claim as it vests, or cancel an accepted +grant into a residual claim. Accepting locks the funder's `DBT` in a `LockedToken` 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). diff --git a/dapp/frontend/src/backend/LedgerBackend.test.ts b/dapp/frontend/src/backend/LedgerBackend.test.ts index 8ecceca4..99fae01e 100644 --- a/dapp/frontend/src/backend/LedgerBackend.test.ts +++ b/dapp/frontend/src/backend/LedgerBackend.test.ts @@ -739,6 +739,26 @@ describe('LedgerBackend.cancelProposal and rejectProposal', () => { expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t1']) }) + // The other way round: the exit is already on the ledger, so a browser that refuses to write must + // not report a failure the funder would read as the grant surviving. + it('reports a cancel this browser cannot record as done', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const { backend } = harness({ acs }) + await backend.createVesting(grant()) + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('storage is blocked', 'SecurityError') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect( + backend.cancelProposal({ proposer: 'funder::1', pendingCid: 'pending-for-t1' }), + ).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalled() + setItem.mockRestore() + warn.mockRestore() + }) + // Criterion 3, and it costs no ledger work: the holding is excluded only while a proposal names // it, so archiving the proposal is what returns it. it('returns the reserved holding to the funder’s balance', async () => { diff --git a/dapp/frontend/src/backend/LedgerBackend.ts b/dapp/frontend/src/backend/LedgerBackend.ts index 4c582178..678ccdd5 100644 --- a/dapp/frontend/src/backend/LedgerBackend.ts +++ b/dapp/frontend/src/backend/LedgerBackend.ts @@ -178,17 +178,24 @@ const recordMisses = (wanted: Set, stored: Set): void => { // What a grant leaves in this browser once it can no longer be accepted: the blob its Accept would // have disclosed, and the count of reads spent looking for that holding. Both are keyed by the -// holding, so both go whichever way the grant ended. +// holding, so both go whichever way the grant ended. Every caller runs after its submission has +// landed, so a browser refusing to write must not turn a committed exit into a reported failure: +// what is left behind names a proposal that no longer exists and can only mislead a later Accept, +// which already says so on its own. const forgetFunding = (tokenCid: string | undefined): void => { if (tokenCid === undefined) { return } - localStorage.setItem( - TOKEN_STORE_KEY, - JSON.stringify(storedTokens().filter((one) => one.contractId !== tokenCid)), - ) - const kept = Object.entries(readMisses()).filter(([contractId]) => contractId !== tokenCid) - localStorage.setItem(MISS_STORE_KEY, JSON.stringify(Object.fromEntries(kept))) + try { + localStorage.setItem( + TOKEN_STORE_KEY, + JSON.stringify(storedTokens().filter((one) => one.contractId !== tokenCid)), + ) + const kept = Object.entries(readMisses()).filter(([contractId]) => contractId !== tokenCid) + localStorage.setItem(MISS_STORE_KEY, JSON.stringify(Object.fromEntries(kept))) + } catch (cause: unknown) { + console.warn('could not forget what this browser kept for a grant that has ended', cause) + } } export class LedgerBackend implements VestingBackend { diff --git a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx b/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx index 24ca7b6d..e992777b 100644 --- a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx +++ b/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx @@ -77,6 +77,7 @@ export const PendingGrantCard = ({