diff --git a/dapp/frontend/architecture.md b/dapp/frontend/architecture.md index 30de334d..8f814ba1 100644 --- a/dapp/frontend/architecture.md +++ b/dapp/frontend/architecture.md @@ -88,47 +88,64 @@ scoped away from `/api/` so it cannot answer the route with `index.html`. ## Creating a grant takes one approval -A pending grant records the contract ids of the holdings its Accept will lock, and that Accept -consumes exactly those. So two grants may never name the same holding: accepting one archives it and -leaves the other permanently unacceptable, `CONTRACT_NOT_FOUND` at the `fetch` before the transfer -even runs. Whatever an outstanding grant already pledged is therefore kept out of the next -selection, read off the funder's own pending-grant rows rather than remembered locally. - -What it does not need is a pre-split. `executeTokenTransfer` returns the sender's leftover input as -change, so `VestingProposal_Accept` can be handed a set of holdings that over-covers the grant and -splits them itself. The Amulet version could not: `AmuletRules_Transfer` consumes everything it is -given, which is why creating a grant used to be two submissions and two wallet prompts. - -`createVesting` picks unpledged holdings largest first until they cover the total. Largest rather -than smallest, so a grant names the fewest inputs: every named holding is a disclosure blob the -receiver has to carry to Accept. +`VestingFactory_CreateVesting` splits the funder's inputs itself: it transfers them into one +unlocked holding of exactly the grant, hands the change straight back, and the proposal names that +holding alone. So a grant reserves its own amount and nothing more, and the rest of the funder's +balance stays spendable while the proposal is outstanding. That split is why the choice takes the +`InstrumentConfig`, and it is still one submission and one wallet approval, because an unlocked +self-transfer's controllers are the sender alone. The Amulet version could not do this in one: +`AmuletRules_Transfer` consumes everything it is given, which is why creating a grant used to be two +submissions and two wallet prompts. + +The reserved holding is still kept out of the next selection, read off the funder's own +pending-grant rows rather than remembered locally: an Accept consumes exactly that contract, so +spending it would leave the grant permanently unacceptable, `CONTRACT_NOT_FOUND` at the `fetch` +before the transfer even runs. A selection built against a stale read now fails its own submission +rather than creating a second grant on a holding that is already gone. + +`createVesting` picks unreserved holdings largest first until they cover the total. Largest rather +than smallest so the factory splits the fewest inputs, no longer for the receiver's sake: the split +leaves one holding to disclose whichever inputs went into it. A holding is worth its `amount` field and nothing is computed: a token-forge `Token` does not decay, so unlike an Amulet there is no decayed value to reason about and no headroom to guess at. Accept is still the one write disclosing something the connected party cannot read for itself: the -funder's holdings. `Token` is `signatory admin, owner` with no observers, so the receiver is no -stakeholder. Their blobs are read with `includeCreatedEventBlob` while the funder is connected and -kept in `localStorage`, written only once the grant is on the ledger, since a declined prompt must -not leave blobs behind for holdings no grant is waiting on. Appended rather than replacing, because -every outstanding grant's own holdings have to stay disclosable. - -Which of those blobs a given Accept sends is read off the ledger, not guessed: the receiver is an -observer of the grant, so `accept` fetches it and discloses exactly the `tokenCids` it names, then -drops them, since that submission archived them. Sending the whole store instead would re-disclose -holdings earlier accepts already consumed, grow without bound, and leave the guard unable to tell a -missing blob from an unrelated one. It is a browser-local hand-off between two wallet accounts, -which is what the demo is; a receiver on another machine has no way to disclose it and `accept` says -so rather than submitting a rejection. +holding the grant reserves. `Token` is `signatory admin, owner` with no observers, so the receiver is +no stakeholder. That holding is created by the funder's own create submission, so its blob cannot be +read before it exists: `createVesting` re-reads the funder's holdings with `includeCreatedEventBlob` +once the grant is on the ledger, and keeps in `localStorage` the blob of every holding an +outstanding grant of theirs reserves. Reconciling the whole set rather than only the grant just made +is what lets a read that failed once be repaired later, instead of leaving a grant nobody can +accept; and reading after the write rather than before is what keeps a declined prompt from leaving +a blob behind for a holding no grant is waiting on. A failure there leaves the grant alone, since it +is already on the ledger and reporting one would invite a second, so it is logged and left to the +next reconcile. + +`viewAs` runs that same reconcile, which is what makes the repair reachable: a funder who creates +one grant and stops never triggers a second create, and their own dashboard is the only other place +the grant is seen. It costs nothing on the settled path, since the holdings are read only once a +grant of this party's is found to be missing its blob, and it is skipped outright for the grants a +party received rather than funded, whose holdings they could not read anyway. + +Which blob a given Accept sends is read off the ledger, not guessed: the receiver is an observer of +the grant, so `accept` fetches it and discloses exactly the `tokenCid` it names, then drops it, since +that submission archived it. Sending the whole store instead would re-disclose holdings earlier +accepts already consumed, which the participant rejects, and grow without bound. It is a +browser-local hand-off between two wallet accounts, which is what the demo +is; a receiver on another machine has no way to disclose it and `accept` says so rather than +submitting a rejection. A grant that has left the receiver's view says something else, because a +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. 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. -A grant and a residual claim each carry `admin` and `instrumentId` as template fields, so a row from -another instrument on a shared participant is dropped before it is mapped. A pending grant carries -neither: `VestingProposal_Accept` derives both by fetching the first holding it names, which the -receiver cannot read. So a pending grant in a foreign instrument does render, and fails at Accept. +A grant, a pending grant and a residual claim each carry `admin` and `instrumentId` as template +fields, so a row from another instrument on a shared participant is dropped before it is mapped. The +proposal stores its pair rather than deriving it, which is what lets the receiver see what a grant is +denominated in: the funder's holdings, which the factory read the pair off, are unreadable to them. A filter always names a template by package name (`#vesting:Vesting:…`) and a command always by the resolved id the deployment carries. The participant rejects each in the other's position, the filter @@ -287,8 +304,8 @@ the `balance` it was given. A Canton balance is a set of holding contracts rather than a scalar, so the read is party-scoped and summed. It reports what a grant could actually spend rather than what the party owns, over the same set `selectHoldings` will draw from: a holding already escrowed is a `LockedToken` and so out by -template, and one an outstanding grant pledged is out because spending it would leave that grant -unacceptable. The two agreeing is the point: a `Max` that offered more would put an amount in the +template, and the one an outstanding grant reserves is out because spending it would leave that +grant unacceptable. The two agreeing is the point: a `Max` that offered more would put an amount in the field that the next step always refuses. The read runs once, on mount: nothing the form does moves the funder's holdings. diff --git a/dapp/frontend/src/backend/LedgerBackend.test.ts b/dapp/frontend/src/backend/LedgerBackend.test.ts index c6b470f3..a44c0675 100644 --- a/dapp/frontend/src/backend/LedgerBackend.test.ts +++ b/dapp/frontend/src/backend/LedgerBackend.test.ts @@ -3,6 +3,7 @@ import { encodeSchedule } from '@/backend/commands' import type { Deployment } from '@/backend/config' import { LedgerBackend } from '@/backend/LedgerBackend' import type { DisclosedContract, LedgerCommand, WalletFns } from '@/backend/wallet' +import { addAmounts, isZero, subtractAmounts } from '@/utils/amount' const deployment: Deployment = { admin: 'instrument-admin::1', @@ -32,7 +33,11 @@ type Submission = { // As much of the ACS query LedgerBackend builds as these tests read back, named once so the two // accessors below share it rather than each casting the body to its own shape. type PartyFilter = { - cumulative?: { identifierFilter?: { TemplateFilter?: { value?: { templateId?: string } } } }[] + cumulative?: { + identifierFilter?: { + TemplateFilter?: { value?: { includeCreatedEventBlob?: boolean; templateId?: string } } + } + }[] } type AcsQuery = { @@ -40,7 +45,11 @@ type AcsQuery = { filter?: { filtersByParty?: Record } } -type Read = { requestMethod: string; resource: string; body?: AcsQuery } +type Read = { + requestMethod: string + resource: string + body?: AcsQuery +} const byParty = (read: Read): Record => read.body?.filter?.filtersByParty ?? {} @@ -51,6 +60,10 @@ const filteredTemplate = (read: Read): string | undefined => const filteredParty = (read: Read): string | undefined => Object.keys(byParty(read))[0] +const readsBlobs = (read: Read): boolean => + byParty(read)[filteredParty(read) ?? '']?.cumulative?.[0]?.identifierFilter?.TemplateFilter?.value + ?.includeCreatedEventBlob === true + const row = (contractId: string, arg: Record): unknown => ({ contractEntry: { JsActiveContract: { createdEvent: { contractId, createArgument: arg } } }, }) @@ -74,6 +87,25 @@ const disclosedToken = (contractId: string): DisclosedContract => ({ createdEventBlob: `blob-${contractId}`, }) +// An outstanding grant of this deployment's instrument, reserving the named holding. +const reserving = (tokenCid: string): unknown => + row(`pending-${tokenCid}`, { + admin: 'instrument-admin::1', + instrumentId: 'DBT', + provider: 'operator::1', + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + tokenCid, + schedule: encodeSchedule(schedule), + note: 'Advisor grant', + }) + +// What the funder kept for the receiver, read the way the backend keys it: `accept` picks one blob +// out of this, so only a direct look says whether the store itself is growing. +const storedTokens = (): DisclosedContract[] => + JSON.parse(localStorage.getItem('vesting.tokenDisclosures') ?? '[]') + const CONFIG = { templateId: '20d54824:Canton.TokenForge.Registry:InstrumentConfig', contractId: '00cfg', @@ -90,16 +122,60 @@ vi.mock('@/backend/registry', () => ({ }), })) -// The harness behaves like the ledger for the one submission a grant now takes: the factory choice -// leaves a pending grant behind pledging exactly the holdings it was given. Without that, a later -// selection would happily spend a holding an outstanding grant is waiting on. +type Created = { + contractId?: string + createArgument?: { admin?: string; amount?: string; instrumentId?: string } +} + +const createdEvent = (contract: unknown): Created => + (contract as { contractEntry?: { JsActiveContract?: { createdEvent?: Created } } }).contractEntry + ?.JsActiveContract?.createdEvent ?? {} + +const cidOf = (contract: unknown): string => createdEvent(contract).contractId ?? '' + +// The harness behaves like the ledger for the one submission a grant takes: the factory splits the +// holdings it is given, so those are consumed and replaced by one holding of exactly the grant plus +// the funder's change, and the pending grant names the first of the two. Without that, a later +// selection would happily spend the holding an outstanding grant is waiting on, and no holding +// would exist for the funder to disclose to the receiver. const settle = (acs: Record, submission: Submission): void => { const exercise = submission.commands?.[0]?.ExerciseCommand if (exercise?.choice !== 'VestingFactory_CreateVesting') { return } - const { tokenCids } = exercise.choiceArgument as { tokenCids: string[] } - acs[PENDING] = [...(acs[PENDING] ?? []), row(`pending-for-${tokenCids[0]}`, { tokenCids })] + const { note, receiver, tokenCids, totalAmount } = exercise.choiceArgument as { + note: string | null + receiver: string + tokenCids: string[] + totalAmount: string + } + const held = acs[TOKEN] ?? [] + const spent = held.filter((contract) => tokenCids.includes(cidOf(contract))) + const change = subtractAmounts( + addAmounts(...spent.map((contract) => createdEvent(contract).createArgument?.amount ?? '0')), + totalAmount, + ) + // The choice takes no instrument of its own: what comes out is the instrument of what went in, so + // a test that funds from another admin's holdings gets a proposal of that admin here too. + const { admin, instrumentId } = createdEvent(spent[0]).createArgument ?? {} + const instrument = { admin, instrumentId } + const funding = tokenRow(`funding-${tokenCids[0]}`, totalAmount, instrument) + const returned = isZero(change) ? [] : [tokenRow(`change-${tokenCids[0]}`, change, instrument)] + const pending = row(`pending-for-${tokenCids[0]}`, { + ...instrument, + provider: 'operator::1', + proposer: submission.actAs?.[0], + receiver, + totalAmount, + tokenCid: cidOf(funding), + note, + }) + acs[TOKEN] = [ + ...held.filter((contract) => !tokenCids.includes(cidOf(contract))), + funding, + ...returned, + ] + acs[PENDING] = [...(acs[PENDING] ?? []), pending] } // The submission carries the synchronizer, so everything disclosed on one arrives stamped with it. @@ -112,6 +188,8 @@ const harness = ( declines?: boolean deployment?: Deployment ledgerEnd?: unknown + readsFailAfterSubmit?: boolean + readsFailOnBlobs?: boolean } = {}, ): { backend: LedgerBackend; submissions: Submission[]; reads: Read[] } => { const { @@ -119,6 +197,8 @@ const harness = ( declines = false, ledgerEnd = { offset: 42 }, deployment: config = deployment, + readsFailAfterSubmit = false, + readsFailOnBlobs = false, } = options const submissions: Submission[] = [] const reads: Read[] = [] @@ -130,11 +210,19 @@ const harness = ( const submission = params as Submission submissions.push(submission) settle(acs, submission) - return {} + // The wallet answers with the transaction's own ids, none of which name a contract it + // created. + return { tx: { payload: { updateId: `update-${submissions.length}` } } } }, ledgerApi: async (params) => { const read = params as Read reads.push(read) + if (readsFailAfterSubmit && submissions.length > 0) { + throw new Error('the participant is not answering') + } + if (readsFailOnBlobs && read.body?.filter !== undefined && readsBlobs(read)) { + throw new Error('the participant is not answering') + } if (read.resource === '/v2/state/ledger-end') { return ledgerEnd } @@ -158,8 +246,8 @@ describe('LedgerBackend.createVesting', () => { note: 'linear', } - // One submission, not two: the token-forge transfer returns the funder's leftover input as - // change, so Accept splits and the funder never has to pre-split. + // One submission, not two: the factory splits the funder's inputs itself, so the funder never has + // to pre-split and the change comes back in the same transaction. it('funds the grant in a single submission', async () => { const { backend, submissions } = harness({ acs: { [TOKEN]: [tokenRow('t1', '600'), tokenRow('t2', '900')] }, @@ -186,11 +274,11 @@ describe('LedgerBackend.createVesting', () => { ]) }) - it('leaves out a holding an outstanding grant has already pledged', async () => { + it('leaves out the holding an outstanding grant has already reserved', async () => { const { backend, submissions } = harness({ acs: { [TOKEN]: [tokenRow('pledged', '1000'), tokenRow('free', '1000')], - [PENDING]: [row('p1', { tokenCids: ['pledged'] })], + [PENDING]: [row('p1', { tokenCid: 'pledged' })], }, }) @@ -201,11 +289,11 @@ describe('LedgerBackend.createVesting', () => { ]) }) - it('refuses when what is left unpledged cannot cover the grant', async () => { + it('refuses when what is left unreserved cannot cover the grant', async () => { const { backend } = harness({ acs: { [TOKEN]: [tokenRow('pledged', '1000'), tokenRow('free', '400')], - [PENDING]: [row('p1', { tokenCids: ['pledged'] })], + [PENDING]: [row('p1', { tokenCid: 'pledged' })], }, }) @@ -228,7 +316,7 @@ describe('LedgerBackend.createVesting', () => { await expect(backend.createVesting(grant)).rejects.toThrow(/only 0 DBT is free/) }) - it('exercises the factory choice with the composed note and schedule, disclosing only it', async () => { + it('exercises the factory choice with the composed note, schedule and config', async () => { const { backend, submissions } = harness({ acs: { [TOKEN]: [tokenRow('t1', '1000')] } }) const result = await backend.createVesting(grant) @@ -245,22 +333,28 @@ describe('LedgerBackend.createVesting', () => { totalAmount: '1000', schedule: encodeSchedule(schedule), tokenCids: ['t1'], + configCid: '00cfg', note: 'Advisor grant\nlinear', }, }, }, ]) - // The funder is not a stakeholder of the observer-less factory, so its disclosure is the - // deployment's rather than something read back here. - expect(submissions[0]?.disclosedContracts).toEqual([ - { - templateId: 'pkg1:Vesting:VestingFactory', - contractId: 'factory-cid', - createdEventBlob: 'YmxvYg==', - synchronizerId: 'sync::1', - }, - ]) - expect(result.disclosedBytes).toBe(deployment.factoryBlob.length) + // The config because the split runs against it, the factory because the funder is not a + // stakeholder of the observer-less factory and so cannot read it. The inputs are the funder's + // own, so they need no disclosure. + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([ + CONFIG, + { + templateId: 'pkg1:Vesting:VestingFactory', + contractId: 'factory-cid', + createdEventBlob: 'YmxvYg==', + }, + ]), + ) + expect(result.disclosedBytes).toBe( + CONFIG.createdEventBlob.length + deployment.factoryBlob.length, + ) }) it('omits the synchronizer id when the config carries none', async () => { @@ -271,19 +365,56 @@ describe('LedgerBackend.createVesting', () => { expect(submissions[0]?.disclosedContracts?.[0]).not.toHaveProperty('synchronizerId') }) + + // The acceptance criterion this issue exists for: the funder keeps everything the grant did not + // take, rather than the whole holding it was funded from. + it('leaves the funder everything the grant did not reserve', async () => { + const { backend } = harness({ acs: { [TOKEN]: [tokenRow('t1', '1500')] } }) + + await backend.createVesting(grant) + + await expect(backend.balanceOf('funder::1')).resolves.toBe('500') + }) + + // The holding the receiver will have to disclose is created by this very submission, so the read + // that carries its blob runs after the write and not with the selection before it. + it('reads the split holding back only once the grant is on the ledger', async () => { + const { backend, reads } = harness({ acs: { [TOKEN]: [tokenRow('t1', '1500')] } }) + + await backend.createVesting(grant) + + const withBlobs = reads.filter( + (read) => + read.body?.filter !== undefined && readsBlobs(read) && filteredTemplate(read) === TOKEN, + ) + expect(withBlobs).toHaveLength(1) + }) + + // The grant is on the ledger by then, so reporting a failure would invite the funder to make a + // second one; the Accept is what says the blob is missing. + it('reports a grant whose funding holding could not be read back as created', async () => { + const { backend } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '1500')] }, + readsFailAfterSubmit: true, + }) + + await expect(backend.createVesting(grant)).resolves.toEqual({ + disclosedBytes: CONFIG.createdEventBlob.length + deployment.factoryBlob.length, + }) + }) }) describe('LedgerBackend.balanceOf', () => { - it('sums the unpledged holdings of this deployment’s instrument', async () => { + it('sums the unreserved holdings of this deployment’s instrument', async () => { const { backend } = harness({ acs: { [TOKEN]: [ tokenRow('a', '600'), tokenRow('b', '400'), - tokenRow('pledged', '900'), + tokenRow('reserved', '900'), tokenRow('foreign', '5000', { instrumentId: 'OTHER' }), ], - [PENDING]: [row('p1', { tokenCids: ['pledged'] })], + [PENDING]: [row('p1', { tokenCid: 'reserved' })], }, }) @@ -408,33 +539,76 @@ describe('LedgerBackend.accept', () => { await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) - expect(submissions[0]?.disclosedContracts).toEqual(onSync([CONFIG, disclosedToken('t1')])) + // The split output, never the input it came out of: that one is archived, and re-disclosing it + // would only fail the transfer's own fetch. + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) expect(submissions[0]?.commands?.[0]?.ExerciseCommand.choice).toBe('VestingProposal_Accept') }) - it('refuses rather than submitting an Accept the participant would reject', async () => { - const { backend } = harness() + // A blob the funder never handed over and a grant that is simply gone are two different things, + // and telling a stale dashboard about the blob store sends the receiver looking in the wrong + // place entirely. + it('refuses an Accept whose blob this browser never kept', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting(grant('First grant')) + localStorage.clear() + const { backend, submissions } = harness({ acs }) await expect( backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }), ).rejects.toThrow(/not disclosable/) + expect(submissions).toHaveLength(0) + }) + + it('refuses an Accept for a grant no longer in the receiver’s view', async () => { + const { backend, submissions } = harness() + + await expect( + backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }), + ).rejects.toThrow(/no longer outstanding/) + expect(submissions).toHaveLength(0) + }) + + // Why every outstanding grant is reconciled and not only the one just made: a read that failed + // once would otherwise leave a grant nobody can ever accept. + it('picks up a blob a failed read left behind when the next grant is created', async () => { + const acs: Record = { + [TOKEN]: [tokenRow('t1', '1500'), tokenRow('t2', '1500')], + } + const flaky = harness({ acs, readsFailAfterSubmit: true }) + await flaky.backend.createVesting(grant('First grant')) + const funder = harness({ acs }) + await funder.backend.createVesting(grant('Second grant')) + const { backend, submissions } = harness({ acs }) + + await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) + + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) }) - // Two grants name the same holding whenever the second selection reads the ACS before the first - // grant is indexed, and the funder is left unable to accept either if that is counted as an error. - it('accepts a grant whose holding was stored by two of them', async () => { + // The funder may never create a second grant, so the next grant cannot be the only repair: their + // own dashboard is where an unacceptable grant would otherwise sit unnoticed forever. + it('picks up a blob a failed read left behind on the funder’s next view', async () => { const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } - await harness({ acs }).backend.createVesting(grant('First grant')) - const unindexed = harness({ acs: { [TOKEN]: [tokenRow('t1', '1500')] } }) - await unindexed.backend.createVesting(grant('Second grant')) + const flaky = harness({ acs, readsFailAfterSubmit: true }) + await flaky.backend.createVesting(grant('First grant')) + const funder = harness({ acs }) + await funder.backend.viewAs('funder::1') const { backend, submissions } = harness({ acs }) await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) - expect(submissions[0]?.disclosedContracts).toEqual(onSync([CONFIG, disclosedToken('t1')])) + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) }) - it('keeps the blobs of a live grant when a later one is declined in the wallet', async () => { + it('keeps the blob of a live grant when a later one is declined in the wallet', async () => { const acs: Record = { [TOKEN]: [tokenRow('t1', '2500')] } const first = harness({ acs }) await first.backend.createVesting(grant('First grant')) @@ -444,7 +618,9 @@ describe('LedgerBackend.accept', () => { await backend.accept({ receiver: 'receiver::1', pendingCid: 'pending-for-t1' }) - expect(submissions[0]?.disclosedContracts).toEqual(onSync([CONFIG, disclosedToken('t1')])) + expect(submissions[0]?.disclosedContracts).toEqual( + onSync([CONFIG, disclosedToken('funding-t1')]), + ) }) }) @@ -494,6 +670,168 @@ describe('LedgerBackend.viewAs', () => { await expect(backend.viewAs('receiver::1')).rejects.toThrow(/did not return an offset/) }) + // The reconcile is the funder's alone: only they can read the holding a grant reserves, so + // reading the holdings on a receiver's every poll would buy nothing. + it('leaves the holdings unread for a party whose pending grants are all incoming', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + // Cleared, so the blob being absent is not what keeps the read away: the receiver could never + // supply it, and it is whose grant this is that decides. + localStorage.clear() + const { backend, reads } = harness({ acs }) + + await backend.viewAs('receiver::1') + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toEqual([]) + }) + + // A dashboard that goes blank because a background repair failed would be a worse bug than the + // one the repair is there for. + it('returns the view even when the holdings cannot be read back', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + localStorage.clear() + const { backend } = harness({ acs, readsFailOnBlobs: true }) + + const view = await backend.viewAs('funder::1') + + expect(view.pendingGrants.map((one) => one.title)).toEqual(['Advisor grant']) + }) + + // Nothing on-ledger stops a funder spending the reserved holding through their wallet, which + // leaves a grant nobody can accept. The read that would find it carries a blob per holding, so it + // is given up on rather than paid for on every view from then on. + it('stops reading the holdings for a reservation that never turns up', async () => { + const { backend, reads } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '500')], [PENDING]: [reserving('archived-elsewhere')] }, + }) + + for (let view = 0; view < 5; view++) { + await backend.viewAs('funder::1') + } + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toHaveLength(3) + }) + + // `readAcs` answers an unparseable response with no rows, so an empty read is indistinguishable + // from a funder who holds nothing. Spending the budget on it would let one participant hiccup + // abandon every outstanding grant in the browser, with nothing short of wiping site data to undo + // it. + it('spends no part of that budget on a read that came back empty', async () => { + const { backend, reads } = harness({ acs: { [PENDING]: [reserving('archived-elsewhere')] } }) + + for (let view = 0; view < 5; view++) { + await backend.viewAs('funder::1') + } + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toHaveLength(5) + }) + + // A holding whose blob the read did not carry is as unusable as one that never came back: nothing + // can be stored for it, so treating it as found would leave it wanted for good and never reach + // the limit that stops the read. + it('counts a holding that comes back without its blob as a miss', async () => { + const blobless = { + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'reserved', + createArgument: { admin: 'instrument-admin::1', instrumentId: 'DBT', amount: '1000' }, + }, + }, + }, + } + const { backend, reads } = harness({ + acs: { [TOKEN]: [blobless], [PENDING]: [reserving('reserved')] }, + }) + + for (let view = 0; view < 5; view++) { + await backend.viewAs('funder::1') + } + + expect(reads.filter((read) => filteredTemplate(read) === TOKEN)).toHaveLength(3) + }) + + // Two reconciles can be in flight at once, a dashboard refresh alongside a fresh grant, and each + // decides what it is missing before either has written. Appending what it found would store the + // same blob twice, and nothing else bounds the store. + it('keeps one blob per holding when two reconciles overlap', async () => { + const acs: Record = { [TOKEN]: [tokenRow('t1', '1500')] } + const funder = harness({ acs }) + await funder.backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + localStorage.clear() + const { backend } = harness({ acs }) + + await Promise.all([backend.viewAs('funder::1'), backend.viewAs('funder::1')]) + + expect(storedTokens().map((one) => one.contractId)).toEqual(['funding-t1']) + }) + + // The proposal now stores the admin and the instrument it is denominated in, so a pending grant + // is filtered like the other two rather than taken on trust. + it('drops pending grants of another instrument', async () => { + const pending = (contractId: string, admin: string): unknown => + row(contractId, { + admin, + instrumentId: 'DBT', + provider: 'operator::1', + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + tokenCid: 'funding', + schedule: encodeSchedule(schedule), + note: 'Advisor grant', + }) + const { backend } = harness({ + acs: { [PENDING]: [pending('mine', 'instrument-admin::1'), pending('theirs', 'other::1')] }, + }) + + const view = await backend.viewAs('receiver::1') + + expect(view.pendingGrants.map((one) => one.id)).toEqual(['mine']) + }) + + // The instrument a grant is denominated in comes from the holdings it was funded from, so a + // deployment pointed at another one sees its own grants and not an empty dashboard. + it('keeps a grant funded under the deployment’s own instrument', async () => { + const instrument = { admin: 'other::1', instrumentId: 'XYZ' } + const { backend } = harness({ + acs: { [TOKEN]: [tokenRow('t1', '1500', instrument)] }, + deployment: { ...deployment, ...instrument }, + }) + await backend.createVesting({ + proposer: 'funder::1', + receiver: 'receiver::1', + totalAmount: '1000', + schedule, + title: 'Advisor grant', + }) + + const view = await backend.viewAs('funder::1') + + expect(view.pendingGrants.map((one) => one.title)).toEqual(['Advisor grant']) + }) + // A shared participant can carry another admin's grants under the same package, and rendering one // under this deployment's symbol would be a lie about what it holds. it('drops grants and claims of another instrument', async () => { diff --git a/dapp/frontend/src/backend/LedgerBackend.ts b/dapp/frontend/src/backend/LedgerBackend.ts index 12c2d1af..970ea46b 100644 --- a/dapp/frontend/src/backend/LedgerBackend.ts +++ b/dapp/frontend/src/backend/LedgerBackend.ts @@ -21,9 +21,10 @@ import { type CreateVestInput, claimChain, composeNote, + fundedBy, lastUpdateOffset, matchesInstrument, - pledgedTokens, + reservedToken, rowToClaim, rowToGrant, rowToPendingGrant, @@ -71,28 +72,38 @@ const STREAM_IDLE_MS = 1000 // Bounded so an offset that fails to advance cannot spin. const CLAIM_HISTORY_PAGES = 20 -// Accept locks the funder's holdings, which the receiver is no stakeholder of and so cannot read the -// disclosure blobs for. They are kept here as the funder submits the pending grant. Persisted, +// Accept locks the holding a grant reserves, which the receiver is no stakeholder of and so cannot +// read the disclosure blob for. It is kept here as the funder submits the pending grant. Persisted, // because the two parties are two wallet accounts and switching between them reloads the app. const TOKEN_STORE_KEY = 'vesting.tokenDisclosures' +// How often a reservation this browser cannot find is looked for again, counted across reloads. The +// read that answers it carries a blob per holding and runs on the funder's dashboard, so a holding +// archived outside this dApp would otherwise cost that read on every view forever, for a grant +// nobody can accept any more. Counted rather than given up on the first miss, because a read that +// answers short is indistinguishable from one that answers in full. +const MISS_STORE_KEY = 'vesting.tokenReadMisses' +const MISS_LIMIT = 3 -// Deduplicated here rather than on write, so a store already holding a holding twice recovers: two -// grants pledge the same one whenever the second ACS read lands before the first grant is indexed, -// and a duplicate would fail `accept`'s count guard for a grant whose blobs are all present. const storedTokens = (): DisclosedContract[] => { try { const stored = JSON.parse(localStorage.getItem(TOKEN_STORE_KEY) ?? '[]') - const tokens: DisclosedContract[] = Array.isArray(stored) ? stored : [] - return [ - ...new Map( - tokens.map((token) => [token.contractId, token]), - ).values(), - ] + return Array.isArray(stored) ? (stored as DisclosedContract[]) : [] } catch { return [] } } +const readMisses = (): Record => { + try { + const stored: unknown = JSON.parse(localStorage.getItem(MISS_STORE_KEY) ?? '{}') + return typeof stored === 'object' && stored !== null && !Array.isArray(stored) + ? (stored as Record) + : {} + } catch { + return {} + } +} + const cidOf = (row: AcsRow): string => row.contractEntry?.JsActiveContract?.createdEvent?.contractId ?? '' @@ -104,6 +115,62 @@ const rowToDisclosed = (row: AcsRow): DisclosedContract | undefined => { : { templateId, contractId, createdEventBlob } } +// The holdings this party's own outstanding grants reserve that this browser has neither a blob for +// nor given up on. Empty is the common answer, which is what lets the caller skip reading the +// holdings at all. +const unstoredReservations = (party: string, pendingRows: AcsRow[]): Set => { + const known = new Set(storedTokens().map((one) => one.contractId)) + const misses = readMisses() + const reserved = mapRows( + pendingRows.filter((row) => fundedBy(row, party)), + reservedToken, + ) + return new Set( + reserved.filter( + (contractId) => !known.has(contractId) && (misses[contractId] ?? 0) < MISS_LIMIT, + ), + ) +} + +// Merged by contract id rather than appended, because two reconciles can overlap, a dashboard +// refresh racing a fresh grant, and each computes what it is missing from a store snapshot taken +// before its own read. Answers with what it stored, which is what a miss is counted against. +const storeTokens = (held: AcsRow[], wanted: Set): Set => { + const missing = mapRows( + held.filter((row) => wanted.has(cidOf(row))), + rowToDisclosed, + ) + if (missing.length === 0) { + return new Set() + } + const merged = new Map(storedTokens().map((one) => [one.contractId, one])) + for (const one of missing) { + merged.set(one.contractId, one) + } + localStorage.setItem(TOKEN_STORE_KEY, JSON.stringify([...merged.values()])) + return new Set(missing.map((one) => one.contractId)) +} + +// Counted against what was stored rather than against what came back, because a holding that +// arrives without its blob is as unusable as one that never arrived: calling it found would leave +// it wanted with no miss ever recorded, and so no bound on the read it costs. +const recordMisses = (wanted: Set, stored: Set): void => { + const missed = [...wanted].filter((contractId) => !stored.has(contractId)) + if (missed.length === 0) { + return + } + const misses = readMisses() + const bumped = Object.fromEntries(missed.map((one) => [one, (misses[one] ?? 0) + 1])) + const abandoned = Object.entries(bumped).filter(([, count]) => count === MISS_LIMIT) + if (abandoned.length > 0) { + console.warn( + 'giving up on the holdings these grants reserve: they cannot be accepted from this browser', + abandoned.map(([contractId]) => contractId), + ) + } + localStorage.setItem(MISS_STORE_KEY, JSON.stringify({ ...misses, ...bumped })) +} + export class LedgerBackend implements VestingBackend { private readonly wallet: WalletFns private readonly factory: DisclosedContract @@ -176,8 +243,8 @@ export class LedgerBackend implements VestingBackend { }) } - // What is free to fund a grant, and not simply what the party holds: a holding already escrowed - // is a LockedToken and so out by template, and a holding an outstanding grant pledged is out + // What is free to fund a grant, and not simply what the party holds: a holding already escrowed is + // a LockedToken and so out by template, and the holding an outstanding grant reserves is out // because spending it would leave that grant unacceptable. Offering more than this would put an // amount in the field that the next step always refuses. async balanceOf(partyId: string): Promise { @@ -193,48 +260,89 @@ export class LedgerBackend implements VestingBackend { this.readAcs(partyId, vesting('VestingContract'), offset), this.readAcs(partyId, vesting('VestedClaim'), offset), ]) + // The funder's own view is the one place a grant whose blob never got recorded is seen again, + // so it is also where that is repaired: without it a funder who makes one grant and stops has + // an unacceptable grant and no way to notice. A failure here must not blank the dashboard. + await this.storeFunding(partyId, offset, pendingGrantRows).catch((cause: unknown) => { + console.warn('could not read back the holdings this party’s grants reserve', cause) + }) return { - pendingGrants: mapRows(pendingGrantRows, rowToPendingGrant), + pendingGrants: mapRows(this.ofInstrument(pendingGrantRows), rowToPendingGrant), grants: mapRows(this.ofInstrument(contractRows), rowToGrant), claims: mapRows(this.ofInstrument(claimRows), rowToClaim), } } - // One submission, so one wallet approval. `executeTokenTransfer` returns the sender's leftover - // input as change, so Accept splits the named holdings itself and the funder never pre-splits the - // way the Amulet version had to. The factory is the operator's and observer-less, so the funder - // cannot read it and its disclosure comes from the deployment; the blob size is what lets the UI + // One submission, so one wallet approval: the factory splits the funder's inputs down to the grant + // and hands the change back in the same transaction, so the funder never pre-splits the way the + // Amulet version had to. The factory is the operator's and observer-less, so the funder cannot + // read it and its disclosure comes from the deployment; the disclosed size is what lets the UI // surface that mechanic. async createVesting(args: CreateVestInput): Promise<{ disclosedBytes: number }> { - const free = await this.freeTokens(args.proposer, true) + const free = await this.freeTokens(args.proposer) const picked = selectHoldings(free, args.totalAmount) if (picked === undefined) { throw new Error( `only ${addAmounts(...free.map(tokenValue))} ${this.instrument.instrumentId} is free to fund this grant`, ) } - const disclosures: DisclosedContract[] = [] - for (const row of picked) { - const disclosure = rowToDisclosed(row) - if (disclosure === undefined) { - throw new Error('a holding funding this grant came back without its disclosure blob') - } - disclosures.push(disclosure) - } - const command = buildCreateVestingCommand(this.factory.templateId, this.factory.contractId, { - proposer: args.proposer, - receiver: args.receiver, - totalAmount: args.totalAmount, - schedule: args.schedule, - tokenCids: picked.map(cidOf), - note: composeNote(args.title, args.note), + const disclosed = await this.submitWithConfig( + args.proposer, + ({ configCid }) => + buildCreateVestingCommand(this.factory.templateId, this.factory.contractId, { + configCid, + proposer: args.proposer, + receiver: args.receiver, + totalAmount: args.totalAmount, + schedule: args.schedule, + tokenCids: picked.map(cidOf), + note: composeNote(args.title, args.note), + }), + [this.factory], + ) + // The grant is on the ledger by now, so a failure to record the blob must not report one: it + // would invite the funder to make a second grant, and `accept` is where a missing blob is felt + // and said. Logged rather than swallowed outright, because the next reconcile is what repairs + // it and nothing else would say one was needed. + await this.reconcileFunding(args.proposer).catch((cause: unknown) => { + console.warn('could not read back the holding this grant reserves', cause) }) - await this.submit(args.proposer, command, [this.factory]) - // After the submit, not before: a grant the wallet declined must not leave blobs behind for - // holdings no grant is waiting on. Appended rather than replacing, because every outstanding - // grant's own holdings have to stay disclosable. - localStorage.setItem(TOKEN_STORE_KEY, JSON.stringify([...storedTokens(), ...disclosures])) - return { disclosedBytes: this.factory.createdEventBlob.length } + return { + disclosedBytes: disclosed.reduce((total, one) => total + one.createdEventBlob.length, 0), + } + } + + // The receiver has to disclose the holding a grant reserves, and only the funder can read it: a + // `Token` has no observers. Every outstanding grant of this funder is reconciled rather than just + // the one just made, so a read that failed once is repaired by their next grant or their next + // dashboard load instead of leaving a grant nobody can accept. The holdings are read only when + // something is actually missing, since this runs on every view, and a reservation the read keeps + // not answering is given up on so that this cannot become a read on every view for good. + private async storeFunding( + party: string, + offset: string | number, + pendingRows: AcsRow[], + ): Promise { + const wanted = unstoredReservations(party, pendingRows) + if (wanted.size === 0) { + return + } + const held = await this.readAcs(party, TOKEN, offset, true) + // A read that fails soft answers with no rows, so an empty one says nothing about whether the + // reservation is still there and must not spend the budget that gives up on it. + if (held.length === 0) { + return + } + recordMisses(wanted, storeTokens(held, wanted)) + } + + // Reconciles against a snapshot of its own, for a caller that has read no rows to hand over. + // Taken after the submit, never before, because the factory creates the holding in that very + // submission and a grant the wallet declined must leave no blob behind. + private async reconcileFunding(party: string): Promise { + const offset = await this.ledgerEnd() + const pendingRows = await this.readAcs(party, vesting('VestingProposal'), offset) + await this.storeFunding(party, offset, pendingRows) } async tap(args: { amount: string; party: string }): Promise { @@ -243,34 +351,36 @@ export class LedgerBackend implements VestingBackend { ) } - // A transfer consumes every holding it is given, so one an outstanding grant pledged has to stay - // out: consuming it is exactly what leaves that grant unacceptable. Blobs are read only where a - // caller will disclose them, since each is several hundred bytes the balance read has no use for. - private async freeTokens(owner: string, includeBlobs = false): Promise { + // A transfer consumes every holding it is given, so the one an outstanding grant reserves has to + // stay out: consuming it is exactly what leaves that grant unacceptable. + private async freeTokens(owner: string): Promise { const offset = await this.ledgerEnd() const [held, pendingRows] = await Promise.all([ - this.readAcs(owner, TOKEN, offset, includeBlobs), + this.readAcs(owner, TOKEN, offset), this.readAcs(owner, vesting('VestingProposal'), offset), ]) - const pledged = new Set(pendingRows.flatMap(pledgedTokens)) - return held.filter((row) => matchesInstrument(row, this.instrument) && !pledged.has(cidOf(row))) + const reserved = new Set(pendingRows.map(reservedToken)) + return held.filter( + (row) => matchesInstrument(row, this.instrument) && !reserved.has(cidOf(row)), + ) } - // A pending grant is deliberately not filtered: VestingProposal carries no admin or instrumentId, - // deriving both at Accept from a holding the receiver cannot read. private ofInstrument(rows: AcsRow[]): AcsRow[] { return rows.filter((row) => matchesInstrument(row, this.instrument)) } // Every choice that moves a holding takes the same config and the same one disclosure, so the - // invariant is held here rather than re-spelled per choice; `extra` is what only Accept adds. + // invariant is held here rather than re-spelled per choice; `extra` is what only create and + // Accept add. It answers with what it disclosed, which only create has a use for. private async submitWithConfig( actAs: string, build: (config: InstrumentConfigRef) => LedgerCommand, extra: DisclosedContract[] = [], - ): Promise { + ): Promise { const { disclosed, ...config } = await fetchInstrumentConfig(actAs, this.instrument) - await this.submit(actAs, build(config), [...disclosed, ...extra]) + const sent = [...disclosed, ...extra] + await this.submit(actAs, build(config), sent) + return sent } // `TRANSACTION_SHAPE_LEDGER_EFFECTS` is what carries the exercise; the default ACS-delta shape @@ -320,30 +430,34 @@ export class LedgerBackend implements VestingBackend { return claimChain(records, contractCid) } - // The grant names the holdings its Accept locks, and the receiver is an observer of the grant, so - // which blobs to send is read off the ledger rather than guessed at. Sending the whole store - // instead would re-disclose holdings earlier accepts already consumed, and would leave the guard - // below unable to tell a missing blob from an unrelated one. + // The grant names the holding its Accept locks, and the receiver is an observer of the grant, so + // which blob to send is read off the ledger rather than guessed at. Sending the whole store + // instead would re-disclose holdings earlier accepts already consumed, which the participant + // rejects. async accept(args: { receiver: string; pendingCid: string }): Promise { const offset = await this.ledgerEnd() const rows = await this.readAcs(args.receiver, vesting('VestingProposal'), offset) - const wanted = new Set( - rows.filter((row) => cidOf(row) === args.pendingCid).flatMap(pledgedTokens), - ) - const tokens = storedTokens().filter((token) => wanted.has(token.contractId)) - if (wanted.size === 0 || tokens.length !== wanted.size) { - throw new Error('the funder holdings this grant locks are not disclosable from this browser') + const proposal = rows.find((row) => cidOf(row) === args.pendingCid) + // 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') + } + const wanted = reservedToken(proposal) + const token = storedTokens().find((one) => one.contractId === wanted) + if (wanted === undefined || token === undefined) { + throw new Error('the funder holding this grant locks is not disclosable from this browser') } await this.submitWithConfig( args.receiver, ({ configCid }) => buildAcceptCommand(this.tid('VestingProposal'), args.pendingCid, configCid), - tokens, + [token], ) - // The submission archived them, so their blobs can only mislead a later Accept from here on. + // 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((token) => !wanted.has(token.contractId))), + JSON.stringify(storedTokens().filter((one) => one.contractId !== wanted)), ) } diff --git a/dapp/frontend/src/backend/VestingBackend.test.ts b/dapp/frontend/src/backend/VestingBackend.test.ts index 6cfeaec9..d0c198c9 100644 --- a/dapp/frontend/src/backend/VestingBackend.test.ts +++ b/dapp/frontend/src/backend/VestingBackend.test.ts @@ -6,7 +6,7 @@ import { composeNote, lastUpdateOffset, matchesInstrument, - pledgedTokens, + reservedToken, rowToClaim, rowToGrant, rowToPendingGrant, @@ -314,29 +314,29 @@ describe('matchesInstrument', () => { }) }) -describe('pledgedTokens', () => { - it('reads the holdings a pending grant’s Accept will consume', () => { +describe('reservedToken', () => { + it('reads the holding a pending grant’s Accept will consume', () => { const pending = { contractEntry: { JsActiveContract: { createdEvent: { contractId: 'p1', - createArgument: { tokenCids: ['t1', 't2'] }, + createArgument: { tokenCid: 't1' }, }, }, }, } as AcsRow - expect(pledgedTokens(pending)).toEqual(['t1', 't2']) + expect(reservedToken(pending)).toBe('t1') }) - it('reads a row naming none as an empty list', () => { - expect(pledgedTokens({} as AcsRow)).toEqual([]) + it('reads a row naming none as undefined', () => { + expect(reservedToken({} as AcsRow)).toBeUndefined() }) }) describe('selectHoldings', () => { - // Largest first, so a grant names the fewest inputs: every named holding is a disclosure blob the - // receiver has to carry to Accept. + // Largest first, so the factory splits the fewest inputs: the receiver carries one disclosure + // whatever is picked, since the split leaves a single holding behind. it('takes the largest holdings first and stops once they cover the total', () => { const rows = [tokenRow('small', '100'), tokenRow('big', '900'), tokenRow('mid', '400')] expect(selectHoldings(rows, '1000')?.map((row) => tokenValue(row))).toEqual(['900', '400']) diff --git a/dapp/frontend/src/backend/VestingBackend.ts b/dapp/frontend/src/backend/VestingBackend.ts index a63c01ec..3ebe2a56 100644 --- a/dapp/frontend/src/backend/VestingBackend.ts +++ b/dapp/frontend/src/backend/VestingBackend.ts @@ -252,22 +252,27 @@ export const tokenValue = (row: AcsRow): string => { } // A shared participant can hold another admin's instrument under the same id, so both halves of the -// pair are compared. `VestingProposal` carries neither field and so is never passed here. +// pair are compared. export const matchesInstrument = (row: AcsRow, instrument: RegistryInstrument): boolean => { const { admin, instrumentId } = argOf(row) as TokenArg return admin === instrument.admin && instrumentId === instrument.instrumentId } -// The holdings a pending grant has already pledged: its Accept consumes exactly these, so nothing -// else may spend them while it is outstanding. -export const pledgedTokens = (row: AcsRow): string[] => { - const cids = argOf(row).tokenCids - return Array.isArray(cids) ? cids.map(String) : [] +// The holding a pending grant reserves: the factory split it off at exactly the grant, and its +// Accept consumes that one contract, so nothing else may spend it while the grant is outstanding. +export const reservedToken = (row: AcsRow): string | undefined => { + const cid = argOf(row).tokenCid + return typeof cid === 'string' ? cid : undefined } -// Largest first, so a grant names the fewest inputs: every named holding is a disclosure blob the -// receiver has to carry to Accept. Undefined rather than a partial set, so the caller can report by -// how much the funder is short. +// Whether a pending grant is one this party funded. A `Token` has no observers, so only the funder +// can read the holding their own grant reserves, and the grants they received are nobody's to +// reconcile from here. +export const fundedBy = (row: AcsRow, party: string): boolean => argOf(row).proposer === party + +// Largest first, so the factory splits the fewest inputs. Not for the receiver's sake any more: the +// split leaves one holding whatever is picked. Undefined rather than a partial set, so the caller +// 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. diff --git a/dapp/frontend/src/backend/commands.test.ts b/dapp/frontend/src/backend/commands.test.ts index 5169f1a1..410796d7 100644 --- a/dapp/frontend/src/backend/commands.test.ts +++ b/dapp/frontend/src/backend/commands.test.ts @@ -77,6 +77,7 @@ describe('command builders', () => { it('builds the factory choice with the canonical amount, encoded schedule and token ids', () => { expect( buildCreateVestingCommand('pkg:Vesting:VestingFactory', 'factory-cid', { + configCid: CONFIG_CID, proposer: 'funder::1', receiver: 'receiver::1', totalAmount: '1000.00', @@ -94,6 +95,7 @@ describe('command builders', () => { totalAmount: '1000', schedule: encodeSchedule(linear), tokenCids: ['t1', 't2'], + configCid: CONFIG_CID, note: null, }, }, diff --git a/dapp/frontend/src/backend/commands.ts b/dapp/frontend/src/backend/commands.ts index 54daba1b..eb5df816 100644 --- a/dapp/frontend/src/backend/commands.ts +++ b/dapp/frontend/src/backend/commands.ts @@ -68,6 +68,7 @@ export const decodeSchedule = (raw: unknown): VestingSchedule => { // ── Command builders ──────────────────────────────────────────────────────── type CreateVestingArgs = { + configCid: string note?: string proposer: string receiver: string @@ -83,8 +84,8 @@ const exercise = ( choiceArgument: Record, ) => ({ ExerciseCommand: { templateId, contractId, choice, choiceArgument } }) -// The only choice taking no config: it moves no token, it only records which of the funder's -// holdings the eventual Accept will lock. +// `tokenCids` are the funder's inputs, which the choice splits down to the grant; the holding the +// proposal ends up naming is the exact-size output that split leaves behind. export const buildCreateVestingCommand = ( templateId: string, factoryCid: string, @@ -96,6 +97,7 @@ export const buildCreateVestingCommand = ( totalAmount: canonicalAmount(args.totalAmount), schedule: encodeSchedule(args.schedule), tokenCids: args.tokenCids, + configCid: args.configCid, note: args.note ?? null, }) diff --git a/dapp/frontend/src/components/CreateGrant/index.tsx b/dapp/frontend/src/components/CreateGrant/index.tsx index 218465fb..ff6e3e37 100644 --- a/dapp/frontend/src/components/CreateGrant/index.tsx +++ b/dapp/frontend/src/components/CreateGrant/index.tsx @@ -333,8 +333,8 @@ export const CreateGrant = ({ onClose }: { onClose: () => void }): React.JSX.Ele value={amount} />

