diff --git a/dapp/frontend/README.md b/dapp/frontend/README.md index 8a971fd9..63e648e3 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, 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 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). 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/backend/LedgerBackend.test.ts b/dapp/frontend/src/backend/LedgerBackend.test.ts index a44c0675..2afb7e76 100644 --- a/dapp/frontend/src/backend/LedgerBackend.test.ts +++ b/dapp/frontend/src/backend/LedgerBackend.test.ts @@ -28,6 +28,7 @@ type Submission = { actAs?: string[] commands?: LedgerCommand[] disclosedContracts?: DisclosedContract[] + synchronizerId?: string } // As much of the ACS query LedgerBackend builds as these tests read back, named once so the two @@ -106,6 +107,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', @@ -364,6 +370,7 @@ describe('LedgerBackend.createVesting', () => { await backend.createVesting(grant) expect(submissions[0]?.disclosedContracts?.[0]).not.toHaveProperty('synchronizerId') + expect(submissions[0]).not.toHaveProperty('synchronizerId') }) // The acceptance criterion this issue exists for: the funder keeps everything the grant did not @@ -484,6 +491,11 @@ describe('LedgerBackend submissions', () => { stamped, stamped, ]) + expect(submissions.map((submission) => submission.synchronizerId)).toEqual([ + 'sync::1', + 'sync::1', + 'sync::1', + ]) }) it('names the template and choice each write exercises', async () => { @@ -622,6 +634,168 @@ 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([]) + // The one write that discloses nothing, so the only one where a synchronizer stamped on the + // disclosures alone would leave the wallet to pick its own. + expect(submission?.synchronizerId).toBe('sync::1') + }) + + 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([]) + }) + + // Blobs are stored only for grants this browser funded, so the read the cancel needs to prune one + // buys the receiver nothing - and readAcs answers [] on a soft failure, which would have read as + // gone and blocked a decline of a grant that is perfectly live. + it('declines without reading the proposal first', async () => { + const { backend, submissions, reads } = harness() + + await backend.rejectProposal({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Reject') + expect(reads.some((read) => read.resource === '/v2/state/active-contracts')).toBe(false) + }) + + 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']) + }) + + // 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 () => { + 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..1f2607b7 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' @@ -84,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) ?? '[]') @@ -171,6 +176,28 @@ 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. 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 + } + 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 { private readonly wallet: WalletFns private readonly factory: DisclosedContract @@ -226,8 +253,9 @@ export class LedgerBackend implements VestingBackend { // actAs is explicit rather than left to the wallet's primary account, so a submission that would // be signed by the wrong key is rejected by the participant instead of silently reassigned. The - // synchronizer is a property of the submission, so it is stamped here and nowhere the disclosures - // are built. + // synchronizer is stamped here and nowhere the disclosures are built, and on the submission as + // well as on each disclosure: a write that discloses nothing would otherwise reach a wallet on + // more than one synchronizer with none named, and land on its default. private submit( actAs: string, command: LedgerCommand, @@ -238,6 +266,7 @@ export class LedgerBackend implements VestingBackend { actAs: [actAs], readAs: [actAs], commands: [command], + ...(sync === undefined ? {} : { synchronizerId: sync }), disclosedContracts: sync === undefined ? disclosed : disclosed.map((one) => ({ ...one, synchronizerId: sync })), }) @@ -441,7 +470,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) @@ -454,11 +483,42 @@ 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. + private endProposal( + party: string, + pendingCid: string, + build: (templateId: string, pendingCid: string) => LedgerCommand, + ): Promise { + return this.submit(party, build(this.tid('VestingProposal'), pendingCid), []) + } + + // Read before the archive for the holding the proposal names, since reading it after would be too + // late. + async cancelProposal(args: { proposer: string; pendingCid: string }): Promise { + const offset = await this.ledgerEnd() + const rows = await this.readAcs(args.proposer, vesting('VestingProposal'), offset) + const proposal = rows.find((row) => cidOf(row) === args.pendingCid) + if (proposal === undefined) { + throw new Error(PROPOSAL_GONE_MESSAGE) + } + await this.endProposal(args.proposer, args.pendingCid, buildCancelProposalCommand) + // Only once the submission has landed: a prompt the wallet declines leaves a grant that is + // still outstanding and still acceptable. + forgetFunding(reservedToken(proposal)) + } + + // No read of its own, unlike the cancel above: blobs are stored only for grants this browser + // funded, so the receiver has nothing to forget, and readAcs answering short would have blocked a + // live decline for a prune that was never going to run. + async rejectProposal(args: { receiver: string; pendingCid: string }): Promise { + await this.endProposal(args.receiver, args.pendingCid, buildRejectProposalCommand) } async withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise { 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 } 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, diff --git a/dapp/frontend/src/components/CancelGrant.tsx b/dapp/frontend/src/components/CancelGrant.tsx index 23ec964c..58302471 100644 --- a/dapp/frontend/src/components/CancelGrant.tsx +++ b/dapp/frontend/src/components/CancelGrant.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { AmountDisplay } from '@/components/AmountDisplay' import { Button } from '@/components/Button' import { FieldError } from '@/components/FieldError' @@ -33,13 +33,27 @@ export const CancelGrant = ({ // Recomputed each tick with `nowMs`, so a residual growing past the floor re-enables the button // on its own rather than sending a submission the contract will assert on. const floorOk = residualMeetsFloor(derived.claimable) + // Only the confirm button is disabled while a submission is in flight, so the dialog can still be + // dismissed over the wallet prompt. Closing then would close whichever dialog has since taken its + // place; the toast still fires, because the cancel did land. + const onScreen = useRef(true) + // Set on mount and not only cleared on unmount, because StrictMode runs setup, cleanup, setup: a + // ref the cleanup alone touches reads false from the first render onwards. + useEffect(() => { + onScreen.current = true + return () => { + onScreen.current = false + } + }, []) const submit = async (): Promise => { setSubmitting(true) try { await onConfirm() toast.success(successMessage) - onClose() + if (onScreen.current) { + onClose() + } } catch (err) { toast.error(errorText(err)) } finally { 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.

