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
9 changes: 5 additions & 4 deletions dapp/frontend/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions dapp/frontend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
174 changes: 174 additions & 0 deletions dapp/frontend/src/backend/LedgerBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, number> =>
JSON.parse(localStorage.getItem('vesting.tokenReadMisses') ?? '{}')

const CONFIG = {
templateId: '20d54824:Canton.TokenForge.Registry:InstrumentConfig',
contractId: '00cfg',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<string, unknown[]> = { [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<string, unknown[]> = { [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<string, unknown[]> = {
[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<string, unknown[]> = { [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<string, unknown[]> = { [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<string, unknown[]> = { [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', () => {
Expand Down
76 changes: 68 additions & 8 deletions dapp/frontend/src/backend/LedgerBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import {
buildAcceptCommand,
buildCancelCommand,
buildCancelProposalCommand,
buildClaimResidualCommand,
buildCreateVestingCommand,
buildRejectProposalCommand,
buildTapCommand,
buildWithdrawCommand,
} from '@/backend/commands'
Expand Down Expand Up @@ -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) ?? '[]')
Expand Down Expand Up @@ -171,6 +176,28 @@ const recordMisses = (wanted: Set<string>, stored: Set<string>): 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
Expand Down Expand Up @@ -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,
Expand All @@ -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 })),
})
Expand Down Expand Up @@ -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)
Expand All @@ -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<unknown> {
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<void> {
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<void> {
await this.endProposal(args.receiver, args.pendingCid, buildRejectProposalCommand)
}

async withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise<void> {
Expand Down
4 changes: 3 additions & 1 deletion dapp/frontend/src/backend/VestingBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ export interface VestingBackend {
accept(args: { receiver: string; pendingCid: string }): Promise<void>
balanceOf(partyId: string): Promise<string>
cancel(args: { creator: string; contractCid: string }): Promise<void>
cancelProposal(args: { proposer: string; pendingCid: 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 }>
rejectProposal(args: { receiver: string; pendingCid: string }): Promise<void>
tap(args: { amount: string; party: string }): Promise<void>
viewAs(partyId: string): Promise<VestingView>
withdraw(args: { receiver: string; contractCid: string; amount: string }): Promise<void>
Expand Down Expand Up @@ -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
}
Expand Down
Loading