- An outstanding grant reserves the whole holdings it names; they return as change - once the receiver accepts. + An outstanding grant reserves exactly this amount; the rest of your balance stays + spendable until the receiver accepts.

diff --git a/vendor/PROVENANCE.md b/vendor/PROVENANCE.md index ce9ce841..1516bc09 100644 --- a/vendor/PROVENANCE.md +++ b/vendor/PROVENANCE.md @@ -32,11 +32,11 @@ its own. | | | |---|---| | Source | https://github.com/BootNodeDev/canton-vesting-forge | -| Release | `v0.1.0` | -| Asset | `vesting-0.0.1.dar` | -| Size | 677236 bytes | -| sha256 | `0aafd73375f82679a575e4ea87efa3ea96b4b98da4e71251db793460a9ce801b` | -| package-id | `64ed80c9a4bd847b2aa9621c02510a1feddb9a0e7e47a48b890a5fa800eeae30` | +| Release | `v0.2.0` | +| Asset | `vesting-0.0.2.dar` | +| Size | 688686 bytes | +| sha256 | `b78439bafdfb4c2e38666d3d99ac73e8c27722dada41d52876d997f16858d5b3` | +| package-id | `e96e3b9b70a12538cc3bba264480dca9e88e87593706cc3f2e638e877ff99f67` | | Bundled canton-token-forge | 0.0.1, package-id `20d54824dc4d76694c70ac51dd5f0b9e063ab789ffa02d07432abb848c8360cc` | | Daml SDK | 3.4.11 | | LF target | 2.1 | @@ -53,3 +53,14 @@ that order. There is no automated update path. To move to a later release of either: replace the binary, re-run the checksum, and update every row above by hand. + +A participant that already vetted an earlier version of the same package refuses +the new one outright when the two are not a valid smart-contract upgrade: +`vesting` 0.0.2 changed `VestingFactory_CreateVesting`'s return type, so a +LocalNet carrying 0.0.1 answers the upload with +`NOT_VALID_UPGRADE_PACKAGE`. Nothing can be uploaded past that; drop the ledger +first and let the stack redeploy both DARs onto it: + + ./scripts/dev-stack.sh down + ( cd .canton-localnet && ../node_modules/.bin/canton-barebones reset ) + ./scripts/dev-stack.sh up diff --git a/vendor/vesting.dar b/vendor/vesting.dar index 6d83022d..a87ba086 100644 Binary files a/vendor/vesting.dar and b/vendor/vesting.dar differ