diff --git a/dapp/frontend/src/pages/PendingGrants/EndPendingGrant.tsx b/dapp/frontend/src/pages/PendingGrants/EndPendingGrant.tsx new file mode 100644 index 00000000..af028afd --- /dev/null +++ b/dapp/frontend/src/pages/PendingGrants/EndPendingGrant.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, 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' + // Only the confirm button is disabled while a submission is in flight, so the dialog can still be + // dismissed over the wallet prompt. Closing then would close whichever dialog has since taken its + // place; the toast still fires, because the choice did land. + const onScreen = useRef(true) + // Set on mount and not only cleared on unmount, because StrictMode runs setup, cleanup, setup: a + // ref the cleanup alone touches reads false from the first render onwards. + useEffect(() => { + onScreen.current = true + return () => { + onScreen.current = false + } + }, []) + + const submit = async (): Promise => { + setSubmitting(true) + try { + await onConfirm() + toast.success(funder ? 'Grant cancelled' : 'Grant declined') + if (onScreen.current) { + 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..9652bbfd 100644 --- a/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx +++ b/dapp/frontend/src/pages/PendingGrants/PendingGrantCard.tsx @@ -11,19 +11,24 @@ import { formatDate, relativeTime } from '@/utils/format' import { vestedFraction } from '@/utils/schedule' // `direction` incoming means the acting party is the receiver and can accept; outgoing was sent as -// funder. +// funder. `ending` is a cancel or decline of this grant already in flight, which the card owner +// knows about and the dismissed dialog no longer does. interface PendingGrantCardProps { direction: 'incoming' | 'outgoing' + ending: boolean nowMs: number onAccept: (pendingGrant: PendingGrant) => void + onEnd: (pendingGrant: PendingGrant) => void pendingGrant: PendingGrant } export const PendingGrantCard = ({ pendingGrant, direction, + ending, 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 +76,37 @@ 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..9979d15f 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 { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/Button' import { ConnectPrompt } from '@/components/ConnectPrompt' import { EmptyState } from '@/components/EmptyState' @@ -8,13 +8,20 @@ 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 type { PendingGrant, Role } from '@/store/types' import { useVesting, useVestingStore } from '@/store/useVestingStore' import { useNow } from '@/utils/clock' import { errorText } from '@/utils/errorText' import { toast } from '@/utils/toast' +interface Ending { + partyId: string + pendingGrant: PendingGrant + role: Role +} + export const PendingGrants = (): React.JSX.Element => { useDocumentTitle('Pending Grants') const nowMs = useNow() @@ -24,6 +31,22 @@ export const PendingGrants = (): React.JSX.Element => { const pendingGrants = useVestingStore((s) => s.pendingGrants) const loading = useVestingStore((s) => s.loading) const accept = useVestingStore((s) => s.accept) + // Captured at open, not read live: the role comes from a URL search param and the party from the + // wallet session, so either can flip under an open dialog (browser Back/Forward, an account + // change). This is what keeps the dialog's title and its write pinned to the grant it opened on. + const [ending, setEnding] = useState(undefined) + // The dialog can be dismissed over the wallet prompt, which unmounts it with its submission still + // in flight and nothing yet refreshed, so what is already being ended is tracked by the page the + // card belongs to rather than by the dialog that may be gone. + const [endingCids, setEndingCids] = useState>(new Set()) + const cancelProposal = useVestingStore((s) => s.cancelProposal) + const rejectProposal = useVestingStore((s) => s.rejectProposal) + + // Pinning the party is only half of it: the grant belongs to the account that opened the dialog, + // so an account switch under it would submit as a party the wallet no longer holds. Close it. + useEffect(() => { + setEnding((current) => (current?.partyId === partyId ? current : undefined)) + }, [partyId]) const direction = role === 'receiver' ? 'incoming' : 'outgoing' const visible = useMemo( @@ -48,6 +71,22 @@ export const PendingGrants = (): React.JSX.Element => { } } + const endGrant = async (target: Ending): Promise => { + const pendingCid = target.pendingGrant.id + setEndingCids((current) => new Set(current).add(pendingCid)) + try { + await (target.role === 'funder' + ? cancelProposal(backend, target.partyId, pendingCid) + : rejectProposal(backend, target.partyId, pendingCid)) + } finally { + setEndingCids((current) => { + const next = new Set(current) + next.delete(pendingCid) + return next + }) + } + } + return (
} /> @@ -74,10 +113,21 @@ export const PendingGrants = (): React.JSX.Element => { direction={direction} nowMs={nowMs} onAccept={(p) => void onAccept(p)} + onEnd={(p) => setEnding({ pendingGrant: p, role, partyId })} + ending={endingCids.has(pendingGrant.id)} /> ))}
)} + + {ending !== undefined && ( + setEnding(undefined)} + pendingGrant={ending.pendingGrant} + role={ending.role} + onConfirm={() => endGrant(ending)} + /> + )} ) } 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..7072678c 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,16 @@ 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) + }, + + 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)