diff --git a/client.js b/client.js index ef0e765d..5524febf 100644 --- a/client.js +++ b/client.js @@ -2661,13 +2661,13 @@ async function getDaoGraceDurationMs() { // --------------------------------------------------------------------------- // dao proposal create // --------------------------------------------------------------------------- -vorpal.command('dao proposal create', 'create a new DAO governance/economic/protocol proposal').action(async function (args, callback) { +vorpal.command('dao proposal create', 'create a new DAO governance/economic/protocol/project proposal').action(async function (args, callback) { const answers = await this.prompt([ { type: 'list', name: 'proposalType', message: 'Proposal type:', - choices: ['governance', 'economic', 'protocol'], + choices: ['governance', 'economic', 'protocol', 'project'], }, { type: 'confirm', @@ -2698,6 +2698,21 @@ vorpal.command('dao proposal create', 'create a new DAO governance/economic/prot message: 'Enter ballot options as comma-separated list (e.g. no,Increase burn,Decrease burn):', default: 'no,yes', }, + { + type: 'input', + name: 'contractorAddress', + message: 'Contractor address (project only):', + when: (a) => a.proposalType === 'project', + }, + { + type: 'input', + name: 'milestonesJson', + message: + 'Milestones as a JSON array (project only) — e.g. ' + + '[{"title":"Design","description":"Design it","deliverable":"A doc","duration":604800000,' + + '"costUsdStr":"1000","penaltyUsdStr":"100","bonusUsdStr":"50"}]:', + when: (a) => a.proposalType === 'project', + }, { type: 'input', name: 'changesJson', @@ -2705,6 +2720,7 @@ vorpal.command('dao proposal create', 'create a new DAO governance/economic/prot 'Enter parameter change sets as JSON array — one set per action option, so the options example above needs two ' + '(e.g. [[{"key":"pctBurned","value":"60","current":"50"}],[{"key":"pctBurned","value":"40","current":"50"}]]):', default: '[]', + when: (a) => a.proposalType !== 'project', }, { type: 'number', @@ -2721,7 +2737,8 @@ vorpal.command('dao proposal create', 'create a new DAO governance/economic/prot const proposalId = daoProposalId(nextCount) const options = answers.options.split(',').map((s) => s.trim()) - const changes = JSON.parse(answers.changesJson) + // Projects supply milestones and a contractor; changesJson is ignored for them. + const changes = answers.proposalType === 'project' ? [] : JSON.parse(answers.changesJson) const maxGraceMs = await getDaoGraceDurationMs() const gracePeriod = answers.gracePeriodDays <= 0 @@ -2729,7 +2746,9 @@ vorpal.command('dao proposal create', 'create a new DAO governance/economic/prot : Math.min(answers.gracePeriodDays * ONE_DAY, maxGraceMs) const typePayload = {} - if (answers.proposalType === 'governance') typePayload.governance = { changes } + if (answers.proposalType === 'project') { + typePayload.project = { milestones: JSON.parse(answers.milestonesJson), address: answers.contractorAddress.trim() } + } else if (answers.proposalType === 'governance') typePayload.governance = { changes } else if (answers.proposalType === 'economic') typePayload.economic = { changes } else if (answers.proposalType === 'protocol') typePayload.protocol = { changes } @@ -3090,7 +3109,7 @@ vorpal.command('dao burn reward', "burn the unclaimed voter reward for a proposa // from it would silently omit proposals that exist but have not been backfilled yet — wrong for // the command whose job is the complete list. `dao summary` is the fast recent-activity path; // this one stays exhaustive. -const VALID_DAO_STATUSES = ['review', 'withheld', 'voting', 'rejected', 'accepted', 'applied', 'canceled'] +const VALID_DAO_STATUSES = ['review', 'withheld', 'voting', 'rejected', 'accepted', 'applied', 'canceled', 'executing', 'completed', 'terminated'] vorpal.command('dao proposals [status]', `list DAO proposals, optionally filtered by status (${VALID_DAO_STATUSES.join('/')})`).action(async function (args, callback) { if (args.status && !VALID_DAO_STATUSES.includes(args.status)) { @@ -3140,6 +3159,129 @@ vorpal.command('dao proposals [status]', `list DAO proposals, optionally filtere callback() }) +// --------------------------------------------------------------------------- +// dao project ... (project proposal lifecycle) +// --------------------------------------------------------------------------- +// Every project tx takes a proposal number and, where relevant, a 1-based milestone number — +// milestones are numbered from 1 in transactions but stored 0-based, and the server converts. +function projectTx(type, proposalNumber, extra = {}) { + return { type, from: USER.address, proposalId: daoProposalId(proposalNumber), ...extra, timestamp: Date.now() } +} + +async function submitProjectTx(ctx, tx) { + try { + signTransaction(tx) + ctx.log(await injectTx(tx)) + } catch (err) { + ctx.log('Error:', err.message) + } +} + +vorpal.command('dao project ', 'show a project proposal: milestones, balance and rate').action(async function (args, callback) { + try { + const res = await axios.get(`${PROTOCOL}://${HOST}/dao/projects/${args.number}`) + const body = parseDaoApiBody(res.data) + if (!body || body.error) { + this.log(body?.error ?? `Project #${args.number} not found.`) + callback() + return + } + const p = body.project + this.log(`\n--- Project #${body.number} [${body.status}] ---`) + this.log(`Contractor: ${p.address}`) + if (p.proposedAddress) this.log(`Proposed: ${p.proposedAddress} (${p.endorsedAddress?.length ?? 0} endorsements)`) + this.log(`Balance: ${weiToLibStr(asBigIntForDisplay(p.balance))} LIB @ rate ${p.rateUsdStr}`) + this.log(`Bonus/penalty thresholds: ${p.durationBonusPercentage}% / ${p.durationPenaltyPercentage}%`) + if (p.startTime) this.log(`Started: ${new Date(p.startTime).toISOString()}`) + if (p.endTime) this.log(`Ended: ${new Date(p.endTime).toISOString()}`) + this.log(`Log entries: ${body.logCount}`) + this.log('Milestones:') + ;(p.milestones ?? []).forEach((m, i) => { + const paid = asBigIntForDisplay(m.paid) + const timing = m.startTime && m.endTime ? ` ${Math.round((m.endTime - m.startTime) / 86400000)}d of ${Math.round(m.duration / 86400000)}d planned` : '' + const pending = m.proposedTime ? ` | proposed ${new Date(m.proposedTime).toISOString()} (${m.endorsedTime?.length ?? 0} endorsements)` : '' + const votes = m.terminateVotes?.length ? ` | ${m.terminateVotes.length} terminate vote(s)` : '' + this.log(` ${i + 1}. [${m.status}] ${m.title} | cost ${m.costUsdStr} USD${timing} | paid ${weiToLibStr(paid)} LIB${pending}${votes}`) + }) + } catch (err) { + this.log('Error:', err.message) + } + callback() +}) + +vorpal.command('dao project logs ', 'show a project audit trail').action(async function (args, callback) { + try { + const res = await axios.get(`${PROTOCOL}://${HOST}/dao/projects/${args.number}/logs`) + const logs = parseDaoApiBody(res.data)?.logs ?? [] + if (logs.length === 0) this.log('No log entries.') + for (const l of logs) { + // params is a structured object; render it as key=value pairs rather than stringifying it. + const params = Object.entries(l.params ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join(' ') + this.log(`${new Date(l.timestamp).toISOString()} ${l.txType} by ${l.caller}${params ? ` | ${params}` : ''}`) + } + } catch (err) { + this.log('Error:', err.message) + } + callback() +}) + +vorpal.command('dao project start ', 'start an accepted project and mint its balance (committee only)').action(async function (args, callback) { + await submitProjectTx(this, projectTx('dao_project_start', args.number)) + callback() +}) + +vorpal.command('dao project end ', 'end a project once every milestone is finished (committee only)').action(async function (args, callback) { + await submitProjectTx(this, projectTx('dao_project_end', args.number)) + callback() +}) + +vorpal.command('dao project reclaim ', 'reclaim an unclaimed project balance after the delay (committee only)').action(async function (args, callback) { + await submitProjectTx(this, projectTx('dao_project_reclaim_balance', args.number)) + callback() +}) + +vorpal + .command('dao project address ', 'propose or endorse a new contractor address (committee only)') + .action(async function (args, callback) { + // Blank endorses whatever is pending; a value proposes a replacement and resets endorsements. + const answers = await this.prompt([{ type: 'input', name: 'proposedAddress', message: 'New contractor address (blank to endorse the pending one):' }]) + const extra = answers.proposedAddress?.trim() ? { proposedAddress: answers.proposedAddress.trim() } : {} + await submitProjectTx(this, projectTx('dao_project_change_address', args.number, extra)) + callback() + }) + +// No milestone argument: the server acts on the next pending milestone for a start and the +// executing one for an end, as the policy specifies. +for (const [command, type, verb] of [ + ['dao milestone start ', 'dao_project_milestone_start', 'start'], + ['dao milestone end ', 'dao_project_milestone_end', 'end'], +]) { + vorpal.command(command, `propose or endorse a milestone ${verb} time (contractor or committee)`).action(async function (args, callback) { + // Blank endorses the pending time; the first value proposes one. A time can only be proposed + // once per milestone, and may not be in the future — the server rejects both rather than + // resetting the endorsements or crediting unserved duration. + const answers = await this.prompt([{ type: 'input', name: 'proposedTime', message: `Proposed ${verb} time in ms since epoch (blank to endorse):` }]) + const extra = answers.proposedTime?.trim() ? { proposedTime: Number(answers.proposedTime.trim()) } : {} + await submitProjectTx(this, projectTx(type, args.number, extra)) + callback() + }) +} + +vorpal + .command('dao milestone terminate ', 'vote to terminate a milestone (committee only)') + .action(async function (args, callback) { + const answers = await this.prompt([{ type: 'input', name: 'reason', message: 'Reason for terminating:' }]) + await submitProjectTx(this, projectTx('dao_project_milestone_terminate', args.number, { milestoneNumber: args.milestone, reason: answers.reason })) + callback() + }) + +vorpal.command('dao milestone claim ', 'claim payment for a completed milestone (contractor only)').action(async function (args, callback) { + await submitProjectTx(this, projectTx('dao_project_milestone_claim', args.number, { milestoneNumber: args.milestone })) + callback() +}) + // --------------------------------------------------------------------------- // dao summary (query — the recently-active index, then details for just those) // --------------------------------------------------------------------------- diff --git a/scripts/test-dao-e2e.ts b/scripts/test-dao-e2e.ts index 2d5b302c..c1f5e336 100644 --- a/scripts/test-dao-e2e.ts +++ b/scripts/test-dao-e2e.ts @@ -28,7 +28,7 @@ * * --parallel splits each scenario into a setup phase (proposal creation, run sequentially * to avoid meta.count races) and a body phase (all remaining steps run concurrently). - * Output lines are prefixed with [S1]…[S18] to distinguish interleaved scenarios. + * Output lines are prefixed with [S1]…[S21] to distinguish interleaved scenarios. * Note: --step is not designed to combine with --parallel. * * By default the network is left running when any step fails so you can iterate on @@ -93,7 +93,7 @@ function parseCommaList(value: string, label: string): string[] { } /** Bumped whenever a new scenario is added — keeps --scenario and --step validation in sync. */ -const MAX_SCENARIO_NUMBER = 20 +const MAX_SCENARIO_NUMBER = 21 function parseScenarioFilter(value: string | null): Set | null { if (value == null) return null @@ -379,6 +379,14 @@ interface StepSortKey { * In --parallel mode all setupSteps across all scenarios run first (sequentially), * then all bodySteps run concurrently. */ +/** + * Extra fields a Scenario 21 transaction may carry. + * + * The keys sc21Tx owns are typed `never`, so passing one is a compile error rather than a silent + * override of the proposal, sender or type the helper is there to fix. + */ +type Sc21TxExtra = { [key: string]: unknown } & { [K in 'type' | 'networkId' | 'from' | 'proposalId' | 'timestamp']?: never } + interface ScenarioDef { num: number name: string @@ -498,6 +506,14 @@ function assert(condition: boolean, message: string): asserts condition { if (!condition) throw new Error(message) } +/** + * Stamps the transaction at injection time. + * + * This overwrites whatever `timestamp` the caller set, so transaction literals must not carry one — + * it would read as meaningful and be silently discarded. A test that needs a *specific* timestamp + * cannot use injectAndAssert/injectExpectReject; it has to build and sign the transaction itself, + * as fundAccount does. + */ function refreshTxTimestamp(tx: T): void { const timestampedTx = tx as { timestamp?: number } timestampedTx.timestamp = Date.now() @@ -822,6 +838,17 @@ function usdStrToLibCeil(usdStr: string, stabilityFactorStr: string): number { return Math.ceil(Number(ethers.formatEther(libWei))) } +/** + * Sums several USD amounts into wei the way the handlers do — converting each amount separately. + * + * Converting the total instead truncates once rather than once per amount, which drifts by a few + * wei. Every project amount is a sum of per-milestone cost and bonus figures, so the expectation + * has to be built the same way to match exactly. + */ +function usdSumToLibWei(rateUsdStr: string, ...usdStrs: string[]): bigint { + return usdStrs.reduce((total, usdStr) => total + usdStrToLibWei(usdStr, rateUsdStr), 0n) +} + /** Convert a USD string to LIB wei using the network stability factor. */ function usdStrToLibWei(usdStr: string, stabilityFactorStr: string): bigint { return ethers.parseEther(usdStr) * 10n ** 18n / ethers.parseEther(stabilityFactorStr) @@ -1215,6 +1242,74 @@ async function getProposal(n: number): Promise { return proposal! } +interface ProjectView { + number: number + status: string + project: { + milestones: Array> + balance: bigint + rateUsdStr: string + address: string + proposedAddress?: string + endorsedAddress: string[] + startTime?: number + endTime?: number + durationBonusPercentage: number + durationPenaltyPercentage: number + } + logCount: number +} + +/** Reads dao/projects/:id, polling because apiGet picks a random node per call. */ +async function getProject(proposalNumber: number): Promise { + let view: ProjectView | null = null + await pollUntil( + async () => { + try { + const res = await apiGet(`/dao/projects/${proposalNumber}`) + const body = safeParse(res.data) + if (body?.project == null) return false + view = body as ProjectView + return true + } catch (err) { + if (isRetryablePollError(err)) return false + throw err + } + }, + txSettleTimeoutMs, + 2_000, + ) + return view! +} + +/** Polls until milestone `n` (1-based) reports `expectedStatus`. */ +async function waitForMilestoneStatus(proposalNumber: number, milestoneNumber: number, expectedStatus: string): Promise> { + let found: Record | null = null + let lastSeen: string | undefined + try { + await pollUntil( + async () => { + const view = await getProject(proposalNumber) + const milestone = view.project.milestones[milestoneNumber - 1] + lastSeen = milestone?.status + if (lastSeen === expectedStatus) { + found = milestone + return true + } + return false + }, + txSettleTimeoutMs, + 2_000, + ) + } catch (err) { + if (err instanceof PollTimeoutError) { + throw new Error(`Milestone ${milestoneNumber} never reached "${expectedStatus}" — last saw "${lastSeen}"`) + } + throw err + } + return found! +} + /** One entry of meta.proposals — the consensus-visible recent-activity index. */ interface ProposalIndexEntry { proposal: number @@ -1437,7 +1532,7 @@ async function waitForListOfChangesFromReceipt(description: string, receipt: TxR ) } -type ProposalType = 'governance' | 'economic' | 'protocol' +type ProposalType = 'governance' | 'economic' | 'protocol' | 'project' type DaoProposalChange = { key: string; value: string; current: string } type DaoProposalChangeSets = DaoProposalChange[] | DaoProposalChange[][] @@ -1448,14 +1543,27 @@ interface ProposalCreateOptions { title: string description: string options?: string[] - changes: DaoProposalChangeSets + /** Parameter proposals carry changes; project proposals carry milestones instead. */ + changes?: DaoProposalChangeSets + project?: { milestones: ProjectMilestoneInput[]; address: string } gracePeriodMs: number startTime?: number expectedBalanceDelta?: (receipt: TxReceipt) => bigint } +interface ProjectMilestoneInput { + title: string + description: string + deliverable: string + duration: number + costUsdStr: string + penaltyUsdStr: string + bonusUsdStr: string +} + function proposalPayloadKey(type: ProposalType): 'governance' | 'economic' | 'protocol' { - return type + // Only called for parameter proposals; projects take the `project` payload instead. + return type as 'governance' | 'economic' | 'protocol' } function asChangeSets(changes: DaoProposalChangeSets): DaoProposalChange[][] { @@ -1465,6 +1573,11 @@ function asChangeSets(changes: DaoProposalChangeSets): DaoProposalChange[][] { async function createDaoProposal(opts: ProposalCreateOptions): Promise { return withProposalCreateLock(async () => { const proposalType = opts.proposalType ?? 'governance' + // `project` and `changes` are both optional on the options type, so this is what stops a + // project proposal silently going out with `project: undefined`. + if (proposalType === 'project' && (opts.project?.milestones == null || opts.project.address == null)) { + throw new Error('createDaoProposal: a project proposal requires project.milestones and project.address') + } const proposalNumber = await nextProposalNumber() const tx: any = { type: 'dao_proposal_create', @@ -1478,8 +1591,8 @@ async function createDaoProposal(opts: ProposalCreateOptions): Promise { description: opts.description, options: opts.options ?? ['no', 'yes'], gracePeriod: opts.gracePeriodMs, - [proposalPayloadKey(proposalType)]: { changes: asChangeSets(opts.changes) }, - timestamp: Date.now(), + // Projects carry milestones and never reach the change-set validator. + ...(proposalType === 'project' ? { project: opts.project } : { [proposalPayloadKey(proposalType)]: { changes: asChangeSets(opts.changes ?? []) } }), } if (opts.startTime !== undefined) tx.startTime = opts.startTime await injectAndAssert(tx, opts.proposer, { expectedBalanceDelta: opts.expectedBalanceDelta }) @@ -1513,7 +1626,6 @@ async function committeeAcceptToVoting( from: committee[i].address, proposalId: daoProposalId(proposalNumber), vote: 'accept', - timestamp: Date.now(), }, committee[i], ) @@ -1526,7 +1638,6 @@ async function committeeAcceptToVoting( networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalNumber), - timestamp: Date.now(), }, actor, ) @@ -1549,7 +1660,6 @@ async function castVote( proposalId: daoProposalId(proposalNumber), weights, spend: libToWei(spendLib), - timestamp: Date.now(), }, voter, { expectedBalanceDelta }, @@ -1565,7 +1675,6 @@ async function finalizeVote(proposalNumber: number, actor: TestAccount, sleepBuf networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalNumber), - timestamp: Date.now(), }, actor, ) @@ -1587,7 +1696,7 @@ async function attemptApplyAndVerify( verifyParameterEffect: (receipt: any) => Promise, ): Promise { const result = await injectAndAssert( - { type: 'dao_apply_parameters', networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalNumber), timestamp: Date.now() }, + { type: 'dao_apply_parameters', networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalNumber) }, actor, ) assert((await getProposal(proposalNumber)).status === 'applied', `Expected proposal #${proposalNumber} status 'applied'`) @@ -1695,7 +1804,7 @@ async function applyAcceptedProposal( for (const voter of eligibleVoters.slice(0, votesNeeded)) { await injectAndAssert( - { type: 'dao_unapply_parameters', networkId: currentNetworkId, from: voter.address, proposalId: daoProposalId(proposalNumber), timestamp: Date.now() }, + { type: 'dao_unapply_parameters', networkId: currentNetworkId, from: voter.address, proposalId: daoProposalId(proposalNumber) }, voter, ) } @@ -1777,7 +1886,6 @@ async function claimAndAssertRewards(proposalNumber: number, claimers: TestAccou networkId: currentNetworkId, from: claimant.address, proposalId: daoProposalId(proposalNumber), - timestamp: Date.now(), }, claimant, { expectedBalanceDelta: receipt => asBigInt(receipt.additionalInfo.reward) - asBigInt(receipt.transactionFee ?? 0n) }, @@ -2142,6 +2250,7 @@ async function main(): Promise { sc19CancelReview: getProposalN('sc19CancelReview'), sc20Lifecycle: getProposalN('sc20Lifecycle'), sc20Emergency: getProposalN('sc20Emergency'), + sc21Project: getProposalN('sc21Project'), } // Register scenario labels for proposals restored from saved state (--no-start/--step reruns), // not just freshly-created ones (those go through setProposalN below). @@ -2208,8 +2317,9 @@ async function main(): Promise { // dao_vote_result being submitted late by whoever happens to call them. const LATE_TRANSITION_DELAY_MS = 20_000 // Sleeps well past targetMs, then injects tx — used by Scenario 18 to submit - // dao_committee_result/dao_vote_result deliberately late. Takes a builder, not a pre-built tx, - // so `timestamp: Date.now()` inside it is captured after the sleep, not before. + // dao_committee_result/dao_vote_result deliberately late. The lateness comes from the sleep: the + // transaction is stamped at injection, after it. Takes a builder rather than a pre-built tx so + // anything else time-dependent would also be captured after the wait. async function injectLate(targetMs: number, label: string, buildTx: () => T, actor: TestAccount): Promise { await sleepUntilTimestamp(targetMs, `${label} (deliberately late)`, SLEEP_BUFFER_MS + LATE_TRANSITION_DELAY_MS) return injectAndAssert(buildTx(), actor) @@ -2304,7 +2414,6 @@ async function main(): Promise { from: committee[0].address, proposalId: daoProposalId(proposalN.sc1), vote: 'accept', - timestamp: Date.now(), }, committee[0], ) @@ -2325,7 +2434,6 @@ async function main(): Promise { from: committee[1].address, proposalId: daoProposalId(proposalN.sc1), vote: 'accept', - timestamp: Date.now(), }, committee[1], ) @@ -2346,7 +2454,6 @@ async function main(): Promise { from: committee[2].address, proposalId: daoProposalId(proposalN.sc1), vote: 'accept', - timestamp: Date.now(), }, committee[2], ) @@ -2370,7 +2477,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, proposer, ) @@ -2404,7 +2510,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, proposer, ) @@ -2438,7 +2543,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, proposer, 'did not vote', @@ -2469,7 +2573,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, voter1, 'already claimed', @@ -2503,7 +2606,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter3.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, voter3, { expectedBalanceDelta: receipt => -asBigInt(receipt.transactionFee ?? 0n) }, @@ -2519,7 +2621,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter3.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, voter3, 'Nothing left to burn', @@ -2566,7 +2667,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc2), vote: 'withhold', withheldReason: 'Test withhold', - timestamp: Date.now(), }, committee[i], ) @@ -2584,7 +2684,7 @@ async function main(): Promise { const proposalBefore = await getProposal(proposalN.sc2) await sleepUntilTimestamp(proposalBefore.reviewEnd, 'reviewEnd', SLEEP_BUFFER_MS) await injectAndAssert( - { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc2), timestamp: Date.now() }, + { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc2) }, proposer, ) const proposal = await getProposal(proposalN.sc2) @@ -2621,7 +2721,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc2), - timestamp: Date.now(), }, proposer, 'withheld', @@ -2672,7 +2771,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc3), - timestamp: Date.now(), }, proposer, ) @@ -2689,7 +2787,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc3), - timestamp: Date.now(), }, proposer, 'not in review status', @@ -2707,7 +2804,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc3), - timestamp: Date.now(), }, proposer, ) @@ -2746,7 +2842,6 @@ async function main(): Promise { governance: { changes: [[{ key: 'pctBurned', value: '70', current: '50' }]], }, - timestamp: Date.now(), }), voter1, 'committee', @@ -2776,7 +2871,6 @@ async function main(): Promise { [{ key: 'pctBurned', value: '65', current: '50' }], ], }, - timestamp: Date.now(), }), committee[0], 'emergency', @@ -2817,7 +2911,6 @@ async function main(): Promise { from: committee[i].address, proposalId: daoProposalId(proposalN.sc4), vote: 'accept', - timestamp: Date.now(), }, committee[i], ) @@ -2872,7 +2965,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc4), - timestamp: Date.now(), }, voter1, 'committee member', @@ -2907,7 +2999,6 @@ async function main(): Promise { networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalN.sc4), - timestamp: Date.now(), }, actor, 'Nothing left to burn', @@ -2946,7 +3037,6 @@ async function main(): Promise { from: voter1.address, proposalId: daoProposalId(proposalN.sc5), vote: 'accept', - timestamp: Date.now(), }, voter1, 'committee', @@ -2966,7 +3056,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc5), weights: [1, 0], spend: libToWei(minVoteSpendLib), - timestamp: Date.now(), }, voter1, 'voting', @@ -2983,7 +3072,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc5), - timestamp: Date.now(), }, proposer, 'voting', @@ -3049,7 +3137,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter9.address, proposalId: daoProposalId(proposalN.sc6), - timestamp: Date.now(), }, voter9, ) @@ -3072,7 +3159,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter10.address, proposalId: daoProposalId(proposalN.sc6), - timestamp: Date.now(), }, voter10, ) @@ -3092,7 +3178,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer4.address, proposalId: daoProposalId(proposalN.sc6), - timestamp: Date.now(), }, proposer4, 'accepted status', @@ -3219,7 +3304,6 @@ async function main(): Promise { options: ['no', 'yes'], gracePeriod: graceDurationMs, governance: { changes: [[{ key: 'nodeRewardAmountUsdStr', value: '1.5', current: '1.0' }]] }, - timestamp: Date.now(), }), proposer6, 'governance parameters', @@ -3243,7 +3327,6 @@ async function main(): Promise { options: ['no', 'yes'], gracePeriod: graceDurationMs, protocol: { changes: [[{ key: 'nodeRewardAmountUsdStr', value: '1.5', current: '1.25' }]] }, - timestamp: Date.now(), }), proposer7, 'protocol parameters', @@ -3394,7 +3477,6 @@ async function main(): Promise { { key: 'countEndpointStart', value: '-3', current: '-1' }, ]], }, - timestamp: Date.now(), }), proposer7, 'overlapping targets', @@ -3504,7 +3586,6 @@ async function main(): Promise { from: committee[1].address, proposalId: daoProposalId(proposalN.sc9), vote: 'accept', - timestamp: Date.now(), }, committee[1], 'has not started', @@ -3523,7 +3604,6 @@ async function main(): Promise { from: committee[1].address, proposalId: daoProposalId(proposalN.sc9), vote: 'accept', - timestamp: Date.now(), }, committee[1], ) @@ -3560,7 +3640,7 @@ async function main(): Promise { '10.2 Committee member switches accept → withhold', async () => { await injectAndAssert( - { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc10), vote: 'accept', timestamp: Date.now() }, + { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc10), vote: 'accept' }, committee[3], ) await injectAndAssert( @@ -3571,7 +3651,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc10), vote: 'withhold', withheldReason: 'Need more analysis', - timestamp: Date.now(), }, committee[3], ) @@ -3593,7 +3672,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc10), vote: 'withhold', withheldReason: 'Committee withhold regression test', - timestamp: Date.now(), }, committee[i], ) @@ -3610,7 +3688,7 @@ async function main(): Promise { const proposalBefore = await getProposal(proposalN.sc10) await sleepUntilTimestamp(proposalBefore.reviewEnd, 'reviewEnd', SLEEP_BUFFER_MS) await injectAndAssert( - { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer3.address, proposalId: daoProposalId(proposalN.sc10), timestamp: Date.now() }, + { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer3.address, proposalId: daoProposalId(proposalN.sc10) }, proposer3, ) const proposal = await getProposal(proposalN.sc10) @@ -3647,7 +3725,7 @@ async function main(): Promise { async () => { for (const i of [2, 4]) { await injectAndAssert( - { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc11), vote: 'accept', timestamp: Date.now() }, + { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc11), vote: 'accept' }, committee[i], ) } @@ -3660,7 +3738,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc11), vote: 'withhold', withheldReason: 'Tie regression test', - timestamp: Date.now(), }, committee[i], ) @@ -3675,7 +3752,7 @@ async function main(): Promise { const proposalBefore = await getProposal(proposalN.sc11) await sleepUntilTimestamp(proposalBefore.reviewEnd, 'reviewEnd', SLEEP_BUFFER_MS) await injectAndAssert( - { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer9.address, proposalId: daoProposalId(proposalN.sc11), timestamp: Date.now() }, + { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer9.address, proposalId: daoProposalId(proposalN.sc11) }, proposer9, ) const proposal = await getProposal(proposalN.sc11) @@ -3778,7 +3855,6 @@ async function main(): Promise { options: ['no', 'yes', '2', '3', '4', '5', '6', '7', '8', '9', '10'], gracePeriod: graceDurationMs, governance: { changes: [[{ key: 'pctBurned', value: '59', current: '50' }]] }, - timestamp: Date.now(), }), proposer10, ) @@ -3813,7 +3889,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc13), weights: c.weights, spend: c.spend, - timestamp: Date.now(), }, voter12, c.reason, @@ -3829,7 +3904,7 @@ async function main(): Promise { const matchesRejectedChange = (change: any) => String(change?.appData?.dao?.pctBurned) === '58' const matchingChangesBefore = (await getProposalListOfChanges()).filter(matchesRejectedChange).length await injectExpectReject( - { type: 'dao_apply_parameters', networkId: currentNetworkId, from: proposer10.address, proposalId: daoProposalId(proposalN.sc13), timestamp: Date.now() }, + { type: 'dao_apply_parameters', networkId: currentNetworkId, from: proposer10.address, proposalId: daoProposalId(proposalN.sc13) }, proposer10, 'Grace period', ) @@ -3896,7 +3971,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc14EmergencyWithhold), vote: 'withhold', withheldReason: 'Emergency withhold E2E test', - timestamp: Date.now(), }, committee[i], ) @@ -3922,7 +3996,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc14EmergencyWithhold), - timestamp: Date.now(), }, voter13, 'current: withheld', @@ -3938,7 +4011,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc14EmergencyWithhold), - timestamp: Date.now(), }, voter13, 'already burned', @@ -3976,7 +4048,6 @@ async function main(): Promise { from: committee[4].address, proposalId: daoProposalId(proposalN.sc15A), vote: 'accept', - timestamp: Date.now(), }, committee[4], 'has not started', @@ -3997,7 +4068,6 @@ async function main(): Promise { from: committee[4].address, proposalId: daoProposalId(proposalN.sc15A), vote: 'accept', - timestamp: Date.now(), }, committee[4], 'review period has ended', @@ -4014,7 +4084,6 @@ async function main(): Promise { from: committee[4].address, proposalId: daoProposalId(proposalN.sc15A), vote: 'withhold', - timestamp: Date.now(), }, committee[4], 'withheldReason', @@ -4027,7 +4096,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc15A), vote: 'withhold', withheldReason: '', - timestamp: Date.now(), }, committee[4], 'withheldReason', @@ -4130,7 +4198,6 @@ async function main(): Promise { options: c.options, gracePeriod: c.gracePeriod, governance: { changes: c.useRawChanges ? c.changes : asChangeSets(c.changes) }, - timestamp: Date.now(), }), c.account, c.reason, @@ -4158,7 +4225,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter14.address, proposalId: daoProposalId(proposalN.sc15B), - timestamp: Date.now(), }, voter14, 'Claim period has not ended yet', @@ -4176,7 +4242,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc15B), - timestamp: Date.now(), }, voter13, 'Claim period has ended', @@ -4204,7 +4269,6 @@ async function main(): Promise { governance: { changes: [[{ key: 'committeeAddresses', value: JSON.stringify(invalidCommitteeAddresses), current: JSON.stringify(daoParams.committeeAddresses) }]], }, - timestamp: Date.now(), }), proposer3, 'committeeAddresses must contain between', @@ -4245,7 +4309,7 @@ async function main(): Promise { '16.2 Non-decisive emergency committee split leaves status review', async () => { await injectAndAssert( - { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[4].address, proposalId: daoProposalId(proposalN.sc16EmergencyTimeout), vote: 'accept', timestamp: Date.now() }, + { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[4].address, proposalId: daoProposalId(proposalN.sc16EmergencyTimeout), vote: 'accept' }, committee[4], ) await injectAndAssert( @@ -4256,7 +4320,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc16EmergencyTimeout), vote: 'withhold', withheldReason: 'Emergency timeout split test', - timestamp: Date.now(), }, committee[2], ) @@ -4270,7 +4333,7 @@ async function main(): Promise { const proposalBefore = await getProposal(proposalN.sc16EmergencyTimeout) await sleepUntilTimestamp(proposalBefore.reviewEnd, 'reviewEnd', SLEEP_BUFFER_MS) await injectAndAssert( - { type: 'dao_committee_result', networkId: currentNetworkId, from: voter14.address, proposalId: daoProposalId(proposalN.sc16EmergencyTimeout), timestamp: Date.now() }, + { type: 'dao_committee_result', networkId: currentNetworkId, from: voter14.address, proposalId: daoProposalId(proposalN.sc16EmergencyTimeout) }, voter14, ) const proposal = await getProposal(proposalN.sc16EmergencyTimeout) @@ -4288,7 +4351,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc16EmergencyTimeout), - timestamp: Date.now(), }, voter16, 'accepted status', @@ -4344,7 +4406,6 @@ async function main(): Promise { from: committee[i].address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), vote: 'accept', - timestamp: Date.now(), }, committee[i], ) @@ -4379,7 +4440,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter5.address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), - timestamp: Date.now(), }, voter5, 'committee member', @@ -4400,7 +4460,6 @@ async function main(): Promise { networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), - timestamp: Date.now(), }, committee[i], { expectedBalanceDelta: receipt => -asBigInt(receipt.transactionFee ?? 0n) }, @@ -4441,7 +4500,6 @@ async function main(): Promise { networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), - timestamp: Date.now(), }, committee[0], 'already submitted', @@ -4461,7 +4519,6 @@ async function main(): Promise { networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), - timestamp: Date.now(), }, committee[1], 'not in applied status', @@ -4514,7 +4571,7 @@ async function main(): Promise { async () => { for (const i of [0, 1, 2]) { await injectAndAssert( - { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc18LateTransition), vote: 'accept', timestamp: Date.now() }, + { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc18LateTransition), vote: 'accept' }, committee[i], ) } @@ -4523,7 +4580,7 @@ async function main(): Promise { await injectLate( reviewEnd, 'reviewEnd', - () => ({ type: 'dao_committee_result', networkId: currentNetworkId, from: proposer5.address, proposalId: daoProposalId(proposalN.sc18LateTransition), timestamp: Date.now() }), + () => ({ type: 'dao_committee_result', networkId: currentNetworkId, from: proposer5.address, proposalId: daoProposalId(proposalN.sc18LateTransition) }), proposer5, ) const proposal = await getProposal(proposalN.sc18LateTransition) @@ -4552,7 +4609,7 @@ async function main(): Promise { await injectLate( votingEnd, 'votingEnd', - () => ({ type: 'dao_vote_result', networkId: currentNetworkId, from: proposer5.address, proposalId: daoProposalId(proposalN.sc18LateTransition), timestamp: Date.now() }), + () => ({ type: 'dao_vote_result', networkId: currentNetworkId, from: proposer5.address, proposalId: daoProposalId(proposalN.sc18LateTransition) }), proposer5, ) const proposal = await getProposal(proposalN.sc18LateTransition) @@ -4659,7 +4716,7 @@ async function main(): Promise { assert(Date.now() < proposalBefore.startTime, 'Expected sc19CancelReview to still be before its startTime at cancel time') const { receipt } = await injectAndAssert( - { type: 'dao_cancel', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc19CancelReview), timestamp: Date.now() }, + { type: 'dao_cancel', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc19CancelReview) }, committee[3], ) assert(receipt.additionalInfo?.proposalStatus === 'canceled', `Expected receipt proposalStatus 'canceled', got ${JSON.stringify(receipt.additionalInfo)}`) @@ -4675,7 +4732,7 @@ async function main(): Promise { '19.2b dao_claim_reward rejected on the canceled sc19CancelReview (no voters, empty pool)', async () => { await injectExpectReject( - { type: 'dao_claim_reward', networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc19CancelReview), timestamp: Date.now() }, + { type: 'dao_claim_reward', networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc19CancelReview) }, voter1, 'did not vote', ) @@ -4691,13 +4748,13 @@ async function main(): Promise { // expiring while an earlier proposal's longer flow is still running. for (const i of [0, 1, 2]) { await injectAndAssert( - { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc19CancelVoting), vote: 'accept', timestamp: Date.now() }, + { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc19CancelVoting), vote: 'accept' }, committee[i], ) } for (const i of [1, 2, 3]) { await injectAndAssert( - { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc19CancelAccepted), vote: 'accept', timestamp: Date.now() }, + { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc19CancelAccepted), vote: 'accept' }, committee[i], ) } @@ -4707,7 +4764,7 @@ async function main(): Promise { // any other committee vote, so they must be cast here too, not deferred to a later step. for (const i of [1, 2, 3]) { await injectAndAssert( - { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc19CancelApplied), vote: 'accept', timestamp: Date.now() }, + { type: 'dao_committee_vote', networkId: currentNetworkId, from: committee[i].address, proposalId: daoProposalId(proposalN.sc19CancelApplied), vote: 'accept' }, committee[i], ) } @@ -4720,14 +4777,14 @@ async function main(): Promise { await sleepUntilTimestamp(acceptedBeforeResult.reviewEnd, 'reviewEnd', SLEEP_BUFFER_MS) await injectAndAssert( - { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer8.address, proposalId: daoProposalId(proposalN.sc19CancelVoting), timestamp: Date.now() }, + { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer8.address, proposalId: daoProposalId(proposalN.sc19CancelVoting) }, proposer8, ) const voting = await getProposal(proposalN.sc19CancelVoting) assert(voting.status === 'voting', `Expected status 'voting', got '${voting.status}'`) await injectAndAssert( - { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer9.address, proposalId: daoProposalId(proposalN.sc19CancelAccepted), timestamp: Date.now() }, + { type: 'dao_committee_result', networkId: currentNetworkId, from: proposer9.address, proposalId: daoProposalId(proposalN.sc19CancelAccepted) }, proposer9, ) const votingAccepted = await getProposal(proposalN.sc19CancelAccepted) @@ -4749,7 +4806,7 @@ async function main(): Promise { '19.4b dao_cancel rejected from a non-committee sender while sc19CancelVoting is voting', async () => { await injectExpectReject( - { type: 'dao_cancel', networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc19CancelVoting), timestamp: Date.now() }, + { type: 'dao_cancel', networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc19CancelVoting) }, voter1, 'committee member', ) @@ -4764,7 +4821,7 @@ async function main(): Promise { const expectedBurn = (poolBeforeCancel * BigInt(Math.round(pctBurned))) / 100n const { receipt } = await injectAndAssert( - { type: 'dao_cancel', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc19CancelVoting), timestamp: Date.now() }, + { type: 'dao_cancel', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc19CancelVoting) }, committee[3], ) assert(receipt.additionalInfo?.proposalStatus === 'canceled', `Expected receipt proposalStatus 'canceled', got ${JSON.stringify(receipt.additionalInfo)}`) @@ -4786,7 +4843,7 @@ async function main(): Promise { // flow for sc19CancelApplied below — sc19CancelVoting's own claimEnd is fixed relative to // its cancel timestamp (19.5), not to how long unrelated later steps take to run. const { receipt } = await injectAndAssert( - { type: 'dao_claim_reward', networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc19CancelVoting), timestamp: Date.now() }, + { type: 'dao_claim_reward', networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc19CancelVoting) }, voter13, ) const reward = asBigInt(receipt.additionalInfo.reward) @@ -4799,7 +4856,7 @@ async function main(): Promise { '19.7 dao_cancel from committee cancels sc19CancelAccepted (from accepted), no additional burn', async () => { const { receipt } = await injectAndAssert( - { type: 'dao_cancel', networkId: currentNetworkId, from: committee[4].address, proposalId: daoProposalId(proposalN.sc19CancelAccepted), timestamp: Date.now() }, + { type: 'dao_cancel', networkId: currentNetworkId, from: committee[4].address, proposalId: daoProposalId(proposalN.sc19CancelAccepted) }, committee[4], ) assert(receipt.additionalInfo?.proposalStatus === 'canceled', `Expected receipt proposalStatus 'canceled', got ${JSON.stringify(receipt.additionalInfo)}`) @@ -4831,7 +4888,7 @@ async function main(): Promise { '19.9 dao_cancel rejected against sc19CancelApplied (already applied)', async () => { await injectExpectReject( - { type: 'dao_cancel', networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc19CancelApplied), timestamp: Date.now() }, + { type: 'dao_cancel', networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc19CancelApplied) }, committee[1], 'voting or accepted', ) @@ -4844,7 +4901,7 @@ async function main(): Promise { await sleepUntilTimestamp(proposalBefore.claimEnd, 'claimEnd', SLEEP_BUFFER_MS) const remainingBeforeBurn = asBigInt(proposalBefore.voterRewardPool) - asBigInt(proposalBefore.claimedReward) const { receipt } = await injectAndAssert( - { type: 'dao_burn_reward', networkId: currentNetworkId, from: voter14.address, proposalId: daoProposalId(proposalN.sc19CancelAccepted), timestamp: Date.now() }, + { type: 'dao_burn_reward', networkId: currentNetworkId, from: voter14.address, proposalId: daoProposalId(proposalN.sc19CancelAccepted) }, voter14, ) assert(asBigInt(receipt.additionalInfo.burned) === remainingBeforeBurn, `Expected burned ${remainingBeforeBurn}, got ${receipt.additionalInfo.burned}`) @@ -4856,18 +4913,18 @@ async function main(): Promise { '19.11 Sanity: dao_vote/dao_vote_result reject sc19CancelVoting, dao_apply_parameters rejects sc19CancelAccepted', async () => { await injectExpectReject( - { type: 'dao_vote', networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc19CancelVoting), weights: [0, 1], spend: libToWei(minVoteSpendLib), timestamp: Date.now() }, + { type: 'dao_vote', networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc19CancelVoting), weights: [0, 1], spend: libToWei(minVoteSpendLib) }, voter13, 'voting', ) await injectExpectReject( - { type: 'dao_vote_result', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc19CancelVoting), timestamp: Date.now() }, + { type: 'dao_vote_result', networkId: currentNetworkId, from: committee[3].address, proposalId: daoProposalId(proposalN.sc19CancelVoting) }, committee[3], 'voting', ) const matchesRejectedChange = (change: any) => String(change?.appData?.dao?.pctBurned) === '68' await expectPreCrackRejectNoGlobalChange( - { type: 'dao_apply_parameters', networkId: currentNetworkId, from: committee[4].address, proposalId: daoProposalId(proposalN.sc19CancelAccepted), timestamp: Date.now() }, + { type: 'dao_apply_parameters', networkId: currentNetworkId, from: committee[4].address, proposalId: daoProposalId(proposalN.sc19CancelAccepted) }, committee[4], 'accepted status', proposalN.sc19CancelAccepted, @@ -4993,7 +5050,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter15.address, proposalId: daoProposalId(proposalN.sc20Lifecycle), - timestamp: Date.now(), }, voter15, { expectedBalanceDelta: receipt => asBigInt(receipt.additionalInfo.reward) - asBigInt(receipt.transactionFee ?? 0n) }, @@ -5034,10 +5090,524 @@ async function main(): Promise { ], } + + // ───────────────────────────────────────────────────────────────────────── + // Scenario 21 — project proposal lifecycle + // ───────────────────────────────────────────────────────────────────────── + // sequentialOnly: the milestone flow is a long chain of endorsements where each step depends on + // the previous one having committed, and it asserts on balances that only this scenario moves. + const MILESTONE_DURATION_MS = 60_000 + // A short duration for the milestone that must run *late*: late needs elapsed > 120% of planned, + // so a 60s plan would cost 72s of wall time to demonstrate. + const LATE_MILESTONE_DURATION_MS = 10_000 + const sc21Milestones = [ + // 1 — delivered early, claimed while executing: pays cost + bonus. + { title: 'Design', description: 'Design the thing', deliverable: 'A design doc', duration: MILESTONE_DURATION_MS, costUsdStr: '100', penaltyUsdStr: '20', bonusUsdStr: '10' }, + // 2 — terminated by the committee: escrow released, never claimable. + { title: 'Build', description: 'Build the thing', deliverable: 'A working thing', duration: MILESTONE_DURATION_MS, costUsdStr: '200', penaltyUsdStr: '40', bonusUsdStr: '20' }, + // 3 — delivered late, so the penalty comes off the cost and no bonus applies. A penalty must + // be strictly smaller than its cost, which is what keeps `paid > 0n` sound as the settled + // marker: no payout can be zero, so no milestone stays claimable after being paid. + { title: 'Polish', description: 'Polish the thing', deliverable: 'A shiny thing', duration: LATE_MILESTONE_DURATION_MS, costUsdStr: '50', penaltyUsdStr: '10', bonusUsdStr: '5' }, + // 4 — completed but deliberately left unclaimed until after dao_project_end, so the claim + // outliving 'executing' is actually exercised rather than assumed. + { title: 'Handover', description: 'Hand it over', deliverable: 'Docs and keys', duration: MILESTONE_DURATION_MS, costUsdStr: '80', penaltyUsdStr: '16', bonusUsdStr: '8' }, + ] + /** + * A project transaction against the Scenario 21 proposal. + * + * No timestamp: injectAndAssert and injectExpectReject stamp it at injection. What is left is the + * sender and whatever the transaction is actually about. + * + * `extra` cannot carry the fields this helper owns. Spreading it last would let a caller silently + * retarget the transaction at another proposal or sender; typing those keys as `never` makes the + * attempt a compile error instead, which is the version that tells you. + */ + const sc21Tx = (type: string, from: TestAccount, extra: Sc21TxExtra = {}): Record => ({ + type, + networkId: currentNetworkId, + from: from.address, + proposalId: daoProposalId(proposalN.sc21Project), + ...extra, + }) + + /** + * What a milestone is worth, derived from the fixture rather than restated. + * + * Each conversion is separate because the handler converts each amount separately — summing or + * subtracting in USD first truncates once instead of once per amount, and drifts by a wei. + * Deriving also means changing a fixture amount cannot leave an assertion quietly describing a + * milestone that no longer exists. + */ + const sc21Cost = (n: number, rateUsdStr: string): bigint => usdStrToLibWei(sc21Milestones[n - 1].costUsdStr, rateUsdStr) + const sc21Bonus = (n: number, rateUsdStr: string): bigint => usdStrToLibWei(sc21Milestones[n - 1].bonusUsdStr, rateUsdStr) + const sc21Penalty = (n: number, rateUsdStr: string): bigint => usdStrToLibWei(sc21Milestones[n - 1].penaltyUsdStr, rateUsdStr) + /** Early delivery, and the amount escrowed per milestone: cost + bonus. */ + const sc21EarlyPayout = (n: number, rateUsdStr: string): bigint => sc21Cost(n, rateUsdStr) + sc21Bonus(n, rateUsdStr) + /** Late delivery: no bonus, penalty off the cost. */ + const sc21LatePayout = (n: number, rateUsdStr: string): bigint => sc21Cost(n, rateUsdStr) - sc21Penalty(n, rateUsdStr) + + /** + * Drives a milestone time to commitment: the first signer proposes it, the rest endorse. + * + * The proposer counts as endorsement #1, which is why only the first carries `proposedTime` — a + * rule that was previously implied by an `i === 0` check repeated at each call site. + */ + const sc21EndorseMilestoneTime = async (type: 'dao_project_milestone_start' | 'dao_project_milestone_end', proposedTime: number, signers: TestAccount[]): Promise => { + for (const [i, signer] of signers.entries()) { + await injectAndAssert(sc21Tx(type, signer, i === 0 ? { proposedTime } : {}), signer) + } + } + + const sc21: ScenarioDef = { + num: 21, + name: 'Scenario 21 — project proposal lifecycle', + sequentialOnly: true, + setupSteps: [ + [ + '21.1 Create the project proposal (contractor = voter16)', + async () => { + setProposalN('sc21Project', await createDaoProposal({ + proposer: proposer3, + proposalType: 'project', + title: 'Project — four milestones', + description: 'Full project lifecycle: start, milestones, claims, end, reclaim', + project: { milestones: sc21Milestones, address: voter16.address }, + gracePeriodMs: graceDurationMs, + })) + saveCurrentRunState() + }, + ], + ], + bodySteps: [ + [ + '21.2 Reject an emergency project and a three-option project ballot', + async () => { + const base = { + type: 'dao_proposal_create', + networkId: currentNetworkId, + from: proposer3.address, + metaId: daoMetaId(), + proposalType: 'project', + title: 'Invalid project', + description: 'Should be rejected at creation', + gracePeriod: graceDurationMs, + project: { milestones: sc21Milestones, address: voter16.address }, + } + // Projects mint, so they must always face a community vote. + await expectProposalCreateReject( + n => ({ ...base, from: committee[0].address, emergency: true, options: ['no', 'yes'], proposalId: daoProposalId(n) }), + committee[0], + 'cannot be emergency', + ) + // A project has one flat milestone array, so a third option would select nothing. + await expectProposalCreateReject( + n => ({ ...base, emergency: false, options: ['no', 'a', 'b'], proposalId: daoProposalId(n) }), + proposer3, + 'exactly 2 entries', + ) + // A penalty must reduce a payment, not erase it. This also rules out a zero cost, which + // keeps every payout positive and `paid > 0n` sound as the settled marker. + for (const bad of [ + { costUsdStr: '50', penaltyUsdStr: '50' }, + { costUsdStr: '50', penaltyUsdStr: '60' }, + { costUsdStr: '0', penaltyUsdStr: '0' }, + ]) { + await expectProposalCreateReject( + n => ({ + ...base, + emergency: false, + options: ['no', 'yes'], + project: { milestones: [{ ...sc21Milestones[0], ...bad }], address: voter16.address }, + proposalId: daoProposalId(n), + }), + proposer3, + 'must be less than', + ) + } + }, + ], + [ + '21.3 Reject dao_project_start before the vote, then drive the proposal to accepted', + async () => { + await injectExpectReject( + sc21Tx('dao_project_start', committee[0]), + committee[0], + 'not in accepted status', + ) + await committeeAcceptToVoting(proposalN.sc21Project, committee[0], committee, SLEEP_BUFFER_MS) + // Two voters: one claims mid-project, the other's share is what the burn later collects. + await castVote(proposalN.sc21Project, voter15, [0, 1], minVoteSpendLib) + await castVote(proposalN.sc21Project, voter14, [0, 1], minVoteSpendLib) + await finalizeVote(proposalN.sc21Project, committee[0], SLEEP_BUFFER_MS) + const proposal = await getProposal(proposalN.sc21Project) + assert(proposal.status === 'accepted', `Expected accepted, got ${proposal.status}`) + }, + ], + [ + '21.4 Reject dao_project_start from a non-committee sender, then start and mint', + async () => { + const proposal = await getProposal(proposalN.sc21Project) + await sleepUntilTimestamp(proposal.applyEligibleAt, 'applyEligibleAt', SLEEP_BUFFER_MS) + // Minting is committee-only; the proposer has no special standing here. + await injectExpectReject( + sc21Tx('dao_project_start', proposer3), + proposer3, + 'Only a committee member', + ) + const { receipt } = await injectAndAssert( + sc21Tx('dao_project_start', committee[0]), + committee[0], + ) + assert(receipt.additionalInfo?.proposalStatus === 'executing', `Expected executing, got ${JSON.stringify(receipt.additionalInfo)}`) + + // Mint must be sum(cost + bonus) across the four milestones, with penalties excluded, and + // converted one amount at a time exactly as projectMintAmountWei does. + const view = await getProject(proposalN.sc21Project) + // Converted at the project's own stored rate, which also asserts the rate was snapshotted: + // if rateUsdStr were left at its '0' default this conversion would throw or mismatch. + const expectedMint = usdSumToLibWei(view.project.rateUsdStr, ...sc21Milestones.flatMap(m => [m.costUsdStr, m.bonusUsdStr])) + assert(asBigInt(view.project.balance) === expectedMint, `Expected mint ${expectedMint}, got ${view.project.balance}`) + assert(view.project.rateUsdStr === stabilityFactorStr, `Expected rate ${stabilityFactorStr}, got ${view.project.rateUsdStr}`) + assert(view.project.milestones.every(m => m.status === 'pending'), 'Every milestone should start pending') + assert(view.logCount >= 1, 'Project start should have written a log entry') + }, + ], + [ + '21.4b Voter rewards are claimable while the project is executing', + async () => { + // The exact case D9 exists for: the project has just left 'accepted' for 'executing' and + // never returns, so without that status in the allowlist this claim is rejected and the + // pool is stranded. It has to happen here rather than at the end of the scenario — the + // claim window is 150s from voting end, while the project lifecycle runs for minutes. + const proposal = await getProposal(proposalN.sc21Project) + assert(proposal.status === 'executing', `Expected executing, got ${proposal.status}`) + await claimAndAssertRewards(proposalN.sc21Project, [voter15]) + }, + ], + [ + '21.5 Milestone boundaries still bind on the transactions that name one', + async () => { + // Start and end no longer carry a milestone number — the server derives the next pending + // and the executing one — so ordering is enforced by the derivation itself. The 1-based + // boundary checks now belong to claim and terminate, which the policy says must name one. + for (const milestoneNumber of [0, sc21Milestones.length + 1]) { + await injectExpectReject( + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber }), + voter16, + milestoneNumber === 0 ? 'positive integer' : 'outside the range', + ) + } + }, + ], + [ + '21.6 Contractor proposes milestone 1 start; two committee endorsements commit it', + async () => { + const startTime = Date.now() + await injectAndAssert( + sc21Tx('dao_project_milestone_start', voter16, { proposedTime: startTime }), + voter16, + ) + // The contractor holds slot 0 and may not endorse their own proposal. + await injectExpectReject( + sc21Tx('dao_project_milestone_start', voter16), + voter16, + 'not endorse', + ) + // Write-once: nobody may replace a proposed time, committee or contractor. Without this a + // re-proposal landing mid-flight would convert an endorsement of one time into another's, + // and the contractor could reset the count each time the committee neared agreement. + await injectExpectReject( + sc21Tx('dao_project_milestone_start', committee[2], { proposedTime: startTime - 5_000 }), + committee[2], + 'already been proposed', + ) + await injectExpectReject( + sc21Tx('dao_project_milestone_start', voter16, { proposedTime: startTime - 5_000 }), + voter16, + 'already been proposed', + ) + const stillPending = await getProject(proposalN.sc21Project) + assert( + Number(stillPending.project.milestones[0].proposedTime) === startTime, + `Rejected re-proposals must leave the original proposedTime ${startTime}, got ${stillPending.project.milestones[0].proposedTime}`, + ) + await injectAndAssert( + sc21Tx('dao_project_milestone_start', committee[0]), + committee[0], + ) + // Two of three so far — still pending. + const partway = await getProject(proposalN.sc21Project) + assert(partway.project.milestones[0].status === 'pending', 'Milestone should not commit on two endorsements') + + await injectAndAssert( + sc21Tx('dao_project_milestone_start', committee[1]), + committee[1], + ) + const milestone = await waitForMilestoneStatus(proposalN.sc21Project, 1, 'executing') + assert(Number(milestone.startTime) === startTime, `Expected startTime ${startTime}, got ${milestone.startTime}`) + // Endorsement state is cleared on commit so it cannot carry into the end question. + assert((milestone.endorsedTime ?? []).length === 0, 'endorsedTime should be cleared on commit') + }, + ], + [ + '21.6b Contractor address proposals and endorsements follow the policy', + async () => { + // Deliberately never reaches three endorsements: committing would replace the contractor + // mid-scenario and break every later claim. + const addressA = voter13.address + const addressB = voter14.address + + await injectAndAssert( + sc21Tx('dao_project_change_address', committee[0], { proposedAddress: addressA }), + committee[0], + ) + let view = await getProject(proposalN.sc21Project) + assert(view.project.proposedAddress === addressA, `Expected ${addressA} pending, got ${view.project.proposedAddress}`) + + // Policy line 352: called without an address, it endorses whatever is pending. + await injectAndAssert( + sc21Tx('dao_project_change_address', committee[1]), + committee[1], + ) + view = await getProject(proposalN.sc21Project) + assert(view.project.endorsedAddress.length === 2, `Expected two endorsements of ${addressA}, got ${view.project.endorsedAddress.length}`) + + // Policy line 353: called with an address, it re-proposes and resets the count to zero. + await injectAndAssert( + sc21Tx('dao_project_change_address', committee[2], { proposedAddress: addressB }), + committee[2], + ) + view = await getProject(proposalN.sc21Project) + assert(view.project.proposedAddress === addressB, `Expected ${addressB} pending after the re-proposal, got ${view.project.proposedAddress}`) + assert(view.project.endorsedAddress.length === 1, `Expected the re-proposal to reset to one endorsement, got ${view.project.endorsedAddress.length}`) + + // A member cannot endorse the same pending value twice. + await injectExpectReject( + sc21Tx('dao_project_change_address', committee[2]), + committee[2], + 'already endorsed', + ) + // Only committee members may take part, and the contractor is not one of them here. + await injectExpectReject( + sc21Tx('dao_project_change_address', voter16), + voter16, + 'Only a committee member', + ) + view = await getProject(proposalN.sc21Project) + assert(view.project.address === voter16.address, 'The contractor address must not change without three endorsements') + }, + ], + [ + '21.7 End milestone 1 early and claim cost + bonus', + async () => { + const before = await getProject(proposalN.sc21Project) + const startedAt = Number(before.project.milestones[0].startTime) + // Well inside the 20% early band for a 60s planned duration. + const endTime = startedAt + 1_000 + + await sc21EndorseMilestoneTime('dao_project_milestone_end', endTime, [voter16, committee[0], committee[1]]) + await waitForMilestoneStatus(proposalN.sc21Project, 1, 'completed') + + // Only the contractor is paid. + await injectExpectReject( + sc21Tx('dao_project_milestone_claim', committee[0], { milestoneNumber: 1 }), + committee[0], + 'Only the contractor', + ) + + const expectedPay = sc21EarlyPayout(1, before.project.rateUsdStr) + const { receipt } = await injectAndAssert( + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 1 }), + voter16, + { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, + ) + assert(receipt.additionalInfo?.deliverySpeed === 'early', `Expected early delivery, got ${receipt.additionalInfo?.deliverySpeed}`) + assert(asBigInt(receipt.additionalInfo.paidWei) === expectedPay, `Expected ${expectedPay}, got ${receipt.additionalInfo.paidWei}`) + + const after = await getProject(proposalN.sc21Project) + assert(asBigInt(after.project.balance) === asBigInt(before.project.balance) - expectedPay, 'Balance should drop by exactly the payout') + // paid records the amount and settles the milestone, which is what blocks a second claim. + assert(asBigInt(after.project.milestones[0].paid) === expectedPay, 'paid should record the amount') + await injectExpectReject( + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 1 }), + voter16, + 'already been claimed', + ) + }, + ], + [ + '21.8 Terminate milestone 2 and release its escrow', + async () => { + const before = await getProject(proposalN.sc21Project) + // Committee only, and a reason is required on every submission. + await injectExpectReject( + sc21Tx('dao_project_milestone_terminate', voter16, { milestoneNumber: 2, reason: 'contractor asks' }), + voter16, + 'Only a committee member', + ) + for (const member of [committee[0], committee[1]]) { + await injectAndAssert( + sc21Tx('dao_project_milestone_terminate', member, { milestoneNumber: 2, reason: 'scope dropped' }), + member, + ) + } + // The same member cannot vote twice to reach the threshold alone. + await injectExpectReject( + sc21Tx('dao_project_milestone_terminate', committee[0], { milestoneNumber: 2, reason: 'again' }), + committee[0], + 'already voted', + ) + await injectAndAssert( + sc21Tx('dao_project_milestone_terminate', committee[2], { milestoneNumber: 2, reason: 'scope dropped' }), + committee[2], + ) + await waitForMilestoneStatus(proposalN.sc21Project, 2, 'terminated') + + // Escrow released must mirror exactly what was minted for it: cost 200 + bonus 20, at the + // project's stored rate rather than the live one. + const after = await getProject(proposalN.sc21Project) + const released = sc21EarlyPayout(2, before.project.rateUsdStr) + assert(asBigInt(after.project.balance) === asBigInt(before.project.balance) - released, 'Terminating should release cost + bonus from the balance') + + // And the contractor cannot be paid for it. + await injectExpectReject( + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 2 }), + voter16, + 'not in completed status', + ) + }, + ], + [ + '21.9 Run milestone 3 late and claim cost minus penalty', + async () => { + const startTime = Date.now() + await sc21EndorseMilestoneTime('dao_project_milestone_start', startTime, [voter16, committee[0], committee[1]]) + await waitForMilestoneStatus(proposalN.sc21Project, 3, 'executing') + + // Past 120% of the planned 10s, so this is late. The proposed end must not be in the + // future relative to the tx, hence the sleep before submitting. + const endTime = startTime + Math.round(LATE_MILESTONE_DURATION_MS * 1.5) + await sleepUntilTimestamp(endTime, 'milestone 3 late end', SLEEP_BUFFER_MS) + await sc21EndorseMilestoneTime('dao_project_milestone_end', endTime, [voter16, committee[0], committee[1]]) + await waitForMilestoneStatus(proposalN.sc21Project, 3, 'completed') + + const before = await getProject(proposalN.sc21Project) + // Late, so no bonus applies and the penalty comes off the cost: 50 - 10. + const { receipt } = await injectAndAssert( + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 3 }), + voter16, + { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, + ) + assert(receipt.additionalInfo?.deliverySpeed === 'late', `Expected late delivery, got ${receipt.additionalInfo?.deliverySpeed}`) + const expectedLatePayout = sc21LatePayout(3, before.project.rateUsdStr) + assert( + asBigInt(receipt.additionalInfo.paidWei) === expectedLatePayout, + `Expected a late payout of ${expectedLatePayout}, got ${receipt.additionalInfo.paidWei}`, + ) + + const after = await getProject(proposalN.sc21Project) + assert( + asBigInt(after.project.balance) === asBigInt(before.project.balance) - expectedLatePayout, + 'A late payout should leave the balance short by exactly what it paid', + ) + assert(asBigInt(after.project.milestones[2].paid) === expectedLatePayout, 'paid records the amount and settles the milestone') + await injectExpectReject( + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 3 }), + voter16, + 'already been claimed', + ) + }, + ], + [ + '21.9b Complete milestone 4 but leave it unclaimed', + async () => { + const startTime = Date.now() + await sc21EndorseMilestoneTime('dao_project_milestone_start', startTime, [voter16, committee[0], committee[1]]) + const started = await waitForMilestoneStatus(proposalN.sc21Project, 4, 'executing') + + // Derived from the recorded start rather than Date.now(): the start endorsement flow is + // three transactions, and if those take more than 80% of the planned duration the milestone + // silently becomes on-time and pays 80 instead of 88. Milestone 1 does the same. + const endTime = Number(started.startTime) + 1_000 + await sc21EndorseMilestoneTime('dao_project_milestone_end', endTime, [voter16, committee[0], committee[1]]) + const milestone = await waitForMilestoneStatus(proposalN.sc21Project, 4, 'completed') + // Deliberately not claimed here — 21.10 ends the project and 21.10b claims it afterwards. + assert(asBigInt(milestone.paid) === 0n, 'Milestone 4 should still be unclaimed going into project end') + }, + ], + [ + '21.10 End the project with milestone 4 still owed', + async () => { + const { receipt } = await injectAndAssert( + sc21Tx('dao_project_end', committee[0]), + committee[0], + ) + // Milestone 4 was last and completed, so the project reads completed (D4) even though + // milestone 2 was terminated — the known under-reporting that decision accepts. + assert(receipt.additionalInfo?.proposalStatus === 'completed', `Expected completed, got ${receipt.additionalInfo?.proposalStatus}`) + + // The balance is trimmed to exactly what milestone 4 is still owed — early delivery, so + // cost 80 + bonus 8. Everything already paid or terminated releases. + const view = await getProject(proposalN.sc21Project) + const stillOwed = sc21EarlyPayout(4, view.project.rateUsdStr) + assert(asBigInt(receipt.additionalInfo.remainingBalanceWei) === stillOwed, `Expected ${stillOwed} still owed, got ${receipt.additionalInfo.remainingBalanceWei}`) + assert(asBigInt(view.project.balance) === stillOwed, 'Project balance should equal what is still owed') + }, + ], + [ + '21.10b Claim milestone 4 after the project has ended', + async () => { + // The case the claim allowlist was widened for: a project leaves 'executing' at + // dao_project_end and never returns, so requiring 'executing' stranded this payment. + const proposal = await getProposal(proposalN.sc21Project) + assert(proposal.status === 'completed', `Expected a completed project, got ${proposal.status}`) + + const before = await getProject(proposalN.sc21Project) + const expectedPay = sc21EarlyPayout(4, before.project.rateUsdStr) + const { receipt } = await injectAndAssert( + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 4 }), + voter16, + { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, + ) + assert(asBigInt(receipt.additionalInfo.paidWei) === expectedPay, `Expected ${expectedPay}, got ${receipt.additionalInfo.paidWei}`) + + const after = await getProject(proposalN.sc21Project) + assert(asBigInt(after.project.balance) === 0n, 'Balance should be empty once the last milestone is paid') + // And with nothing left, reclaim has nothing to take. + await injectExpectReject( + sc21Tx('dao_project_reclaim_balance', committee[0]), + committee[0], + 'already zero', + ) + }, + ], + [ + '21.11 The unclaimed reward pool can still be burned once the project has completed', + async () => { + // The other half of D9. voter14 never claimed, so a residue remains; burning it from a + // 'completed' project proves the burn allowlist covers the project statuses too. + const proposal = await getProposal(proposalN.sc21Project) + assert(proposal.status === 'completed', `Expected completed, got ${proposal.status}`) + const remaining = asBigInt(proposal.voterRewardPool) - asBigInt(proposal.claimedReward) + assert(remaining > 0n, `Expected an unclaimed residue to burn, got ${remaining}`) + + await sleepUntilTimestamp(proposal.claimEnd, 'claimEnd', SLEEP_BUFFER_MS) + const { receipt } = await injectAndAssert( + { type: 'dao_burn_reward', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, + committee[0], + { expectedBalanceDelta: r => -asBigInt(r.transactionFee ?? 0n) }, + ) + assert(asBigInt(receipt.additionalInfo.burned) === remaining, `Expected burned ${remaining}, got ${receipt.additionalInfo.burned}`) + }, + ], + ], + } + // ───────────────────────────────────────────────────────────────────────── // Run scenarios — sequential (default) or parallel (--parallel flag) // ───────────────────────────────────────────────────────────────────────── - const scenarios = [sc1, sc2, sc3, sc4, sc5, sc6, sc7, sc8, sc9, sc10, sc11, sc12, sc13, sc14, sc15, sc16, sc17, sc18, sc19, sc20] + const scenarios = [sc1, sc2, sc3, sc4, sc5, sc6, sc7, sc8, sc9, sc10, sc11, sc12, sc13, sc14, sc15, sc16, sc17, sc18, sc19, sc20, sc21] validateScenarioCatalog(scenarios) if (PARALLEL) { await runScenariosParallel(scenarios) diff --git a/src/@types/index.ts b/src/@types/index.ts index c4187f8a..1b918423 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -86,6 +86,14 @@ export enum AJVSchemaEnum { dao_claim_reward = 'dao_claim_reward', dao_burn_reward = 'dao_burn_reward', dao_cancel = 'dao_cancel', + dao_project_start = 'dao_project_start', + dao_project_milestone_start = 'dao_project_milestone_start', + dao_project_milestone_end = 'dao_project_milestone_end', + dao_project_milestone_terminate = 'dao_project_milestone_terminate', + dao_project_milestone_claim = 'dao_project_milestone_claim', + dao_project_reclaim_balance = 'dao_project_reclaim_balance', + dao_project_end = 'dao_project_end', + dao_project_change_address = 'dao_project_change_address', } export enum TXTypes { @@ -154,6 +162,14 @@ export enum TXTypes { dao_claim_reward = 'dao_claim_reward', dao_burn_reward = 'dao_burn_reward', dao_cancel = 'dao_cancel', + dao_project_start = 'dao_project_start', + dao_project_milestone_start = 'dao_project_milestone_start', + dao_project_milestone_end = 'dao_project_milestone_end', + dao_project_milestone_terminate = 'dao_project_milestone_terminate', + dao_project_milestone_claim = 'dao_project_milestone_claim', + dao_project_reclaim_balance = 'dao_project_reclaim_balance', + dao_project_end = 'dao_project_end', + dao_project_change_address = 'dao_project_change_address', } export interface BaseLiberdusTx { @@ -521,6 +537,11 @@ export namespace Tx { governance?: DaoGovernanceData economic?: DaoEconomicData protocol?: DaoProtocolData + /** + * Project proposals supply only what the proposer chooses; every other DaoProjectData field is + * set by the network (balance and rate at project start, times and endorsements as it runs). + */ + project?: { milestones: DaoMilestone[]; address: string } // Optional: when committee review should begin. Must be >= tx.timestamp (creation time); // defaults to tx.timestamp (creationTime) when omitted. See dao_proposal_create.validate. startTime?: number @@ -580,6 +601,59 @@ export namespace Tx { from: string proposalId: string } + + export interface DaoProjectStart extends BaseLiberdusTx { + from: string + proposalId: string + } + + /** + * No milestone number: the policy says "start the next milestone", so the handler derives it as + * the first one still pending rather than taking it from the sender. + */ + export interface DaoProjectMilestoneStart extends BaseLiberdusTx { + from: string + proposalId: string + /** Present when proposing a time; absent when endorsing the pending one. */ + proposedTime?: number + } + + /** Likewise derived — "end the current milestone", the one that is executing. */ + export interface DaoProjectMilestoneEnd extends BaseLiberdusTx { + from: string + proposalId: string + proposedTime?: number + } + + export interface DaoProjectMilestoneTerminate extends BaseLiberdusTx { + from: string + proposalId: string + milestoneNumber: number + reason: string + } + + export interface DaoProjectMilestoneClaim extends BaseLiberdusTx { + from: string + proposalId: string + milestoneNumber: number + } + + export interface DaoProjectChangeAddress extends BaseLiberdusTx { + from: string + proposalId: string + /** Present when proposing a replacement; absent when endorsing the pending one. */ + proposedAddress?: string + } + + export interface DaoProjectEnd extends BaseLiberdusTx { + from: string + proposalId: string + } + + export interface DaoProjectReclaimBalance extends BaseLiberdusTx { + from: string + proposalId: string + } } export interface Signature { @@ -794,8 +868,14 @@ export interface DevAccount { } // New DAO account types (Phase 1: governance/economic/protocol proposals) -export type DaoProposalStatus = 'review' | 'withheld' | 'voting' | 'rejected' | 'accepted' | 'applied' | 'canceled' -export type DaoProposalType = 'governance' | 'economic' | 'protocol' +/** + * `applied` is reachable only by parameter proposals; `executing`/`completed`/`terminated` only by + * project proposals. Widening this union does NOT flag the hand-maintained status allowlists in + * dao_claim_reward/dao_burn_reward — they are string comparisons TypeScript cannot check. + */ +export type DaoProposalStatus = 'review' | 'withheld' | 'voting' | 'rejected' | 'accepted' | 'canceled' | 'applied' | 'executing' | 'completed' | 'terminated' +export type DaoProposalType = 'governance' | 'economic' | 'protocol' | 'project' +export type DaoMilestoneStatus = 'pending' | 'executing' | 'completed' | 'terminated' export interface DaoParamChange { key: string value: string @@ -815,6 +895,88 @@ export interface DaoProtocolData { changes: DaoParamChanges } +/** One committee submission toward terminating a milestone. The reason is required by policy. */ +export interface DaoTerminateVote { + address: string + reason: string + timestamp: number +} + +/** The eight transactions that can append to a project's audit trail. */ +export type DaoProjectTxType = + | 'dao_project_start' + | 'dao_project_milestone_start' + | 'dao_project_milestone_end' + | 'dao_project_milestone_terminate' + | 'dao_project_milestone_claim' + | 'dao_project_change_address' + | 'dao_project_end' + | 'dao_project_reclaim_balance' + +/** Append-only audit trail entry. The trail starts when the project enters `executing`. */ +export interface DaoProjectLogEntry { + caller: string + timestamp: number + txType: DaoProjectTxType + /** + * What identifies the action, never what resulted from it. + * + * In practice the sender's own fields, plus an identifier the handler had to derive to know what + * it was acting on. Outcomes stay out: amounts minted, paid, owed or reclaimed are products of + * the handler and are already recoverable from the account state and the transaction receipt. + * Keeping them out also means no value here is ever a bigint. + */ + params: Record +} + +export interface DaoMilestone { + title: string + description: string + deliverable: string + /** Planned duration in ms; bonus/penalty compare the actual elapsed time against this. */ + duration: number + costUsdStr: string + penaltyUsdStr: string + bonusUsdStr: string + startTime?: number + endTime?: number + /** + * Staged start or end time awaiting endorsement. Cleared on each commit, so endorsements + * collected for a start cannot carry into the end — the two share these fields. + */ + proposedTime?: number + /** Endorsers, proposer first. Index 0 may be the contractor; later entries must be committee. */ + endorsedTime: string[] + terminateVotes: DaoTerminateVote[] + status: DaoMilestoneStatus + /** + * Amount paid out, in wei, and the settled marker: non-zero means claimed. + * + * A zero payout is unreachable, so the two meanings cannot diverge. `penalty < cost` is enforced + * at proposal creation and repeated in wei at dao_project_start, which together keep every payout + * branch strictly positive. + */ + paid: bigint +} + +export interface DaoProjectData { + milestones: DaoMilestone[] + startTime?: number + endTime?: number + /** LIB minted at project start, in wei; drawn down by milestone claims. */ + balance: bigint + /** USD/LIB rate fixed when balance was minted. Every payout converts at this, not the live rate. */ + rateUsdStr: string + /** Contractor address permitted to claim completed milestones. */ + address: string + proposedAddress?: string + /** Endorsers of proposedAddress, proposer first. Committee only — the contractor cannot propose. */ + endorsedAddress: string[] + durationBonusPercentage: number + durationPenaltyPercentage: number + logs: DaoProjectLogEntry[] +} + /** * One entry in the DaoProposalsMeta proposal index. `timestamp` is the txTimestamp of the * transaction that created the proposal or last changed its status — not the proposal account's @@ -902,6 +1064,7 @@ export interface DaoProposalAccount { governance?: DaoGovernanceData economic?: DaoEconomicData protocol?: DaoProtocolData + project?: DaoProjectData hash: string timestamp: number } diff --git a/src/@types/transactionSchemas.ts b/src/@types/transactionSchemas.ts index fc8c7747..54eb0356 100644 --- a/src/@types/transactionSchemas.ts +++ b/src/@types/transactionSchemas.ts @@ -764,7 +764,7 @@ export const schemaDaoProposalCreateTX = { proposalId: { type: 'string', minLength: 64, maxLength: 64 }, metaId: { type: 'string', minLength: 64, maxLength: 64 }, emergency: { type: 'boolean' }, - proposalType: { enum: ['governance', 'economic', 'protocol'] }, + proposalType: { enum: ['governance', 'economic', 'protocol', 'project'] }, gracePeriod: { type: 'number', minimum: 0 }, title: { type: 'string', minLength: 1, maxLength: 100 }, description: { type: 'string', maxLength: 10000 }, @@ -777,6 +777,7 @@ export const schemaDaoProposalCreateTX = { governance: { type: 'object' }, economic: { type: 'object' }, protocol: { type: 'object' }, + project: { type: 'object' }, startTime: { type: 'number', minimum: 0 }, networkId: { type: 'string' }, }, @@ -892,6 +893,74 @@ export const schemaDaoBurnRewardTX = { additionalProperties: false, } +// Shared by milestone start and end: both propose or endorse a single timestamp. +export const schemaDaoProjectMilestoneTimeTX = { + type: 'object', + properties: { + ...baseTxProperties, + from: { type: 'string' }, + proposalId: { type: 'string', minLength: 64, maxLength: 64 }, + proposedTime: { type: 'number', minimum: 0 }, + networkId: { type: 'string' }, + }, + required: [...baseTxRequired, 'from', 'proposalId'], + additionalProperties: false, +} + +// dao_project_end and dao_project_reclaim_balance carry no extra fields, so they reuse +// schemaDaoProjectStartTX rather than duplicating an identical shape three times. +export const schemaDaoProjectChangeAddressTX = { + type: 'object', + properties: { + ...baseTxProperties, + from: { type: 'string' }, + proposalId: { type: 'string', minLength: 64, maxLength: 64 }, + proposedAddress: { type: 'string', minLength: 64, maxLength: 64 }, + networkId: { type: 'string' }, + }, + required: [...baseTxRequired, 'from', 'proposalId'], + additionalProperties: false, +} + +export const schemaDaoProjectMilestoneClaimTX = { + type: 'object', + properties: { + ...baseTxProperties, + from: { type: 'string' }, + proposalId: { type: 'string', minLength: 64, maxLength: 64 }, + milestoneNumber: { type: 'number', minimum: 1 }, + networkId: { type: 'string' }, + }, + required: [...baseTxRequired, 'from', 'proposalId', 'milestoneNumber'], + additionalProperties: false, +} + +export const schemaDaoProjectMilestoneTerminateTX = { + type: 'object', + properties: { + ...baseTxProperties, + from: { type: 'string' }, + proposalId: { type: 'string', minLength: 64, maxLength: 64 }, + milestoneNumber: { type: 'number', minimum: 1 }, + reason: { type: 'string', minLength: 1, maxLength: 500 }, + networkId: { type: 'string' }, + }, + required: [...baseTxRequired, 'from', 'proposalId', 'milestoneNumber', 'reason'], + additionalProperties: false, +} + +export const schemaDaoProjectStartTX = { + type: 'object', + properties: { + ...baseTxProperties, + from: { type: 'string' }, + proposalId: { type: 'string', minLength: 64, maxLength: 64 }, + networkId: { type: 'string' }, + }, + required: [...baseTxRequired, 'from', 'proposalId'], + additionalProperties: false, +} + export const schemaDaoCancelTX = { type: 'object', properties: { @@ -983,6 +1052,14 @@ function addSchemas(): void { [TXTypes.dao_claim_reward]: schemaDaoClaimRewardTX, [TXTypes.dao_burn_reward]: schemaDaoBurnRewardTX, [TXTypes.dao_cancel]: schemaDaoCancelTX, + [TXTypes.dao_project_start]: schemaDaoProjectStartTX, + [TXTypes.dao_project_milestone_start]: schemaDaoProjectMilestoneTimeTX, + [TXTypes.dao_project_milestone_end]: schemaDaoProjectMilestoneTimeTX, + [TXTypes.dao_project_milestone_terminate]: schemaDaoProjectMilestoneTerminateTX, + [TXTypes.dao_project_milestone_claim]: schemaDaoProjectMilestoneClaimTX, + [TXTypes.dao_project_change_address]: schemaDaoProjectChangeAddressTX, + [TXTypes.dao_project_end]: schemaDaoProjectStartTX, + [TXTypes.dao_project_reclaim_balance]: schemaDaoProjectStartTX, } // Loop through TXTypes and register corresponding schemas Object.entries(txSchemaMap).forEach(([txType, schema]) => { diff --git a/src/accounts/daoProposalAccount.ts b/src/accounts/daoProposalAccount.ts index 94914c3a..55720805 100644 --- a/src/accounts/daoProposalAccount.ts +++ b/src/accounts/daoProposalAccount.ts @@ -30,6 +30,7 @@ export function daoProposalAccount(id: string): DaoProposalAccount { committeeVotes: [], // unapplyVotes intentionally omitted — created lazily by the handler on first // dao_unapply_parameters submission, not carried by every proposal from creation. + // project intentionally omitted for the same reason — only project proposals have one. options: [], totalVote: [], voterRewardPool: 0n, diff --git a/src/api/dao/proposals.ts b/src/api/dao/proposals.ts index a21d6a60..c1438767 100644 --- a/src/api/dao/proposals.ts +++ b/src/api/dao/proposals.ts @@ -47,6 +47,65 @@ export const summary = (dapp) => async (req, res): Promise => { } } +/** + * The project view of a proposal: milestones with their derived state, plus the balance and the + * rate every payout converts at. + * + * Separate from `dao/proposals/:id` because that endpoint returns the whole account, which for a + * project is dominated by an unbounded `logs` array. A caller watching milestone progress should + * not have to pull the entire audit trail on every poll. + */ +export const project = (dapp) => async (req, res): Promise => { + try { + if (!/^\d+$/.test(req.params.id)) { + res.status(400).json({ error: 'Invalid proposal number' }) + return + } + const account = await dapp.getLocalOrRemoteAccount(proposalId(parseInt(req.params.id, 10))) + const proposal = account?.data as DaoProposalAccount + if (!proposal) { + res.status(404).json({ error: `Proposal #${req.params.id} not found` }) + return + } + if (proposal.proposalType !== 'project' || !proposal.project) { + res.status(400).json({ error: `Proposal #${req.params.id} is not a project proposal` }) + return + } + const { logs, ...project } = proposal.project + res.send( + Utils.safeStringify({ + number: proposal.number, + status: proposal.status, + project, + logCount: logs?.length ?? 0, + }), + ) + } catch (error) { + dapp.log(error) + res.json({ error }) + } +} + +/** The audit trail, split out so it is fetched deliberately rather than on every project read. */ +export const projectLogs = (dapp) => async (req, res): Promise => { + try { + if (!/^\d+$/.test(req.params.id)) { + res.status(400).json({ error: 'Invalid proposal number' }) + return + } + const account = await dapp.getLocalOrRemoteAccount(proposalId(parseInt(req.params.id, 10))) + const proposal = account?.data as DaoProposalAccount + if (!proposal?.project) { + res.status(404).json({ error: `Project #${req.params.id} not found` }) + return + } + res.send(Utils.safeStringify({ logs: proposal.project.logs ?? [] })) + } catch (error) { + dapp.log(error) + res.json({ error }) + } +} + export const get = (dapp) => async (req, res): Promise => { try { if (!/^\d+$/.test(req.params.id)) { diff --git a/src/api/index.ts b/src/api/index.ts index 60a80f8b..64f1c276 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -39,6 +39,8 @@ export default (dapp: Shardus): void => { dapp.registerExternalGet('dao/proposals/:id', dao.proposals.get(dapp)) dapp.registerExternalGet('dao/proposals/meta', dao.proposals.meta(dapp)) dapp.registerExternalGet('dao/proposals/summary', dao.proposals.summary(dapp)) + dapp.registerExternalGet('dao/projects/:id', dao.proposals.project(dapp)) + dapp.registerExternalGet('dao/projects/:id/logs', dao.proposals.projectLogs(dapp)) dapp.registerExternalGet('dao/voters/:proposalId', dao.voters.list(dapp)) dapp.registerExternalGet('account/:id', accounts.account(dapp)) diff --git a/src/config/index.ts b/src/config/index.ts index 3d880e8f..948271e3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -268,6 +268,23 @@ interface LiberdusFlags { // Committee votes needed for dao_unapply_parameters to flip applied -> accepted. Consensus- // relevant: must stay identical across all nodes. daoUnapplyCommitteeThreshold: number + // Early/late thresholds as a percentage of a milestone's planned duration. Snapshotted onto each + // project at creation, so a project is judged by the rules it was created under. + daoProjectDurationBonusPercentage: number + daoProjectDurationPenaltyPercentage: number + // Ceiling on the LIB a single project may mint at dao_project_start, as a decimal string so the + // value stays JSON-safe over /debug-liberdus-flags and exact when parsed to wei. + // + // PLACEHOLDER VALUE — set from tokenomics before this reaches a real network. + // + // This is a per-project cap, not a supply cap: the policy's guard is + // `current_supply + balance <= max_mint_threshold`, but current_supply is not maintained anywhere + // yet, so N projects can each pass this and still mint arbitrarily much in aggregate. + // TODO: add the current_supply term once the network maintains it, and move this onto the network + // account so governance can tune it (behind a version flag). + daoMaxMintThresholdLibStr: string + // How long after a project ends before the committee may reclaim an unclaimed balance. + daoProjectReclaimDelayMs: number minCommitteeMembers: number maxCommitteeMembers: number enableAJVValidation: boolean @@ -319,6 +336,10 @@ export const LiberdusFlags: LiberdusFlags = { enableNewDAOTransactions: true, // turned on by migration 2.5.1 enableDaoCancel: true, daoUnapplyCommitteeThreshold: 3, + daoProjectDurationBonusPercentage: 20, + daoProjectDurationPenaltyPercentage: 20, + daoMaxMintThresholdLibStr: '1000000', + daoProjectReclaimDelayMs: 90 * ONE_DAY, minCommitteeMembers: 4, maxCommitteeMembers: 10, enableAJVValidation: false, diff --git a/src/index.ts b/src/index.ts index 98a01ee4..a21dad49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,14 @@ const daoPreCrackTxTypes = new Set([ TXTypes.dao_claim_reward, TXTypes.dao_burn_reward, TXTypes.dao_cancel, + TXTypes.dao_project_start, + TXTypes.dao_project_milestone_start, + TXTypes.dao_project_milestone_end, + TXTypes.dao_project_milestone_terminate, + TXTypes.dao_project_milestone_claim, + TXTypes.dao_project_change_address, + TXTypes.dao_project_end, + TXTypes.dao_project_reclaim_balance, ]) let isReadyToJoinLatestValue = false diff --git a/src/transactions/dao/dao_burn_reward.ts b/src/transactions/dao/dao_burn_reward.ts index 79086fe3..b2fd3626 100644 --- a/src/transactions/dao/dao_burn_reward.ts +++ b/src/transactions/dao/dao_burn_reward.ts @@ -49,7 +49,18 @@ export const validate = ( response.reason = 'Proposal is withheld; the reward pool was already burned' return response } - if (proposal.status !== 'accepted' && proposal.status !== 'applied' && proposal.status !== 'rejected' && proposal.status !== 'canceled') { + // Project proposals leave 'accepted' at dao_project_start and never return, so omitting the + // three project statuses would strand their voter reward pool permanently — unclaimable and + // unburnable. This list is hand-maintained: widening DaoProposalStatus does not flag it. + if ( + proposal.status !== 'accepted' && + proposal.status !== 'applied' && + proposal.status !== 'rejected' && + proposal.status !== 'canceled' && + proposal.status !== 'executing' && + proposal.status !== 'completed' && + proposal.status !== 'terminated' + ) { response.reason = `Proposal voting has not been finalised (current status: ${proposal.status})` return response } diff --git a/src/transactions/dao/dao_claim_reward.ts b/src/transactions/dao/dao_claim_reward.ts index 28ee9f49..e5b7d7ba 100644 --- a/src/transactions/dao/dao_claim_reward.ts +++ b/src/transactions/dao/dao_claim_reward.ts @@ -47,7 +47,18 @@ export const validate = ( response.reason = 'Proposal account not found or is not a DaoProposalAccount' return response } - if (proposal.status !== 'accepted' && proposal.status !== 'applied' && proposal.status !== 'rejected' && proposal.status !== 'canceled') { + // Project proposals leave 'accepted' at dao_project_start and never return, so omitting the + // three project statuses would strand their voter reward pool permanently — unclaimable and + // unburnable. This list is hand-maintained: widening DaoProposalStatus does not flag it. + if ( + proposal.status !== 'accepted' && + proposal.status !== 'applied' && + proposal.status !== 'rejected' && + proposal.status !== 'canceled' && + proposal.status !== 'executing' && + proposal.status !== 'completed' && + proposal.status !== 'terminated' + ) { response.reason = `Proposal voting has not been finalised (current status: ${proposal.status})` return response } diff --git a/src/transactions/dao/dao_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts new file mode 100644 index 00000000..bcaeb8e8 --- /dev/null +++ b/src/transactions/dao/dao_project_change_address.ts @@ -0,0 +1,209 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' +import { planAddressEndorsement } from '../../utils/daoProjectEndorsement' + +export const validate_fields = (tx: Tx.DaoProjectChangeAddress, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (tx.proposedAddress !== undefined && utils.isValidAddress(tx.proposedAddress) === false) { + response.reason = 'tx "proposedAddress" is not a valid address' + return response + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectChangeAddress, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project } = ctx + + // Deliberately not allowed while merely `accepted`: before dao_project_start there are no funds + // to redirect, and the community voted on a proposal naming this contractor. Substituting another + // party before the project even begins changes what was approved without another vote — the + // correct response to a contractor becoming unavailable then is a new proposal. + const isRunning = proposal.status === 'executing' + const isSettling = (proposal.status === 'completed' || proposal.status === 'terminated') && project.balance > 0n + if (!isRunning && !isSettling) { + response.reason = `Contractor address cannot be changed while the project is ${proposal.status}${ + project.balance === 0n ? ' with no remaining balance' : '' + }` + return response + } + // Committee only. Unlike milestone timings, the contractor has no say in who replaces them. + if (!proposal.committeeAddresses.includes(tx.from)) { + response.reason = 'Only a committee member can change the contractor address' + return response + } + if (tx.proposedAddress !== undefined && tx.proposedAddress === project.address) { + response.reason = 'Proposed address is already the contractor address' + return response + } + + // The same call apply() makes, so the two cannot disagree about what this transaction does. + const plan = planAddressEndorsement(tx, proposal.committeeAddresses, project) + if (plan.error) { + response.reason = plan.error + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectChangeAddress, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const project = proposal.project + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + // The address this sender backed, whichever way they submitted it — read before anything is + // written, and before a commit clears proposedAddress, so a blank endorsement still records what + // it endorsed. That, not the mode, is what a dispute turns on. + const supportedAddress = tx.proposedAddress ?? project.proposedAddress + + // Decide first, then apply what comes back. validate() ran the same call against the same + // wrappedStates, so an error here means the two disagreed. + const result = planAddressEndorsement(tx, proposal.committeeAddresses, project) + if (result.error) throw new Error(`dao_project_change_address endorsement failed after validation: ${result.error}`) + project.proposedAddress = result.nextProposedAddress + project.endorsedAddress = result.nextEndorsements + + const previousAddress = project.address + if (result.committed) { + project.address = project.proposedAddress + project.proposedAddress = undefined + project.endorsedAddress = [] + } + + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_change_address', { proposedAddress: supportedAddress }) + + from.timestamp = txTimestamp + proposal.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { + previousAddress, + contractorAddress: project.address, + endorsements: project.endorsedAddress.length, + committed: result.committed === true, + }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_change_address tx', from.id, tx.proposalId) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectChangeAddress, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectChangeAddress, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectChangeAddress): ShardusTypes.ShardusMemoryPatternsInput => { + return { rw: [tx.from, tx.proposalId], wo: [], on: [], ri: [], ro: [] } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount, + accountId: string, + tx: Tx.DaoProjectChangeAddress, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_change_address.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_project_end.ts b/src/transactions/dao/dao_project_end.ts new file mode 100644 index 00000000..3ad8c59c --- /dev/null +++ b/src/transactions/dao/dao_project_end.ts @@ -0,0 +1,193 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount, DaoProposalsMeta } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { daoProposalsMetaId } from '../../accounts/daoProposalsMetaAccount' +import { recordProposalStatus } from '../../utils/daoProposalIndex' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { allMilestonesFinished } from '../../utils/daoProjectMilestoneState' +import { milestonePayoutWei } from '../../utils/daoProjectPayout' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' + +export const validate_fields = (tx: Tx.DaoProjectEnd, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectEnd, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project } = ctx + + if (proposal.status !== 'executing') { + response.reason = `Project is not in executing status (current: ${proposal.status})` + return response + } + + if (!proposal.committeeAddresses.includes(tx.from)) { + response.reason = 'Only a committee member can end a project' + return response + } + if (!allMilestonesFinished(project)) { + response.reason = 'Every milestone must be completed or terminated before the project can end' + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectEnd, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const meta = wrappedStates[daoProposalsMetaId()].data as DaoProposalsMeta + const project = proposal.project + const previousStatus = proposal.status + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + // Shrink the balance to exactly what is still owed: completed milestones that have not been + // claimed yet. Everything else — terminated milestones, and milestones already paid — releases. + // The contractor can keep claiming against this after the project ends, which is why the balance + // is trimmed rather than zeroed. + const owedWei = project.milestones.reduce((total, m) => { + if (m.status !== 'completed' || m.paid > 0n) return total + return total + milestonePayoutWei(m, project).amountWei + }, 0n) + project.balance = owedWei + project.endTime = txTimestamp + + // D4: the project takes the last milestone's status. Simple, and deliberately chosen over + // "terminated if any milestone was terminated" — note this can under-report failure, since a + // project whose earlier milestone was terminated but whose last one completed reads as completed. + const lastMilestone = project.milestones[project.milestones.length - 1] + proposal.status = lastMilestone.status === 'terminated' ? 'terminated' : 'completed' + proposal.timestamp = txTimestamp + + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_end') + + if (proposal.status !== previousStatus) { + recordProposalStatus(meta, proposal.number, proposal.status, proposal.emergency, txTimestamp) + } + + from.timestamp = txTimestamp + meta.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { proposalNumber: proposal.number, proposalStatus: proposal.status, remainingBalanceWei: project.balance }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_end tx', from.id, tx.proposalId, proposal.status) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectEnd, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectEnd, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId, daoProposalsMetaId()] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectEnd): ShardusTypes.ShardusMemoryPatternsInput => { + return { rw: [tx.from, tx.proposalId, daoProposalsMetaId()], wo: [], on: [], ri: [], ro: [] } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount | DaoProposalsMeta, + accountId: string, + tx: Tx.DaoProjectEnd, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_end.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_project_milestone_claim.ts b/src/transactions/dao/dao_project_milestone_claim.ts new file mode 100644 index 00000000..b2225b10 --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -0,0 +1,212 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import * as config from '../../config' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { milestonePayoutWei } from '../../utils/daoProjectPayout' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' + +export const validate_fields = (tx: Tx.DaoProjectMilestoneClaim, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (typeof tx.milestoneNumber !== 'number' || !Number.isInteger(tx.milestoneNumber) || tx.milestoneNumber < 1) { + response.reason = 'tx "milestoneNumber" must be a positive integer' + return response + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectMilestoneClaim, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + // Claiming outlives the project: dao_project_end leaves a balance for exactly this. + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project, milestone } = ctx + // Claiming is deliberately allowed after the project ends: dao_project_end trims the balance to + // exactly what completed-but-unclaimed milestones still owe, so the contractor can collect it. + if (proposal.status !== 'executing' && proposal.status !== 'completed' && proposal.status !== 'terminated') { + response.reason = `Project is not in executing, completed or terminated status (current: ${proposal.status})` + return response + } + + // Only the contractor is paid, and only for work the committee agreed was finished. + if (tx.from !== project.address) { + response.reason = 'Only the contractor may claim a milestone' + return response + } + if (milestone.status !== 'completed') { + response.reason = `Milestone ${tx.milestoneNumber} is not in completed status (current: ${milestone.status})` + return response + } + // Sound as a settled marker because a zero payout cannot occur: `penalty < cost` at creation, + // repeated in wei at dao_project_start, keeps every payout branch strictly positive. + if (milestone.paid > 0n) { + response.reason = `Milestone ${tx.milestoneNumber} has already been claimed` + return response + } + + let payoutWei: bigint + try { + payoutWei = milestonePayoutWei(milestone, project).amountWei + } catch (err) { + response.reason = err instanceof Error ? err.message : String(err) + return response + } + // The balance is what was actually minted, so it caps what can be paid out regardless of what the + // milestone arithmetic says. + if (payoutWei > project.balance) { + response.reason = `Milestone payout (${payoutWei}) exceeds the project balance (${project.balance})` + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectMilestoneClaim, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const project = proposal.project + const milestone = project.milestones[tx.milestoneNumber - 1] + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + const payout = milestonePayoutWei(milestone, project) + project.balance = SafeBigIntMath.subtract(project.balance, payout.amountWei) + from.data.balance = SafeBigIntMath.add(from.data.balance, payout.amountWei) + milestone.paid = payout.amountWei + + appendProjectLog( + project, + tx.from, + txTimestamp, + 'dao_project_milestone_claim', + { milestoneNumber: tx.milestoneNumber }, + ) + + from.timestamp = txTimestamp + proposal.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { + milestoneNumber: tx.milestoneNumber, + milestoneStatus: milestone.status, + deliverySpeed: payout.speed, + paidWei: payout.amountWei, + remainingBalanceWei: project.balance, + }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_milestone_claim tx', from.id, tx.proposalId, tx.milestoneNumber, payout.amountWei) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectMilestoneClaim, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectMilestoneClaim, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectMilestoneClaim): ShardusTypes.ShardusMemoryPatternsInput => { + return { rw: [tx.from, tx.proposalId], wo: [], on: [], ri: [], ro: [] } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount, + accountId: string, + tx: Tx.DaoProjectMilestoneClaim, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_milestone_claim.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts new file mode 100644 index 00000000..f4f3b464 --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -0,0 +1,228 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import * as config from '../../config' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { planMilestoneTimeEndorsement } from '../../utils/daoProjectEndorsement' +import { findExecutingMilestone } from '../../utils/daoProjectMilestoneState' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' + +export const validate_fields = (tx: Tx.DaoProjectMilestoneEnd, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (tx.proposedTime !== undefined) { + if (typeof tx.proposedTime !== 'number' || !Number.isFinite(tx.proposedTime) || tx.proposedTime <= 0) { + response.reason = 'tx "proposedTime" must be a positive finite number if provided' + return response + } + // A start time in the future would let a milestone claim a duration it has not served. + if (tx.proposedTime > tx.timestamp) { + response.reason = `tx "proposedTime" (${tx.proposedTime}) cannot be later than the transaction time (${tx.timestamp})` + return response + } + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectMilestoneEnd, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project } = ctx + + if (proposal.status !== 'executing') { + response.reason = `Project is not in executing status (current: ${proposal.status})` + return response + } + + // The policy says "end the current milestone". At most one can be executing, since a milestone + // cannot start while an earlier one is unfinished, so this resolves unambiguously. + const current = findExecutingMilestone(project) + if (current.error) { + response.reason = current.error + return response + } + const { milestone } = current + // An end before the start would produce a negative duration and invert the bonus/penalty test. + if (tx.proposedTime !== undefined && milestone.startTime !== undefined && tx.proposedTime < milestone.startTime) { + response.reason = `tx "proposedTime" (${tx.proposedTime}) cannot be earlier than the milestone start (${milestone.startTime})` + return response + } + + // The same call apply() makes, so the two cannot disagree about what this transaction does. + // Nothing is mutated here: the plan is discarded and recomputed in apply(). + const plan = planMilestoneTimeEndorsement(tx, proposal.committeeAddresses, project.address, milestone) + if (plan.error) { + response.reason = plan.error + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectMilestoneEnd, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const project = proposal.project + // Re-derived rather than carried from validate(), so apply() depends only on wrappedStates — + // the snapshot Shardus gives both, so the two cannot resolve to different milestones. validate() + // has already rejected the no-match case, which is why this destructure is not re-checked. + const { milestone, index: milestoneIndex } = findExecutingMilestone(project) + const milestoneNumber = milestoneIndex + 1 + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + // Decide first, then apply what comes back. validate() ran the same call against the same + // wrappedStates, so an error here means the two disagreed — throwing keeps a half-applied + // endorsement out of consensus state. + const result = planMilestoneTimeEndorsement(tx, proposal.committeeAddresses, project.address, milestone) + if (result.error) throw new Error(`dao_project_milestone_end endorsement failed after validation: ${result.error}`) + milestone.proposedTime = result.nextProposedTime + milestone.endorsedTime = result.nextEndorsements + + if (result.committed) { + milestone.endTime = milestone.proposedTime + milestone.status = 'completed' + // Cleared on commit like the start was, so nothing carries into a later question about this + // milestone and the fields are unambiguous for the next one. + milestone.proposedTime = undefined + milestone.endorsedTime = [] + // Abandoned termination intent must not linger on a finished milestone, where it could later + // reach the threshold and terminate work that was already accepted and paid for. + milestone.terminateVotes = [] + } + + appendProjectLog( + project, + tx.from, + txTimestamp, + 'dao_project_milestone_end', + tx.proposedTime === undefined ? { milestoneNumber } : { milestoneNumber, proposedTime: tx.proposedTime }, + ) + + from.timestamp = txTimestamp + proposal.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { + milestoneNumber, + milestoneStatus: milestone.status, + endorsements: milestone.endorsedTime.length, + committed: result.committed === true, + }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_milestone_end tx', from.id, tx.proposalId, milestoneNumber) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectMilestoneEnd, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectMilestoneEnd, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectMilestoneEnd): ShardusTypes.ShardusMemoryPatternsInput => { + return { rw: [tx.from, tx.proposalId], wo: [], on: [], ri: [], ro: [] } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount, + accountId: string, + tx: Tx.DaoProjectMilestoneEnd, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_milestone_end.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts new file mode 100644 index 00000000..b4e44b01 --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -0,0 +1,228 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import * as config from '../../config' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { planMilestoneTimeEndorsement } from '../../utils/daoProjectEndorsement' +import { canStartMilestone, findNextPendingMilestone } from '../../utils/daoProjectMilestoneState' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' + +export const validate_fields = (tx: Tx.DaoProjectMilestoneStart, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (tx.proposedTime !== undefined) { + if (typeof tx.proposedTime !== 'number' || !Number.isFinite(tx.proposedTime) || tx.proposedTime <= 0) { + response.reason = 'tx "proposedTime" must be a positive finite number if provided' + return response + } + // A start time in the future would let a milestone claim a duration it has not served. + if (tx.proposedTime > tx.timestamp) { + response.reason = `tx "proposedTime" (${tx.proposedTime}) cannot be later than the transaction time (${tx.timestamp})` + return response + } + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectMilestoneStart, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project } = ctx + + if (proposal.status !== 'executing') { + response.reason = `Project is not in executing status (current: ${proposal.status})` + return response + } + + // The policy says "start the next milestone", so the sender does not name one. Derived from + // wrappedStates, which Shardus snapshots identically for every node. + const next = findNextPendingMilestone(project) + if (next.error) { + response.reason = next.error + return response + } + const { milestone, index: milestoneIndex } = next + + const orderError = canStartMilestone(project, milestoneIndex) + if (orderError) { + response.reason = orderError + return response + } + + // The same call apply() makes, so the two cannot disagree about what this transaction does. + // Nothing is mutated here: the plan is discarded and recomputed in apply(). + const plan = planMilestoneTimeEndorsement(tx, proposal.committeeAddresses, project.address, milestone) + if (plan.error) { + response.reason = plan.error + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectMilestoneStart, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const project = proposal.project + // Re-derived rather than carried from validate(), so apply() depends only on wrappedStates — + // the snapshot Shardus gives both, so the two cannot resolve to different milestones. validate() + // has already rejected the no-match case, which is why this destructure is not re-checked. + const { milestone, index: milestoneIndex } = findNextPendingMilestone(project) + const milestoneNumber = milestoneIndex + 1 + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + // Decide first, then apply what comes back. validate() ran the same call against the same + // wrappedStates, so an error here means the two disagreed — throwing keeps a half-applied + // endorsement out of consensus state. + const result = planMilestoneTimeEndorsement(tx, proposal.committeeAddresses, project.address, milestone) + if (result.error) throw new Error(`dao_project_milestone_start endorsement failed after validation: ${result.error}`) + milestone.proposedTime = result.nextProposedTime + milestone.endorsedTime = result.nextEndorsements + + if (result.committed) { + milestone.startTime = milestone.proposedTime + milestone.status = 'executing' + // Cleared so the same two fields can carry the end-time question next, with no endorsements + // inherited from the start. + milestone.proposedTime = undefined + milestone.endorsedTime = [] + } + + appendProjectLog( + project, + tx.from, + txTimestamp, + 'dao_project_milestone_start', + // milestoneNumber is derived rather than sent, but an audit entry that cannot say which + // milestone was acted on is not much of an audit entry. + tx.proposedTime === undefined ? { milestoneNumber } : { milestoneNumber, proposedTime: tx.proposedTime }, + ) + + from.timestamp = txTimestamp + proposal.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { + milestoneNumber, + milestoneStatus: milestone.status, + endorsements: milestone.endorsedTime.length, + committed: result.committed === true, + }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_milestone_start tx', from.id, tx.proposalId, milestoneNumber) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectMilestoneStart, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectMilestoneStart, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectMilestoneStart): ShardusTypes.ShardusMemoryPatternsInput => { + return { rw: [tx.from, tx.proposalId], wo: [], on: [], ri: [], ro: [] } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount, + accountId: string, + tx: Tx.DaoProjectMilestoneStart, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_milestone_start.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_project_milestone_terminate.ts b/src/transactions/dao/dao_project_milestone_terminate.ts new file mode 100644 index 00000000..9d1b32b8 --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_terminate.ts @@ -0,0 +1,218 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import * as config from '../../config' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { requiredEndorsements } from '../../utils/daoProjectEndorsement' +import { usdToWeiAtRate } from '../../utils/daoProjectPayout' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' + +export const validate_fields = ( + tx: Tx.DaoProjectMilestoneTerminate, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (typeof tx.milestoneNumber !== 'number' || !Number.isInteger(tx.milestoneNumber) || tx.milestoneNumber < 1) { + response.reason = 'tx "milestoneNumber" must be a positive integer' + return response + } + // The policy requires a reason on every termination submission — it is the record of why the DAO + // stopped paying for work, and the log is what a dispute would be argued from. + if (typeof tx.reason !== 'string' || tx.reason.trim().length === 0 || tx.reason.length > 500) { + response.reason = 'tx "reason" must be a non-empty string of at most 500 characters' + return response + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectMilestoneTerminate, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project, milestone } = ctx + + if (proposal.status !== 'executing') { + response.reason = `Project is not in executing status (current: ${proposal.status})` + return response + } + + // A milestone can be abandoned before or during work, but not after it has already resolved. + if (milestone.status !== 'pending' && milestone.status !== 'executing') { + response.reason = `Milestone ${tx.milestoneNumber} cannot be terminated (current: ${milestone.status})` + return response + } + // Committee only — unlike start and end, the contractor has no say in abandoning their own work. + if (!proposal.committeeAddresses.includes(tx.from)) { + response.reason = 'Only a committee member can terminate a milestone' + return response + } + if (milestone.terminateVotes.some((v) => v.address === tx.from)) { + response.reason = 'This address has already voted to terminate this milestone' + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectMilestoneTerminate, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const project = proposal.project + const milestone = project.milestones[tx.milestoneNumber - 1] + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + milestone.terminateVotes.push({ address: tx.from, reason: tx.reason, timestamp: txTimestamp }) + + // Committee-only, so the reachable maximum is the committee size — no contractor slot to allow for. + const required = requiredEndorsements(proposal.committeeAddresses.length, false) + const committed = milestone.terminateVotes.length >= required + if (committed) { + milestone.status = 'terminated' + milestone.endTime = txTimestamp + // The contractor can never claim this milestone, so the escrow held for it is released back out + // of the project's balance. Mirrors what was minted for it: cost plus the early bonus. + // At the project's stored rate, not the live one — this must mirror exactly what was minted for + // this milestone, or the balance drifts whenever the stability factor moves. + const releasedWei = usdToWeiAtRate(milestone.costUsdStr, project.rateUsdStr) + usdToWeiAtRate(milestone.bonusUsdStr, project.rateUsdStr) + project.balance = SafeBigIntMath.subtract(project.balance, releasedWei) + // Any pending start/end question on this milestone is moot now. + milestone.proposedTime = undefined + milestone.endorsedTime = [] + } + + appendProjectLog( + project, + tx.from, + txTimestamp, + 'dao_project_milestone_terminate', + { milestoneNumber: tx.milestoneNumber, reason: tx.reason }, + ) + + from.timestamp = txTimestamp + proposal.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { + milestoneNumber: tx.milestoneNumber, + milestoneStatus: milestone.status, + terminateVotes: milestone.terminateVotes.length, + committed, + }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_milestone_terminate tx', from.id, tx.proposalId, tx.milestoneNumber) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectMilestoneTerminate, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectMilestoneTerminate, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectMilestoneTerminate): ShardusTypes.ShardusMemoryPatternsInput => { + return { rw: [tx.from, tx.proposalId], wo: [], on: [], ri: [], ro: [] } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount, + accountId: string, + tx: Tx.DaoProjectMilestoneTerminate, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_milestone_terminate.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_project_reclaim_balance.ts b/src/transactions/dao/dao_project_reclaim_balance.ts new file mode 100644 index 00000000..aa893c44 --- /dev/null +++ b/src/transactions/dao/dao_project_reclaim_balance.ts @@ -0,0 +1,177 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import * as config from '../../config' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' + +export const validate_fields = (tx: Tx.DaoProjectReclaimBalance, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectReclaimBalance, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project } = ctx + + if (proposal.status !== 'completed' && proposal.status !== 'terminated') { + response.reason = `Project is not in completed or terminated status (current: ${proposal.status})` + return response + } + if (!proposal.committeeAddresses.includes(tx.from)) { + response.reason = 'Only a committee member can reclaim a project balance' + return response + } + if (project.balance === 0n) { + response.reason = 'Project balance is already zero' + return response + } + // The contractor gets a long window to claim what they earned before the DAO takes it back. + const reclaimableAt = (project.endTime ?? 0) + config.LiberdusFlags.daoProjectReclaimDelayMs + if (tx.timestamp < reclaimableAt) { + response.reason = `Project balance cannot be reclaimed until ${reclaimableAt}` + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectReclaimBalance, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const project = proposal.project + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + // The balance is not transferred anywhere — it was minted into the project and is simply gone + // again. Since a project's balance counts toward the LIB in circulation, leaving it unclaimed + // forever would inflate the supply for work that was never paid for. + const reclaimedWei = project.balance + project.balance = 0n + + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_reclaim_balance') + + from.timestamp = txTimestamp + proposal.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { proposalNumber: proposal.number, reclaimedWei }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_reclaim_balance tx', from.id, tx.proposalId, reclaimedWei) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectReclaimBalance, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectReclaimBalance, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectReclaimBalance): ShardusTypes.ShardusMemoryPatternsInput => { + return { rw: [tx.from, tx.proposalId], wo: [], on: [], ri: [], ro: [] } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount, + accountId: string, + tx: Tx.DaoProjectReclaimBalance, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_reclaim_balance.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_project_start.ts b/src/transactions/dao/dao_project_start.ts new file mode 100644 index 00000000..1cc07ad6 --- /dev/null +++ b/src/transactions/dao/dao_project_start.ts @@ -0,0 +1,233 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import * as config from '../../config' +import { NetworkAccount, UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount, DaoProposalsMeta } from '../../@types' +import { SafeBigIntMath } from '../../utils/safeBigIntMath' +import * as AccountsStorage from '../../storage/accountStorage' +import * as utils from '../../utils' +import { daoProposalsMetaId } from '../../accounts/daoProposalsMetaAccount' +import { recordProposalStatus } from '../../utils/daoProposalIndex' +import { getApplyEligibleAt } from '../../accounts/daoProposalAccount' +import { degenerateMilestoneAtRate, exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../../utils/daoProjectMint' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' + +export const validate_fields = (tx: Tx.DaoProjectStart, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { + if (utils.isValidAddress(tx.from) === false) { + response.reason = 'tx "from" is not a valid address' + return response + } + if (utils.isValidAddress(tx.proposalId) === false) { + response.reason = 'tx "proposalId" is not a valid address' + return response + } + if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { + response.reason = 'tx must be signed by the from account' + return response + } + if (crypto.verifyObj(tx) === false) { + response.reason = 'incorrect signing' + return response + } + response.success = true + return response +} + +export const validate = ( + tx: Tx.DaoProjectStart, + wrappedStates: WrappedStates, + response: ShardusTypes.IncomingTransactionResult, +): ShardusTypes.IncomingTransactionResult => { + const network = wrappedStates[config.networkAccount]?.data as NetworkAccount + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal } = ctx + + if (!network) { + response.reason = 'Network account not found' + return response + } + if (proposal.status !== 'accepted') { + response.reason = `Proposal is not in accepted status (current: ${proposal.status})` + return response + } + // Committee-only. This transaction mints, so it is deliberately not open to anyone the way + // dao_apply_parameters is for non-emergency parameter proposals. + if (!proposal.committeeAddresses.includes(tx.from)) { + response.reason = 'Only a committee member can start a project' + return response + } + // Projects never carry the emergency exemption (dao_proposal_create rejects emergency projects), + // so the grace period always applies. + if (tx.timestamp < getApplyEligibleAt(proposal)) { + response.reason = 'Grace period has not elapsed yet' + return response + } + + let mintWei: bigint + try { + mintWei = projectMintAmountWei(proposal.project.milestones, (usdStr) => utils.usdStrToWei(usdStr, network)) + if (exceedsMintThreshold(mintWei)) { + response.reason = `Project would mint ${mintWei} wei, exceeding the maximum of ${maxMintThresholdWei()} wei` + return response + } + // The rate is fixed for the project's life at apply() below, so this is the last point at which + // a milestone that converts to a zero payout can be caught. A project can therefore pass + // creation and still fail to start: the remedy is a new proposal with amounts that survive + // conversion, which is better than minting escrow against milestones that cannot pay out. + const degenerate = degenerateMilestoneAtRate(proposal.project.milestones, (usdStr) => utils.usdStrToWei(usdStr, network)) + if (degenerate) { + response.reason = `Project cannot start: ${degenerate}` + return response + } + } catch (err) { + // A malformed milestone amount or a malformed mint ceiling both land here. Failing the + // transaction is the correct outcome for either — never mint on an amount we could not compute. + response.reason = err instanceof Error ? err.message : String(err) + return response + } + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance < txFeeWei) { + response.reason = 'Insufficient balance to cover the transaction fee' + return response + } + + response.success = true + response.reason = 'This transaction is valid!' + return response +} + +export const apply = ( + tx: Tx.DaoProjectStart, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, +): void => { + const from = wrappedStates[tx.from].data as UserAccount + const proposal = wrappedStates[tx.proposalId].data as DaoProposalAccount + const network = wrappedStates[config.networkAccount].data as NetworkAccount + const meta = wrappedStates[daoProposalsMetaId()].data as DaoProposalsMeta + const previousStatus = proposal.status + const project = proposal.project + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + // The mint. Recomputed here rather than carried from validate() so apply() depends only on + // wrappedStates, which Shardus snapshots identically for every node. + const mintWei = projectMintAmountWei(project.milestones, (usdStr) => utils.usdStrToWei(usdStr, network)) + project.balance = mintWei + // Every later payout converts at this rate, not the live one, so the contractor carries the LIB + // price risk from here and the DAO's exposure is fixed at the amount minted. + project.rateUsdStr = network.current.stabilityFactorStr + project.startTime = txTimestamp + + proposal.status = 'executing' + proposal.timestamp = txTimestamp + + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_start') + + // Always a real transition (accepted -> executing), but the guard is kept so every handler reads + // the same way and stays correct if the branches ever change. + if (proposal.status !== previousStatus) { + recordProposalStatus(meta, proposal.number, proposal.status, proposal.emergency, txTimestamp) + } + + from.timestamp = txTimestamp + meta.timestamp = txTimestamp + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: true, + from: from.id, + to: proposal.id, + type: tx.type, + transactionFee: txFeeWei, + additionalInfo: { + proposalNumber: proposal.number, + proposalStatus: proposal.status, + mintedWei: mintWei, + rateUsdStr: project.rateUsdStr, + }, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) + + dapp.log('Applied dao_project_start tx', from.id, tx.proposalId, 'minted', mintWei) +} + +export const createFailedAppReceiptData = ( + tx: Tx.DaoProjectStart, + txTimestamp: number, + txId: string, + wrappedStates: WrappedStates, + dapp: Shardus, + applyResponse: ShardusTypes.ApplyResponse, + reason: string, +): void => { + // A failed transaction still costs its sender the fee, or their whole balance if it is smaller — + // otherwise failing is free and can be repeated without cost. + const from = wrappedStates[tx.from]?.data as UserAccount + let transactionFee = BigInt(0) + if (from) { + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + if (from.data.balance >= txFeeWei) { + transactionFee = txFeeWei + from.data.balance = SafeBigIntMath.subtract(from.data.balance, transactionFee) + } else { + transactionFee = from.data.balance + from.data.balance = BigInt(0) + } + from.timestamp = txTimestamp + } + + const appReceiptData: AppReceiptData = { + txId, + timestamp: txTimestamp, + success: false, + reason, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee, + } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) +} + +export const keys = (tx: Tx.DaoProjectStart, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId, config.networkAccount, daoProposalsMetaId()] + result.allKeys = [...result.sourceKeys, ...result.targetKeys] + return result +} + +export const memoryPattern = (tx: Tx.DaoProjectStart): ShardusTypes.ShardusMemoryPatternsInput => { + return { + rw: [tx.from, tx.proposalId, daoProposalsMetaId()], + wo: [], + on: [], + ri: [], + ro: [config.networkAccount], + } +} + +export const createRelevantAccount = ( + dapp: Shardus, + account: UserAccount | DaoProposalAccount | DaoProposalsMeta, + accountId: string, + tx: Tx.DaoProjectStart, + accountCreated = false, +): ShardusTypes.WrappedResponse => { + if (!account) { + throw new Error(`dao_project_start.createRelevantAccount: account ${accountId} does not exist`) + } + return dapp.createWrappedResponse(accountId, accountCreated, account.hash, account.timestamp, account) +} diff --git a/src/transactions/dao/dao_proposal_create.ts b/src/transactions/dao/dao_proposal_create.ts index 9079f0e2..1a5daa59 100644 --- a/src/transactions/dao/dao_proposal_create.ts +++ b/src/transactions/dao/dao_proposal_create.ts @@ -12,6 +12,23 @@ import { recordProposalStatus } from '../../utils/daoProposalIndex' // import { backfillProposalIndex } from '../../utils/daoProposalIndex' // disabled with its call in apply() import { validateDaoOptions } from '../../utils/daoBallotOptions' import { validateProposalChangeSets } from '../../utils/daoProposalChangeSets' +import { validateProjectMilestones } from '../../utils/daoProjectMilestones' + +/** + * Routes payload validation by proposal type. Parameter proposals carry nested `changes`; projects + * carry `milestones` and never reach validateProposalChangeSets, which would reject them for having + * no change sets at all. + */ +function validateProposalPayload(tx: Tx.DaoProposalCreate, network: NetworkAccount | undefined, dapp: Shardus): string | undefined { + if (tx.proposalType === 'project') { + if (typeof tx.project?.address !== 'string' || utils.isValidAddress(tx.project.address) === false) { + return 'tx "project.address" must be a valid contractor address' + } + return validateProjectMilestones(tx.project.milestones) + } + const payload = tx[tx.proposalType as 'governance' | 'economic' | 'protocol'] + return validateProposalChangeSets(tx.proposalType, tx.options, payload?.changes ?? [], network, dapp, tx.emergency) +} export const validate_fields = ( tx: Tx.DaoProposalCreate, @@ -34,8 +51,13 @@ export const validate_fields = ( response.reason = 'tx "emergency" must be a boolean' return response } - if (!['governance', 'economic', 'protocol'].includes(tx.proposalType)) { - response.reason = 'tx "proposalType" must be one of: governance, economic, protocol' + if (!['governance', 'economic', 'protocol', 'project'].includes(tx.proposalType)) { + response.reason = 'tx "proposalType" must be one of: governance, economic, protocol, project' + return response + } + // Projects mint new coins, so they must always face a community vote — no committee-only path. + if (tx.proposalType === 'project' && tx.emergency === true) { + response.reason = 'tx "project" proposals cannot be emergency proposals' return response } if (tx.gracePeriod !== undefined && (typeof tx.gracePeriod !== 'number' || tx.gracePeriod < 0)) { @@ -50,7 +72,7 @@ export const validate_fields = ( response.reason = 'tx "description" must be a non-empty string of at most 10000 characters' return response } - const optionsError = validateDaoOptions(tx.options) + const optionsError = validateDaoOptions(tx.options, tx.proposalType) if (optionsError) { response.reason = optionsError return response @@ -64,10 +86,9 @@ export const validate_fields = ( response.reason = `tx "startTime" (${tx.startTime}) cannot be earlier than the creation time (${tx.timestamp})` return response } - const payload = tx[tx.proposalType as 'governance' | 'economic' | 'protocol'] - const changesError = validateProposalChangeSets(tx.proposalType, tx.options, payload?.changes ?? [], AccountsStorage.cachedNetworkAccount, dapp, tx.emergency) - if (changesError) { - response.reason = changesError + const payloadError = validateProposalPayload(tx, AccountsStorage.cachedNetworkAccount, dapp) + if (payloadError) { + response.reason = payloadError return response } if (!tx.sign || !tx.sign.owner || !tx.sign.sig || tx.sign.owner !== tx.from) { @@ -138,10 +159,9 @@ export const validate = ( } // Recheck with live wrappedStates — validate_fields ran against the cached network account. - const txPayload = tx[tx.proposalType as 'governance' | 'economic' | 'protocol'] - const changesError = validateProposalChangeSets(tx.proposalType, tx.options, txPayload?.changes ?? [], network, dapp, tx.emergency) - if (changesError) { - response.reason = changesError + const payloadError = validateProposalPayload(tx, network, dapp) + if (payloadError) { + response.reason = payloadError return response } @@ -220,6 +240,20 @@ export const apply = async ( if (tx.governance) proposal.governance = tx.governance if (tx.economic) proposal.economic = tx.economic if (tx.protocol) proposal.protocol = tx.protocol + if (tx.proposalType === 'project' && tx.project) { + // Only the proposer-supplied fields come from the tx. balance/rate are set by + // dao_project_start; times, endorsements and logs accrue as the project runs. + proposal.project = { + milestones: tx.project.milestones.map((m) => ({ ...m, status: 'pending', paid: 0n, endorsedTime: [], terminateVotes: [] })), + balance: 0n, + rateUsdStr: '0', + address: tx.project.address, + endorsedAddress: [], + durationBonusPercentage: config.LiberdusFlags.daoProjectDurationBonusPercentage, + durationPenaltyPercentage: config.LiberdusFlags.daoProjectDurationPenaltyPercentage, + logs: [], + } + } proposal.status = 'review' diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 56ba0b2f..d051b35e 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -57,6 +57,14 @@ import * as dao_unapply_parameters from './dao/dao_unapply_parameters' import * as dao_claim_reward from './dao/dao_claim_reward' import * as dao_burn_reward from './dao/dao_burn_reward' import * as dao_cancel from './dao/dao_cancel' +import * as dao_project_start from './dao/dao_project_start' +import * as dao_project_milestone_start from './dao/dao_project_milestone_start' +import * as dao_project_milestone_end from './dao/dao_project_milestone_end' +import * as dao_project_milestone_terminate from './dao/dao_project_milestone_terminate' +import * as dao_project_milestone_claim from './dao/dao_project_milestone_claim' +import * as dao_project_change_address from './dao/dao_project_change_address' +import * as dao_project_end from './dao/dao_project_end' +import * as dao_project_reclaim_balance from './dao/dao_project_reclaim_balance' export default { init_network, @@ -118,4 +126,12 @@ export default { dao_claim_reward, dao_burn_reward, dao_cancel, + dao_project_start, + dao_project_milestone_start, + dao_project_milestone_end, + dao_project_milestone_terminate, + dao_project_milestone_claim, + dao_project_change_address, + dao_project_end, + dao_project_reclaim_balance, } diff --git a/src/utils/daoBallotOptions.ts b/src/utils/daoBallotOptions.ts index 5a8ed51f..fbe4f7f6 100644 --- a/src/utils/daoBallotOptions.ts +++ b/src/utils/daoBallotOptions.ts @@ -13,7 +13,11 @@ export function isNegativeOption(option: string): boolean { return NEGATIVE_OPTION_STRINGS.includes(normalizeOption(option)) } -export function validateDaoOptions(options: string[]): string | undefined { +/** + * `proposalType` is optional because only projects constrain the count: a project has one flat + * milestone array with no per-option variant, so a third option would have nothing to select. + */ +export function validateDaoOptions(options: string[], proposalType?: string): string | undefined { if (!Array.isArray(options) || options.length < 2 || options.length > 10) { return 'tx "options" must be an array with 2 to 10 entries' } @@ -25,6 +29,9 @@ export function validateDaoOptions(options: string[]): string | undefined { if (!isNegativeOption(options[0])) { return `tx "options[0]" must be a recognized rejection choice (one of: ${NEGATIVE_OPTION_STRINGS.join(', ')})` } + if (proposalType === 'project' && options.length !== 2) { + return `tx "options" for a project proposal must have exactly 2 entries (got ${options.length})` + } return undefined } diff --git a/src/utils/daoProjectEndorsement.ts b/src/utils/daoProjectEndorsement.ts new file mode 100644 index 00000000..b54fd01e --- /dev/null +++ b/src/utils/daoProjectEndorsement.ts @@ -0,0 +1,173 @@ +/** + * Committee agreements needed before a proposed value commits. + * + * The proposer always counts as the first, so this is a total, not a number of *additional* + * endorsers. Read the policy's rules that way and they already agree: "endorsed by two committee + * members" after a proposer set the value, "two more committee members" for an address change, and + * "once three committee members submit" for a termination all come to three. + */ +export const PROJECT_ENDORSEMENT_THRESHOLD = 3 + +/** + * How many endorsements are actually required, given who may endorse this particular value. + * + * The clamp exists so a committee smaller than the threshold cannot deadlock — but the reachable + * maximum differs by path, so a single clamp to committee size would be wrong. On the paths the + * contractor may open, the contractor occupies index 0 and the committee supplies the rest, so the + * ceiling is `1 + committeeSize`. Clamping those to `committeeSize` would commit a milestone one + * endorsement early whenever the committee is small. + */ +export function requiredEndorsements(committeeSize: number, contractorMayPropose: boolean): number { + const reachable = contractorMayPropose ? committeeSize + 1 : committeeSize + return Math.min(PROJECT_ENDORSEMENT_THRESHOLD, Math.max(reachable, 1)) +} + +export interface EndorsementCheck { + error?: string + /** True once the endorsement list has reached the required count and the value should commit. */ + committed?: boolean +} + +/** + * Write-once: rejects a second proposal for a value that already has one pending. + * + * Milestone paths only. A pending time that cannot be replaced is what stops an endorsement counting + * toward a time its sender never saw, and it enforces the policy's "the contractor can only call + * this once" — applyEndorsement's contractor check blocks endorsing but not re-proposing, and every + * proposal resets the count. + * + * Safe on a milestone because a bad value still has an escape: terminate accepts a `pending` one. + * The address path has none — `proposedAddress` is cleared only on commit, so this would freeze the + * contractor address for the life of the project, and changing it is the remedy for a lost key. + */ +export function writeOnceError(hasPendingValue: boolean, isProposingNewValue: boolean): string | undefined { + if (isProposingNewValue && hasPendingValue) return 'A value has already been proposed; it can only be endorsed' + return undefined +} + +/** + * Applies one propose-or-endorse submission to an endorsement list, in place. + * + * A submission carrying a value replaces whatever was pending and re-seeds the endorsements with its + * sender; one without endorses what is pending. Whether a second proposal is allowed at all is the + * caller's decision — see writeOnceError. + * + * `endorsements` is mutated. Prefer the plan* functions below, which decide without mutating. + */ +export function applyEndorsement( + endorsements: string[], + sender: string, + isProposingNewValue: boolean, + committeeAddresses: string[], + contractorAddress: string | undefined, + hasPendingValue: boolean, +): EndorsementCheck { + const isCommittee = committeeAddresses.includes(sender) + const isContractor = contractorAddress !== undefined && sender === contractorAddress + const contractorMayPropose = contractorAddress !== undefined + + if (!isCommittee && !isContractor) { + return { error: 'Only a committee member or the contractor may submit this transaction' } + } + // Letting the contractor endorse would let them occupy two of the three slots. + if (isContractor && !isCommittee && !isProposingNewValue) { + return { error: 'The contractor may propose a value but not endorse one' } + } + + if (isProposingNewValue) { + // Replace rather than append: a new value is a different question, so endorsements collected + // for the old one must not carry over. + endorsements.length = 0 + endorsements.push(sender) + } else { + if (!hasPendingValue) { + return { error: 'There is no proposed value to endorse' } + } + if (endorsements.includes(sender)) { + return { error: 'This address has already endorsed the proposed value' } + } + endorsements.push(sender) + } + + const required = requiredEndorsements(committeeAddresses.length, contractorMayPropose) + return { committed: endorsements.length >= required } +} + +export interface EndorsementPlan { + error?: string + isProposing: boolean + committed: boolean + /** Replaces the endorsement list wholesale. A fresh array, never the one that was read. */ + nextEndorsements: string[] +} + +export interface MilestoneTimePlan extends EndorsementPlan { + nextProposedTime?: number +} + +export interface AddressPlan extends EndorsementPlan { + nextProposedAddress?: string +} + +/** + * Decides what a milestone start or end submission does, without changing anything. + * + * Reads the pending time and endorsement list itself and returns what should replace them. It takes + * no "is something pending" flag on purpose: a caller that computed one *after* writing + * `proposedTime` turned every opening proposal into a rejected re-proposal, and since validate() and + * apply() built those arguments separately, only a live network caught it. + * + * Milestone times are write-once and the contractor may open one — addresses differ on both counts. + */ +export function planMilestoneTimeEndorsement( + tx: { from: string; proposedTime?: number }, + committeeAddresses: string[], + contractorAddress: string | undefined, + milestone: { proposedTime?: number; endorsedTime: string[] }, +): MilestoneTimePlan { + const isProposing = tx.proposedTime !== undefined + const hasPendingValue = milestone.proposedTime !== undefined + const nextEndorsements = [...milestone.endorsedTime] + + const writeOnce = writeOnceError(hasPendingValue, isProposing) + if (writeOnce) return { error: writeOnce, isProposing, committed: false, nextEndorsements } + + const result = applyEndorsement(nextEndorsements, tx.from, isProposing, committeeAddresses, contractorAddress, hasPendingValue) + if (result.error) return { error: result.error, isProposing, committed: false, nextEndorsements } + + return { + isProposing, + committed: result.committed === true, + nextProposedTime: isProposing ? tx.proposedTime : milestone.proposedTime, + nextEndorsements, + } +} + +/** + * The same for a contractor address change, differing on both policy points. + * + * No write-once, per policy line 353: refusing a second proposal would freeze the contractor address + * for the life of the project, and changing it is the remedy for a lost key. + * + * No contractor slot: the committee alone decides who replaces them, so the threshold clamps to the + * committee size. + */ +export function planAddressEndorsement( + tx: { from: string; proposedAddress?: string }, + committeeAddresses: string[], + project: { proposedAddress?: string; endorsedAddress: string[] }, +): AddressPlan { + const isProposing = tx.proposedAddress !== undefined + const hasPendingValue = project.proposedAddress !== undefined + const nextEndorsements = [...project.endorsedAddress] + + const result = applyEndorsement(nextEndorsements, tx.from, isProposing, committeeAddresses, undefined, hasPendingValue) + if (result.error) return { error: result.error, isProposing, committed: false, nextEndorsements } + + return { + isProposing, + committed: result.committed === true, + nextProposedAddress: isProposing ? tx.proposedAddress : project.proposedAddress, + nextEndorsements, + } +} diff --git a/src/utils/daoProjectLog.ts b/src/utils/daoProjectLog.ts new file mode 100644 index 00000000..56e7fa23 --- /dev/null +++ b/src/utils/daoProjectLog.ts @@ -0,0 +1,25 @@ +import { DaoProjectData, DaoProjectLogEntry, DaoProjectTxType } from '../@types' + +/** + * Appends to the project's audit trail — who called, when, and what they did. The policy keeps this + * "in case of any dispute between the contractor and DAO". + * + * `params` is structured rather than a formatted string so the trail can be queried without parsing + * it. It carries what identifies the action, not what resulted from it — see DaoProjectLogEntry. + * + * Uncapped by decision. Growth follows committee behaviour, not the milestone count — every propose + * or endorse appends — so bounding milestones does not bound this. Acceptable because only the + * committee and contractor can append. + * TODO: cap with oldest-first eviction if project accounts get large. + */ +export function appendProjectLog( + project: DaoProjectData, + caller: string, + timestamp: number, + txType: DaoProjectTxType, + params: Record = {}, +): void { + if (!Array.isArray(project.logs)) project.logs = [] + const entry: DaoProjectLogEntry = { caller, timestamp, txType, params } + project.logs.push(entry) +} diff --git a/src/utils/daoProjectMilestoneState.ts b/src/utils/daoProjectMilestoneState.ts new file mode 100644 index 00000000..f4469eb5 --- /dev/null +++ b/src/utils/daoProjectMilestoneState.ts @@ -0,0 +1,79 @@ +import { DaoMilestone, DaoProjectData } from '../@types' + +/** + * Resolves a transaction's 1-based milestone number to its array index. + * + * Transactions number from 1 to match how proposals are addressed externally; storage is 0-indexed. + * An off-by-one here misroutes a payment, so both boundaries are rejected explicitly rather than + * left to produce `undefined`. + */ +export function resolveMilestone(project: DaoProjectData, milestoneNumber: unknown): { milestone?: DaoMilestone; index?: number; error?: string } { + if (typeof milestoneNumber !== 'number' || !Number.isInteger(milestoneNumber)) { + return { error: 'tx "milestoneNumber" must be an integer' } + } + if (milestoneNumber < 1 || milestoneNumber > project.milestones.length) { + return { error: `tx "milestoneNumber" (${milestoneNumber}) is outside the range 1..${project.milestones.length}` } + } + const index = milestoneNumber - 1 + return { milestone: project.milestones[index], index } +} + +/** + * Milestones run strictly in order: one may only start once every earlier one has finished, either + * completed or terminated. Checking all of them rather than just the previous one costs nothing. + * + * Runs after findNextPendingMilestone and catches what that cannot: the next `pending` milestone may + * still sit behind one that is `executing`. That rejection is what makes deriving the milestone + * safe, so do not drop it as redundant. + */ +export function canStartMilestone(project: DaoProjectData, index: number): string | undefined { + for (let i = 0; i < index; i++) { + const previous = project.milestones[i] + if (previous.status !== 'completed' && previous.status !== 'terminated') { + return `Milestone ${i + 1} is still ${previous.status}; milestones run in order` + } + } + return undefined +} + +/** + * The milestone a start transaction acts on: the first one still `pending`. + * + * The policy says "start the next milestone" rather than naming one, so the sender supplies no + * number. A pure function of the project data, so every node derives the same milestone. + * + * This says which milestone is next in line, not that it may start — `canStartMilestone` still runs + * at the call site, and rejects one whose predecessor is merely `executing`. + */ +export function findNextPendingMilestone(project: DaoProjectData): { milestone?: DaoMilestone; index?: number; error?: string } { + const index = project.milestones.findIndex((m) => m.status === 'pending') + if (index === -1) { + return { error: 'No milestone is pending; every milestone has already started or finished' } + } + return { milestone: project.milestones[index], index } +} + +/** + * The milestone an end transaction acts on: the one currently `executing`. + * + * At most one can be, since a milestone cannot start while an earlier one is unfinished, so "the + * current milestone" resolves without the sender naming it. + */ +export function findExecutingMilestone(project: DaoProjectData): { milestone?: DaoMilestone; index?: number; error?: string } { + const executing = project.milestones.reduce((found, m, i) => (m.status === 'executing' ? [...found, i] : found), []) + if (executing.length === 0) { + return { error: 'No milestone is executing; there is nothing to end' } + } + // Unreachable while canStartMilestone holds, but this is consensus code and "the current + // milestone" has to mean one milestone. Failing closed beats silently ending the earliest of + // several and writing a payout against it. + if (executing.length > 1) { + return { error: `Milestones ${executing.map((i) => i + 1).join(', ')} are all executing; cannot resolve the current one` } + } + return { milestone: project.milestones[executing[0]], index: executing[0] } +} + +/** True when no milestone remains that could still start or finish. */ +export function allMilestonesFinished(project: DaoProjectData): boolean { + return project.milestones.every((m) => m.status === 'completed' || m.status === 'terminated') +} diff --git a/src/utils/daoProjectMilestones.ts b/src/utils/daoProjectMilestones.ts new file mode 100644 index 00000000..d42d3e22 --- /dev/null +++ b/src/utils/daoProjectMilestones.ts @@ -0,0 +1,83 @@ +import { ethers } from 'ethers' +import { DaoMilestone } from '../@types' + +/** + * Creation-time bounds on a project proposal. + * + * These exist because the proposer chooses the size of a consensus-visible account that is then + * rewritten and re-hashed by every subsequent project transaction. Without them one proposal can + * make every later transaction on it expensive. Hard-coded rather than configurable, matching the + * title/description limits already inline in dao_proposal_create. + */ +export const MAX_PROJECT_MILESTONES = 20 +export const MAX_MILESTONE_TITLE_LENGTH = 100 +export const MAX_MILESTONE_TEXT_LENGTH = 2000 + +/** Rejects anything ethers.parseEther would throw on, plus negatives. */ +function usdStrError(value: unknown, field: string, path: string): string | undefined { + if (typeof value !== 'string' || value.length === 0) { + return `${path}.${field} must be a non-empty USD string` + } + let parsed: bigint + try { + parsed = ethers.parseEther(value) + } catch { + return `${path}.${field} ("${value}") is not a valid decimal USD string` + } + if (parsed < 0n) return `${path}.${field} ("${value}") must not be negative` + return undefined +} + +/** + * Validates the milestone array supplied at proposal creation. + * + * Only the proposer-supplied milestone fields are checked. Everything else on DaoProjectData — + * balance, rate, times, endorsements, logs — is written by the network as the project runs. The + * contractor address is validated by the caller, which keeps this module free of the utils barrel + * and the config/utils import cycle that comes with it. + */ +export function validateProjectMilestones(milestones: unknown): string | undefined { + if (!Array.isArray(milestones) || milestones.length === 0) { + return 'tx "project.milestones" must be a non-empty array' + } + if (milestones.length > MAX_PROJECT_MILESTONES) { + return `tx "project.milestones" has ${milestones.length} entries, exceeding the maximum of ${MAX_PROJECT_MILESTONES}` + } + + for (const [i, entry] of milestones.entries()) { + const path = `project.milestones[${i}]` + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + return `${path} must be an object` + } + const m = entry as Partial + + if (typeof m.title !== 'string' || m.title.trim().length === 0 || m.title.length > MAX_MILESTONE_TITLE_LENGTH) { + return `${path}.title must be a non-empty string of at most ${MAX_MILESTONE_TITLE_LENGTH} characters` + } + for (const field of ['description', 'deliverable'] as const) { + const text = m[field] + if (typeof text !== 'string' || text.trim().length === 0 || text.length > MAX_MILESTONE_TEXT_LENGTH) { + return `${path}.${field} must be a non-empty string of at most ${MAX_MILESTONE_TEXT_LENGTH} characters` + } + } + // A zero or non-finite duration would make the bonus/penalty comparison meaningless — every + // milestone would be judged instantly late or produce NaN. + if (typeof m.duration !== 'number' || !Number.isFinite(m.duration) || m.duration <= 0) { + return `${path}.duration must be a positive finite number of milliseconds` + } + for (const field of ['costUsdStr', 'penaltyUsdStr', 'bonusUsdStr'] as const) { + const error = usdStrError(m[field], field, path) + if (error) return error + } + // A penalty reduces a payment; it does not erase it. Allowing penalty >= cost would let late + // delivery forfeit the milestone entirely, which is what a termination is for. It also keeps + // every payout branch strictly positive, so `paid > 0n` remains a sound settled marker. Note + // this is the USD-level guarantee only — dao_project_start repeats it in wei, where truncation + // at the project's rate could still collapse a valid pair. + if (ethers.parseEther(m.penaltyUsdStr) >= ethers.parseEther(m.costUsdStr)) { + return `${path}.penaltyUsdStr ("${m.penaltyUsdStr}") must be less than ${path}.costUsdStr ("${m.costUsdStr}")` + } + } + + return undefined +} diff --git a/src/utils/daoProjectMint.ts b/src/utils/daoProjectMint.ts new file mode 100644 index 00000000..676a5ecf --- /dev/null +++ b/src/utils/daoProjectMint.ts @@ -0,0 +1,68 @@ +import { ethers } from 'ethers' +import { LiberdusFlags } from '../config' +import { DaoMilestone } from '../@types' + +/** + * The configured per-project mint ceiling, in wei. + * + * Stored as a decimal string so the flag stays JSON-safe and settable through the debug endpoint; + * parseEther reads it exactly, with no float rounding. + * + * Throws on a malformed value rather than defaulting. A ceiling that silently becomes something + * other than what an operator configured is worse than a failed transaction, and the throw surfaces + * in validation — so a bad value stops mints rather than widening them. + */ +export function maxMintThresholdWei(): bigint { + const configured = LiberdusFlags.daoMaxMintThresholdLibStr + let parsed: bigint + try { + parsed = ethers.parseEther(configured) + } catch { + throw new Error(`daoMaxMintThresholdLibStr ("${configured}") is not a valid decimal LIB string`) + } + if (parsed < 0n) { + throw new Error(`daoMaxMintThresholdLibStr ("${configured}") must not be negative`) + } + return parsed +} + +/** True when minting `amountWei` would exceed the ceiling. Strictly greater — equality is allowed. */ +export function exceedsMintThreshold(amountWei: bigint): boolean { + return amountWei > maxMintThresholdWei() +} + +/** + * The most a project could ever owe: every milestone's cost plus its early-delivery bonus. + * + * Penalties are excluded — a penalty only reduces what a contractor is paid, so folding it in would + * mint more escrow than the project can legitimately pay out. + * + * The converter is injected rather than imported to keep this module clear of the utils barrel and + * its config/utils cycle. That is why this differs from milestonePayoutWei, which takes the project: + * the mint converts at the *live* rate, which only the caller can reach. + */ +export function projectMintAmountWei(milestones: DaoMilestone[], usdStrToWei: (usdStr: string) => bigint): bigint { + return milestones.reduce((total, m) => total + usdStrToWei(m.costUsdStr) + usdStrToWei(m.bonusUsdStr), 0n) +} + +/** + * Repeats the creation-time `penalty < cost` rule in wei, at the rate the project is about to fix. + * + * Creation compares USD strings, but payouts are truncating divisions by the rate, so two amounts + * that differ in USD can land on the same wei value — "0.000000000000000002" and + * "0.000000000000000001" both truncate to 0 at a large enough rate. That would make a late payout + * zero and leave the milestone claimable forever under `paid > 0n`. + * + * The rate is unknown at creation but known here, and fixed for the project's life once snapshotted, + * so one check covers every later payout. + */ +export function degenerateMilestoneAtRate(milestones: DaoMilestone[], usdStrToWei: (usdStr: string) => bigint): string | undefined { + for (const [i, m] of milestones.entries()) { + const costWei = usdStrToWei(m.costUsdStr) + const penaltyWei = usdStrToWei(m.penaltyUsdStr) + if (penaltyWei >= costWei) { + return `milestones[${i}] converts to a penalty of ${penaltyWei} wei against a cost of ${costWei} wei at the current rate, which would allow a zero payout` + } + } + return undefined +} diff --git a/src/utils/daoProjectPayout.ts b/src/utils/daoProjectPayout.ts new file mode 100644 index 00000000..b6e72e19 --- /dev/null +++ b/src/utils/daoProjectPayout.ts @@ -0,0 +1,78 @@ +import { ethers } from 'ethers' +import { DaoMilestone, DaoProjectData } from '../@types' + +const WEI = 10n ** 18n + +/** + * Converts USD to wei at a fixed rate, not the live one. + * + * Payouts use the rate snapshotted at mint, so the DAO's exposure stays capped at what it minted and + * the contractor carries the price movement. Taking the rate as an argument rather than reading the + * network account also keeps this module clear of the utils barrel and its import cycle. + */ +export function usdToWeiAtRate(usdStr: string, rateUsdStr: string): bigint { + const rate = ethers.parseEther(rateUsdStr) + if (rate === 0n) throw new Error('Project rate is zero; cannot convert USD to LIB') + return (ethers.parseEther(usdStr) * WEI) / rate +} + +export type MilestoneDeliverySpeed = 'early' | 'ontime' | 'late' + +/** + * Classifies a completed milestone against its planned duration. + * + * Both comparisons are strict, so landing exactly on a threshold is on time — the DAO neither pays a + * bonus nor levies a penalty for a boundary case. + */ +export function classifyDelivery(actualDuration: number, plannedDuration: number, bonusPercentage: number, penaltyPercentage: number): MilestoneDeliverySpeed { + const earlyCutoff = plannedDuration * (1 - bonusPercentage / 100) + const lateCutoff = plannedDuration * (1 + penaltyPercentage / 100) + if (actualDuration < earlyCutoff) return 'early' + if (actualDuration > lateCutoff) return 'late' + return 'ontime' +} + +export interface MilestonePayout { + speed: MilestoneDeliverySpeed + /** What the contractor is owed for this milestone, in wei, after bonus or penalty. */ + amountWei: bigint +} + +/** + * What a completed milestone pays out. + * + * Takes the project rather than a rate or a converter, so a payout cannot be computed at the live + * rate by mistake. That error has been made here once already. + * + * A payout of zero is unreachable — `penalty < cost` is enforced at proposal creation and repeated + * in wei at dao_project_start — which is what lets `paid > 0n` mean "settled" in + * dao_project_milestone_claim. The zero floor below is kept as the only thing between a future gap + * in those checks and a negative payout. + */ +export function milestonePayoutWei(milestone: DaoMilestone, project: DaoProjectData): MilestonePayout { + const usdStrToWei = (usdStr: string): bigint => usdToWeiAtRate(usdStr, project.rateUsdStr) + + // Fail closed: defaulting a missing timestamp to zero made the duration negative, which reads as + // `early` and pays cost plus bonus. The inversion check is separate because the two times are + // proposed and endorsed independently and nothing else compares them — equal times are a + // legitimate zero-length milestone, inverted ones are not. + if (milestone.startTime === undefined || milestone.endTime === undefined) { + throw new Error('Milestone is missing a start or end time; cannot compute a payout') + } + if (milestone.endTime < milestone.startTime) { + throw new Error(`Milestone end time (${milestone.endTime}) is before its start time (${milestone.startTime}); cannot compute a payout`) + } + const actualDuration = milestone.endTime - milestone.startTime + const speed = classifyDelivery(actualDuration, milestone.duration, project.durationBonusPercentage, project.durationPenaltyPercentage) + + const cost = usdStrToWei(milestone.costUsdStr) + if (speed === 'early') { + return { speed, amountWei: cost + usdStrToWei(milestone.bonusUsdStr) } + } + if (speed === 'late') { + const penalty = usdStrToWei(milestone.penaltyUsdStr) + // Unreachable while the creation rule and the start-time guard both hold — see the note above. + return { speed, amountWei: penalty >= cost ? 0n : cost - penalty } + } + return { speed, amountWei: cost } +} diff --git a/src/utils/daoProjectTxContext.ts b/src/utils/daoProjectTxContext.ts new file mode 100644 index 00000000..5c6a0284 --- /dev/null +++ b/src/utils/daoProjectTxContext.ts @@ -0,0 +1,65 @@ +import { WrappedStates, UserAccount, DaoProposalAccount, DaoProjectData, DaoMilestone } from '../@types' +import { isUserAccount, isDaoProposalAccount } from '../@types/accountTypeGuards' +import { resolveMilestone } from './daoProjectMilestoneState' + +interface ProjectTxContextError { + error: string + from?: undefined + proposal?: undefined + project?: undefined + milestone?: undefined + milestoneIndex?: undefined +} + +interface ProjectTxContextLoaded { + error?: undefined + from: UserAccount + proposal: DaoProposalAccount + project: DaoProjectData + milestone?: DaoMilestone + milestoneIndex?: number +} + +/** + * Either the loaded accounts or the reason they could not be loaded, never a mix of both. + * + * A union rather than a bag of optionals so a caller cannot reach `project` without having handled + * `error` first — previously the early return carried all the safety by convention, with no help + * from the type. + */ +export type ProjectTxContext = ProjectTxContextError | ProjectTxContextLoaded + +/** + * The preamble every project transaction repeats: load the accounts, confirm this really is a + * project proposal, and resolve the milestone number when one was supplied. + * + * Deliberately says nothing about project status. The eight project transactions use five different + * status rules between them, so a shared default served half of them and had to be overridden by the + * rest — and that default caused the bug this helper was meant to prevent, silently applying + * `executing` to the claim handler and blocking the post-end claim the balance is trimmed for. Each + * handler now states its own rule inline, where a reviewer reads it alongside the other guards. + */ +export function loadProjectTxContext(wrappedStates: WrappedStates, fromAddress: string, proposalId: string, milestoneNumber?: unknown): ProjectTxContext { + const from = wrappedStates[fromAddress]?.data as UserAccount + const proposal = wrappedStates[proposalId]?.data as DaoProposalAccount + + if (!from || !isUserAccount(from)) { + return { error: 'from account not found or is not a UserAccount' } + } + if (!proposal || !isDaoProposalAccount(proposal)) { + return { error: 'Proposal account not found or is not a DaoProposalAccount' } + } + if (proposal.proposalType !== 'project') { + return { error: `Proposal type "${proposal.proposalType}" is not a project` } + } + if (!proposal.project) { + return { error: 'Project proposal is missing its project data' } + } + + if (milestoneNumber === undefined) { + return { from, proposal, project: proposal.project } + } + const resolved = resolveMilestone(proposal.project, milestoneNumber) + if (resolved.error) return { error: resolved.error } + return { from, proposal, project: proposal.project, milestone: resolved.milestone, milestoneIndex: resolved.index } +} diff --git a/test/daoProjectEndorsement.test.ts b/test/daoProjectEndorsement.test.ts new file mode 100644 index 00000000..c642ab9b --- /dev/null +++ b/test/daoProjectEndorsement.test.ts @@ -0,0 +1,265 @@ +import { + applyEndorsement, + planAddressEndorsement, + planMilestoneTimeEndorsement, + PROJECT_ENDORSEMENT_THRESHOLD, + requiredEndorsements, + writeOnceError, +} from '../src/utils/daoProjectEndorsement' + +const C1 = 'c1' +const C2 = 'c2' +const C3 = 'c3' +const C4 = 'c4' +const COMMITTEE = [C1, C2, C3, C4] +const CONTRACTOR = 'contractor' + +describe('requiredEndorsements', () => { + test('is the flat threshold on a normally-sized committee', () => { + expect(requiredEndorsements(4, true)).toBe(PROJECT_ENDORSEMENT_THRESHOLD) + expect(requiredEndorsements(4, false)).toBe(PROJECT_ENDORSEMENT_THRESHOLD) + expect(requiredEndorsements(10, true)).toBe(PROJECT_ENDORSEMENT_THRESHOLD) + }) + + test('clamps to what is reachable, and the reachable max differs by path', () => { + // Committee-only: at most committeeSize can ever endorse. + expect(requiredEndorsements(2, false)).toBe(2) + // Contractor path: the contractor occupies index 0, so committeeSize + 1 is reachable — which + // is why clamping both paths to committeeSize would commit a milestone one endorsement early. + expect(requiredEndorsements(2, true)).toBe(3) + }) + + test('never drops below one, even with an empty committee', () => { + expect(requiredEndorsements(0, false)).toBe(1) + }) +}) + +describe('applyEndorsement', () => { + test('a committee proposer counts as the first endorsement', () => { + const endorsements: string[] = [] + const result = applyEndorsement(endorsements, C1, true, COMMITTEE, undefined, false) + expect(result.error).toBeUndefined() + expect(endorsements).toEqual([C1]) + expect(result.committed).toBe(false) + }) + + test('commits on the third distinct committee member', () => { + const e: string[] = [] + applyEndorsement(e, C1, true, COMMITTEE, undefined, false) + expect(applyEndorsement(e, C2, false, COMMITTEE, undefined, true).committed).toBe(false) + expect(applyEndorsement(e, C3, false, COMMITTEE, undefined, true).committed).toBe(true) + expect(e).toEqual([C1, C2, C3]) + }) + + test('the contractor may propose, and then two committee members complete it', () => { + // Three total, of which two are committee — exactly the policy's "endorsed by two committee + // members" for a contractor-proposed time. + const e: string[] = [] + applyEndorsement(e, CONTRACTOR, true, COMMITTEE, CONTRACTOR, false) + expect(e).toEqual([CONTRACTOR]) + expect(applyEndorsement(e, C1, false, COMMITTEE, CONTRACTOR, true).committed).toBe(false) + expect(applyEndorsement(e, C2, false, COMMITTEE, CONTRACTOR, true).committed).toBe(true) + }) + + test('the contractor cannot endorse, only propose', () => { + // Otherwise they could hold two of the three slots. + const e: string[] = [] + applyEndorsement(e, C1, true, COMMITTEE, CONTRACTOR, false) + const result = applyEndorsement(e, CONTRACTOR, false, COMMITTEE, CONTRACTOR, true) + expect(result.error).toMatch('may propose a value but not endorse') + expect(e).toEqual([C1]) + }) + + test('a stranger can neither propose nor endorse', () => { + const e: string[] = [] + expect(applyEndorsement(e, 'nobody', true, COMMITTEE, CONTRACTOR, false).error).toMatch('committee member or the contractor') + expect(applyEndorsement(e, 'nobody', false, COMMITTEE, CONTRACTOR, false).error).toMatch('committee member or the contractor') + expect(e).toEqual([]) + }) + + test('a second proposal replaces the pending value and re-seeds the endorsements', () => { + // applyEndorsement itself allows this — whether a path may re-propose at all is writeOnceError's + // decision, applied by the caller. This is the contractor address path's behaviour, per policy. + const e: string[] = [] + applyEndorsement(e, C1, true, COMMITTEE, undefined, false) + applyEndorsement(e, C2, false, COMMITTEE, undefined, true) + expect(e).toEqual([C1, C2]) + + applyEndorsement(e, C3, true, COMMITTEE, undefined, true) + expect(e).toEqual([C3]) + }) + + test('the same address cannot endorse twice', () => { + const e: string[] = [] + applyEndorsement(e, C1, true, COMMITTEE, undefined, false) + applyEndorsement(e, C2, false, COMMITTEE, undefined, true) + const result = applyEndorsement(e, C2, false, COMMITTEE, undefined, true) + expect(result.error).toMatch('already endorsed') + expect(e).toEqual([C1, C2]) + }) + + test('the proposer cannot pad the count by endorsing their own proposal', () => { + const e: string[] = [] + applyEndorsement(e, C1, true, COMMITTEE, undefined, false) + expect(applyEndorsement(e, C1, false, COMMITTEE, undefined, true).error).toMatch('already endorsed') + expect(e).toEqual([C1]) + }) + + test('endorsing nothing is rejected', () => { + const e: string[] = [] + expect(applyEndorsement(e, C1, false, COMMITTEE, undefined, false).error).toMatch('no proposed value') + expect(e).toEqual([]) + }) + + test('commits early on a committee too small to reach the threshold', () => { + // Degrade rather than deadlock: with two members and no contractor path, two is everything. + const small = [C1, C2] + const e: string[] = [] + applyEndorsement(e, C1, true, small, undefined, false) + expect(applyEndorsement(e, C2, false, small, undefined, true).committed).toBe(true) + }) + + test('a committee member on the contractor path still needs three', () => { + const small = [C1, C2] + const e: string[] = [] + applyEndorsement(e, C1, true, small, CONTRACTOR, false) + // committeeSize + 1 = 3 is reachable here, so it must not commit at two. + expect(applyEndorsement(e, C2, false, small, CONTRACTOR, true).committed).toBe(false) + }) + + test('C4 exists so the committee is larger than the threshold', () => { + expect(COMMITTEE).toContain(C4) + expect(COMMITTEE.length).toBeGreaterThan(PROJECT_ENDORSEMENT_THRESHOLD) + }) +}) + +describe('writeOnceError', () => { + // The milestone paths apply this; the contractor address path deliberately does not. + test('allows the first proposal', () => { + expect(writeOnceError(false, true)).toBeUndefined() + }) + + test('rejects a second proposal', () => { + expect(writeOnceError(true, true)).toMatch('already been proposed') + }) + + test('applies to the contractor too, which is what the policy requires', () => { + // "The contractor can only call this once". applyEndorsement's contractor check only blocks + // endorsing, so without this they could re-propose each time the committee neared three, + // resetting the count and stalling their own milestone indefinitely. The rule is sender-blind, + // so it covers them by construction rather than by a separate case. + const e: string[] = [] + applyEndorsement(e, CONTRACTOR, true, COMMITTEE, CONTRACTOR, false) + applyEndorsement(e, C1, false, COMMITTEE, CONTRACTOR, true) + expect(e).toEqual([CONTRACTOR, C1]) + expect(writeOnceError(true, true)).toMatch('already been proposed') + }) + + test('never blocks an endorsement', () => { + // Endorsing a pending value is the whole point of the rule, so it must pass either way. + expect(writeOnceError(true, false)).toBeUndefined() + expect(writeOnceError(false, false)).toBeUndefined() + }) +}) + +describe('planMilestoneTimeEndorsement', () => { + // These target the function the handlers actually call. The applyEndorsement tests above cannot + // stand in for them: they take hasPendingValue as a parameter, so they passed throughout the bug + // where a caller computed it after mutating, and would pass again if it returned. + const milestone = (over: { proposedTime?: number; endorsedTime?: string[] } = {}) => ({ + proposedTime: over.proposedTime, + endorsedTime: over.endorsedTime ?? [], + }) + + test('a first proposed time seeds endorsement #1 and is not a write-once violation', () => { + // The exact regression. Reading the pending value after writing proposedTime made every opening + // proposal look like a re-proposal, so endorsedTime was never seeded and no milestone committed. + const plan = planMilestoneTimeEndorsement({ from: CONTRACTOR, proposedTime: 500 }, COMMITTEE, CONTRACTOR, milestone()) + expect(plan.error).toBeUndefined() + expect(plan.isProposing).toBe(true) + expect(plan.nextProposedTime).toBe(500) + expect(plan.nextEndorsements).toEqual([CONTRACTOR]) + }) + + test('a second proposed time is rejected and changes nothing', () => { + const plan = planMilestoneTimeEndorsement({ from: C1, proposedTime: 900 }, COMMITTEE, CONTRACTOR, milestone({ proposedTime: 500, endorsedTime: [CONTRACTOR] })) + expect(plan.error).toMatch('already been proposed') + expect(plan.nextEndorsements).toEqual([CONTRACTOR]) + }) + + test('a submission without a time endorses the pending one and keeps it', () => { + const plan = planMilestoneTimeEndorsement({ from: C1 }, COMMITTEE, CONTRACTOR, milestone({ proposedTime: 500, endorsedTime: [CONTRACTOR] })) + expect(plan.error).toBeUndefined() + expect(plan.isProposing).toBe(false) + expect(plan.nextProposedTime).toBe(500) + expect(plan.nextEndorsements).toEqual([CONTRACTOR, C1]) + }) + + test('the third endorsement commits', () => { + const plan = planMilestoneTimeEndorsement({ from: C2 }, COMMITTEE, CONTRACTOR, milestone({ proposedTime: 500, endorsedTime: [CONTRACTOR, C1] })) + expect(plan.committed).toBe(true) + }) + + test('the contractor may open a proposal but not endorse one', () => { + const plan = planMilestoneTimeEndorsement({ from: CONTRACTOR }, COMMITTEE, CONTRACTOR, milestone({ proposedTime: 500, endorsedTime: [C1] })) + expect(plan.error).toMatch('may propose a value but not endorse') + }) + + test('the same address cannot endorse twice', () => { + const plan = planMilestoneTimeEndorsement({ from: C1 }, COMMITTEE, CONTRACTOR, milestone({ proposedTime: 500, endorsedTime: [C1] })) + expect(plan.error).toMatch('already endorsed') + }) + + test('nextEndorsements is a fresh array, so applying it cannot alias live state', () => { + const m = milestone({ proposedTime: 500, endorsedTime: [CONTRACTOR] }) + const plan = planMilestoneTimeEndorsement({ from: C1 }, COMMITTEE, CONTRACTOR, m) + expect(plan.nextEndorsements).not.toBe(m.endorsedTime) + expect(m.endorsedTime).toEqual([CONTRACTOR]) + }) + + test('deciding never mutates what it was given', () => { + // The property the whole shape exists for: validate() can call this freely. + const m = milestone({ proposedTime: 500, endorsedTime: [CONTRACTOR] }) + planMilestoneTimeEndorsement({ from: C1, proposedTime: 900 }, COMMITTEE, CONTRACTOR, m) + expect(m).toEqual({ proposedTime: 500, endorsedTime: [CONTRACTOR] }) + }) +}) + +describe('planAddressEndorsement', () => { + const project = (over: { proposedAddress?: string; endorsedAddress?: string[] } = {}) => ({ + proposedAddress: over.proposedAddress, + endorsedAddress: over.endorsedAddress ?? [], + }) + const ADDRESS_A = 'address-a' + const ADDRESS_B = 'address-b' + + test('a second proposal is accepted and reseeds, unlike the milestone path', () => { + // Policy line 353. This is the difference the two helpers exist to make visible. + const plan = planAddressEndorsement({ from: C2, proposedAddress: ADDRESS_B }, COMMITTEE, project({ proposedAddress: ADDRESS_A, endorsedAddress: [C1] })) + expect(plan.error).toBeUndefined() + expect(plan.nextProposedAddress).toBe(ADDRESS_B) + expect(plan.nextEndorsements).toEqual([C2]) + }) + + test('a blank submission endorses the pending address', () => { + const plan = planAddressEndorsement({ from: C2 }, COMMITTEE, project({ proposedAddress: ADDRESS_A, endorsedAddress: [C1] })) + expect(plan.nextProposedAddress).toBe(ADDRESS_A) + expect(plan.nextEndorsements).toEqual([C1, C2]) + }) + + test('three distinct committee members commit the change', () => { + const plan = planAddressEndorsement({ from: C3 }, COMMITTEE, project({ proposedAddress: ADDRESS_A, endorsedAddress: [C1, C2] })) + expect(plan.committed).toBe(true) + }) + + test('the contractor has no say — only the committee may submit', () => { + const plan = planAddressEndorsement({ from: CONTRACTOR, proposedAddress: ADDRESS_A }, COMMITTEE, project()) + expect(plan.error).toMatch('committee member or the contractor') + }) + + test('deciding never mutates what it was given', () => { + const p = project({ proposedAddress: ADDRESS_A, endorsedAddress: [C1] }) + planAddressEndorsement({ from: C2, proposedAddress: ADDRESS_B }, COMMITTEE, p) + expect(p).toEqual({ proposedAddress: ADDRESS_A, endorsedAddress: [C1] }) + }) +}) diff --git a/test/daoProjectLog.test.ts b/test/daoProjectLog.test.ts new file mode 100644 index 00000000..89c5f14b --- /dev/null +++ b/test/daoProjectLog.test.ts @@ -0,0 +1,77 @@ +import { DaoProjectData } from '../src/@types' +import { appendProjectLog } from '../src/utils/daoProjectLog' + +function project(logs?: unknown): DaoProjectData { + return { logs } as unknown as DaoProjectData +} + +describe('appendProjectLog', () => { + test('records the caller, timestamp, type and params', () => { + const p = project([]) + appendProjectLog(p, 'committee-1', 1000, 'dao_project_milestone_claim', { milestoneNumber: 2 }) + expect(p.logs).toEqual([{ caller: 'committee-1', timestamp: 1000, txType: 'dao_project_milestone_claim', params: { milestoneNumber: 2 } }]) + }) + + test('initialises the array when it is absent', () => { + // Projects created before the log existed deserialise without it. + const p = project(undefined) + appendProjectLog(p, 'committee-1', 1000, 'dao_project_end') + expect(p.logs).toHaveLength(1) + }) + + test('a transaction with nothing to identify it records empty params, not a missing field', () => { + const p = project([]) + appendProjectLog(p, 'committee-1', 1000, 'dao_project_start') + expect(p.logs[0].params).toEqual({}) + }) + + test('appends rather than replacing, so the trail is complete', () => { + const p = project([]) + appendProjectLog(p, 'committee-1', 1000, 'dao_project_milestone_start', { milestoneNumber: 1, proposedTime: 500 }) + appendProjectLog(p, 'committee-2', 2000, 'dao_project_milestone_start', { milestoneNumber: 1 }) + expect(p.logs.map((l) => l.caller)).toEqual(['committee-1', 'committee-2']) + }) + + test('a proposal records its time and an endorsement does not, so the two are distinguishable', () => { + // Under the write-once rule a milestone path can only be proposed once, so the presence of + // proposedTime tells a reader which entry opened the question. + const p = project([]) + appendProjectLog(p, 'committee-1', 1000, 'dao_project_milestone_end', { milestoneNumber: 1, proposedTime: 500 }) + appendProjectLog(p, 'committee-2', 2000, 'dao_project_milestone_end', { milestoneNumber: 1 }) + expect(p.logs[0].params.proposedTime).toBe(500) + expect(p.logs[1].params.proposedTime).toBeUndefined() + }) + + test('every address-change entry names the address its sender supported', () => { + // The rebind rule makes both paths carry it, so the log records support rather than mode. + const p = project([]) + appendProjectLog(p, 'committee-1', 1000, 'dao_project_change_address', { proposedAddress: 'abc' }) + appendProjectLog(p, 'committee-2', 2000, 'dao_project_change_address', { proposedAddress: 'abc' }) + expect(p.logs.map((l) => l.params.proposedAddress)).toEqual(['abc', 'abc']) + }) + + test('params hold no computed outcomes, so no value is ever a bigint', () => { + // Guards the drift most likely to creep back: paid, minted, owed and reclaimed are products of + // the handler, recoverable from the account state and the receipt, and are not bigint-safe here. + const p = project([]) + appendProjectLog(p, 'contractor', 1000, 'dao_project_milestone_claim', { milestoneNumber: 3 }) + for (const value of Object.values(p.logs[0].params)) { + expect(typeof value).not.toBe('bigint') + } + expect(p.logs[0].params).not.toHaveProperty('paidWei') + }) +}) + +describe('address-change entries record the address supported', () => { + // The mode is not recoverable from an entry and deliberately is not recorded; who backed which + // address is what a dispute turns on, so that must survive on both paths. + test('a blank endorsement records the pending address, not undefined', () => { + const pendingAddress = 'pending-address' + const tx: { proposedAddress?: string } = {} + const supported = tx.proposedAddress ?? pendingAddress + const p = project([]) + appendProjectLog(p, 'committee-1', 1000, 'dao_project_change_address', { proposedAddress: supported }) + expect(p.logs[0].params.proposedAddress).toBe(pendingAddress) + expect(Object.values(p.logs[0].params)).not.toContain(undefined) + }) +}) diff --git a/test/daoProjectMilestoneState.test.ts b/test/daoProjectMilestoneState.test.ts new file mode 100644 index 00000000..06ed2433 --- /dev/null +++ b/test/daoProjectMilestoneState.test.ts @@ -0,0 +1,113 @@ +import { DaoProjectData } from '../src/@types' +import { allMilestonesFinished, canStartMilestone, findExecutingMilestone, findNextPendingMilestone, resolveMilestone } from '../src/utils/daoProjectMilestoneState' + +function project(...statuses: string[]): DaoProjectData { + return { milestones: statuses.map((status) => ({ status })) } as unknown as DaoProjectData +} + +describe('resolveMilestone', () => { + test('maps 1-based transaction numbers onto the 0-based array', () => { + const p = project('pending', 'pending', 'pending') + expect(resolveMilestone(p, 1).index).toBe(0) + expect(resolveMilestone(p, 3).index).toBe(2) + }) + + test('rejects both boundaries rather than returning undefined', () => { + // An off-by-one here misroutes a payment, so 0 and length + 1 are explicit rejections. + const p = project('pending', 'pending') + expect(resolveMilestone(p, 0).error).toMatch('outside the range') + expect(resolveMilestone(p, 3).error).toMatch('outside the range') + }) + + test('rejects non-integers', () => { + const p = project('pending') + expect(resolveMilestone(p, 1.5).error).toMatch('must be an integer') + expect(resolveMilestone(p, '1').error).toMatch('must be an integer') + expect(resolveMilestone(p, undefined).error).toMatch('must be an integer') + }) +}) + +describe('canStartMilestone', () => { + test('the first milestone can always start', () => { + expect(canStartMilestone(project('pending', 'pending'), 0)).toBeUndefined() + }) + + test('a later milestone waits for every earlier one to finish', () => { + expect(canStartMilestone(project('executing', 'pending'), 1)).toMatch('Milestone 1 is still executing') + expect(canStartMilestone(project('pending', 'pending'), 1)).toMatch('Milestone 1 is still pending') + }) + + test('completed and terminated both count as finished', () => { + expect(canStartMilestone(project('completed', 'pending'), 1)).toBeUndefined() + expect(canStartMilestone(project('terminated', 'pending'), 1)).toBeUndefined() + }) + + test('checks every earlier milestone, not just the previous one', () => { + // Closes the case where an earlier milestone was somehow left pending. + expect(canStartMilestone(project('pending', 'completed', 'pending'), 2)).toMatch('Milestone 1') + }) +}) + +describe('allMilestonesFinished', () => { + test('true only when nothing can still start or finish', () => { + expect(allMilestonesFinished(project('completed', 'terminated'))).toBe(true) + expect(allMilestonesFinished(project('completed', 'executing'))).toBe(false) + expect(allMilestonesFinished(project('pending'))).toBe(false) + }) +}) + +describe('findNextPendingMilestone', () => { + test('is the first pending milestone, not merely the first unfinished one', () => { + expect(findNextPendingMilestone(project('completed', 'pending', 'pending')).index).toBe(1) + }) + + test('skips past finished milestones of either kind', () => { + expect(findNextPendingMilestone(project('completed', 'terminated', 'pending')).index).toBe(2) + }) + + test('errors rather than returning a milestone when none is pending', () => { + expect(findNextPendingMilestone(project('completed', 'executing')).error).toMatch('No milestone is pending') + }) + + test('an executing milestone shifts the target, and canStartMilestone then rejects it', () => { + // This is the retargeting window: once milestone 1 starts, milestone 2 becomes the derived + // target. What makes it safe is not that the target holds still, but that the order check + // refuses a milestone whose predecessor is merely executing rather than finished. + const p = project('executing', 'pending') + const next = findNextPendingMilestone(p) + expect(next.index).toBe(1) + expect(canStartMilestone(p, next.index)).toMatch('still executing') + }) +}) + +describe('findExecutingMilestone', () => { + test('is the executing milestone', () => { + expect(findExecutingMilestone(project('completed', 'executing', 'pending')).index).toBe(1) + }) + + test('errors when nothing is executing', () => { + expect(findExecutingMilestone(project('completed', 'pending')).error).toMatch('nothing to end') + }) + + test('only one milestone can be executing, so the derivation is unambiguous', () => { + // Guaranteed by canStartMilestone, which will not start a milestone while an earlier one is + // unfinished. Asserted here so the invariant this derivation rests on is written down. + const p = project('executing', 'pending') + expect(canStartMilestone(p, 1)).toBeDefined() + }) +}) + +describe('findExecutingMilestone uniqueness', () => { + test('fails closed when more than one milestone is executing', () => { + // Unreachable while canStartMilestone holds. Asserted because "the current milestone" has to + // resolve to one milestone: silently taking the earliest would end the wrong one and write a + // payout against it. + const error = findExecutingMilestone(project('executing', 'executing')).error + expect(error).toMatch('are all executing') + expect(error).toMatch('1, 2') + }) + + test('still resolves the single executing milestone', () => { + expect(findExecutingMilestone(project('completed', 'executing', 'pending')).index).toBe(1) + }) +}) diff --git a/test/daoProjectMilestones.test.ts b/test/daoProjectMilestones.test.ts new file mode 100644 index 00000000..5378e027 --- /dev/null +++ b/test/daoProjectMilestones.test.ts @@ -0,0 +1,126 @@ +import { validateDaoOptions } from '../src/utils/daoBallotOptions' +import { MAX_MILESTONE_TEXT_LENGTH, MAX_MILESTONE_TITLE_LENGTH, MAX_PROJECT_MILESTONES, validateProjectMilestones } from '../src/utils/daoProjectMilestones' + +function milestone(over: Record = {}): unknown { + return { + title: 'Deliver the thing', + description: 'Build it', + deliverable: 'A working thing', + duration: 86_400_000, + costUsdStr: '1000', + penaltyUsdStr: '100', + bonusUsdStr: '50', + ...over, + } +} + +describe('validateProjectMilestones', () => { + test('accepts a well-formed milestone array', () => { + expect(validateProjectMilestones([milestone(), milestone()])).toBeUndefined() + }) + + test('rejects an empty or non-array milestones field', () => { + expect(validateProjectMilestones([])).toMatch('non-empty array') + expect(validateProjectMilestones(undefined)).toMatch('non-empty array') + expect(validateProjectMilestones('milestones')).toMatch('non-empty array') + }) + + test('caps the milestone count', () => { + // The proposer picks the size of an account that every later project tx rewrites and re-hashes. + const atLimit = Array.from({ length: MAX_PROJECT_MILESTONES }, () => milestone()) + expect(validateProjectMilestones(atLimit)).toBeUndefined() + expect(validateProjectMilestones([...atLimit, milestone()])).toMatch('exceeding the maximum') + }) + + test('rejects a non-object entry', () => { + expect(validateProjectMilestones([null])).toMatch('must be an object') + expect(validateProjectMilestones([['a']])).toMatch('must be an object') + }) + + test('bounds the text fields, and rejects whitespace-only', () => { + expect(validateProjectMilestones([milestone({ title: '' })])).toMatch('title') + expect(validateProjectMilestones([milestone({ title: ' ' })])).toMatch('title') + expect(validateProjectMilestones([milestone({ title: 'x'.repeat(MAX_MILESTONE_TITLE_LENGTH + 1) })])).toMatch('title') + expect(validateProjectMilestones([milestone({ description: 'x'.repeat(MAX_MILESTONE_TEXT_LENGTH + 1) })])).toMatch('description') + expect(validateProjectMilestones([milestone({ deliverable: '' })])).toMatch('deliverable') + }) + + test('requires a positive finite duration', () => { + // Zero or NaN would make the bonus/penalty comparison meaningless rather than merely odd. + for (const duration of [0, -1, NaN, Infinity, '86400000']) { + expect(validateProjectMilestones([milestone({ duration })])).toMatch('positive finite number') + } + }) + + test('requires parseable non-negative USD strings', () => { + expect(validateProjectMilestones([milestone({ costUsdStr: 'abc' })])).toMatch('not a valid decimal USD string') + expect(validateProjectMilestones([milestone({ bonusUsdStr: '' })])).toMatch('non-empty USD string') + expect(validateProjectMilestones([milestone({ penaltyUsdStr: 1000 })])).toMatch('non-empty USD string') + expect(validateProjectMilestones([milestone({ costUsdStr: '-5' })])).toMatch('must not be negative') + }) + + test('requires the penalty to be smaller than the cost', () => { + // A penalty reduces a payment rather than erasing it, and the rule is what keeps every payout + // branch strictly positive so `paid > 0n` can mean "settled". + expect(validateProjectMilestones([milestone({ costUsdStr: '1000', penaltyUsdStr: '999.999999999999999999' })])).toBeUndefined() + expect(validateProjectMilestones([milestone({ costUsdStr: '1000', penaltyUsdStr: '1000' })])).toMatch('must be less than') + expect(validateProjectMilestones([milestone({ costUsdStr: '1000', penaltyUsdStr: '1001' })])).toMatch('must be less than') + }) + + test('names both values when the penalty rule fails', () => { + const error = validateProjectMilestones([milestone({ costUsdStr: '40', penaltyUsdStr: '50' })]) + expect(error).toMatch('"50"') + expect(error).toMatch('"40"') + }) + + test('rejects a zero cost, which the penalty rule implies', () => { + // Previously allowed as a checkpoint-only milestone. Since penalty >= 0 is already enforced, + // penalty < cost makes cost > 0 unreachable-by-construction rather than separately checked. + expect(validateProjectMilestones([milestone({ costUsdStr: '0', penaltyUsdStr: '0', bonusUsdStr: '0' })])).toMatch('must be less than') + }) + + test('a zero penalty is still allowed against a positive cost', () => { + expect(validateProjectMilestones([milestone({ costUsdStr: '1000', penaltyUsdStr: '0' })])).toBeUndefined() + }) + + test('reports the index of the offending milestone', () => { + const error = validateProjectMilestones([milestone(), milestone({ duration: 0 })]) + expect(error).toMatch('milestones[1]') + expect(validateProjectMilestones([milestone(), milestone({ costUsdStr: '10', penaltyUsdStr: '20' })])).toMatch('milestones[1]') + }) +}) + +describe('validateDaoOptions for project proposals', () => { + test('accepts exactly two options', () => { + expect(validateDaoOptions(['no', 'yes'], 'project')).toBeUndefined() + expect(validateDaoOptions(['no', 'fund the build'], 'project')).toBeUndefined() + }) + + test('rejects three or more options for a project', () => { + // A project has one flat milestone array, so a third option would select nothing. + expect(validateDaoOptions(['no', 'a', 'b'], 'project')).toMatch('exactly 2 entries') + }) + + test('leaves other proposal types multi-option', () => { + expect(validateDaoOptions(['no', 'a', 'b'], 'governance')).toBeUndefined() + expect(validateDaoOptions(['no', 'a', 'b'])).toBeUndefined() + }) + + test('still enforces the negative-first rule for projects', () => { + expect(validateDaoOptions(['yes', 'no'], 'project')).toMatch('rejection choice') + }) +}) + +describe('milestone settled marker', () => { + // `paid > 0n` is the settled marker, which is only sound while a zero payout is unreachable. + // These assert the two rules that make it so, at the boundary where each one bites. + test('the creation rule makes a zero-cost milestone invalid', () => { + expect(validateProjectMilestones([milestone({ costUsdStr: '0', penaltyUsdStr: '0' })])).toBeDefined() + }) + + test('the creation rule leaves a strictly positive late payout', () => { + // cost - penalty > 0 for every pair the rule admits, so a claim can never settle at zero. + expect(validateProjectMilestones([milestone({ costUsdStr: '50', penaltyUsdStr: '49.999999999999999999' })])).toBeUndefined() + expect(validateProjectMilestones([milestone({ costUsdStr: '50', penaltyUsdStr: '50' })])).toBeDefined() + }) +}) diff --git a/test/daoProjectMint.test.ts b/test/daoProjectMint.test.ts new file mode 100644 index 00000000..e55ad591 --- /dev/null +++ b/test/daoProjectMint.test.ts @@ -0,0 +1,131 @@ +import { ethers } from 'ethers' +import { LiberdusFlags } from '../src/config' +import { DaoMilestone } from '../src/@types' +import { degenerateMilestoneAtRate, exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../src/utils/daoProjectMint' + +const original = LiberdusFlags.daoMaxMintThresholdLibStr + +afterEach(() => { + LiberdusFlags.daoMaxMintThresholdLibStr = original +}) + +describe('maxMintThresholdWei', () => { + test('parses the configured LIB string exactly', () => { + LiberdusFlags.daoMaxMintThresholdLibStr = '1000000' + expect(maxMintThresholdWei()).toBe(ethers.parseEther('1000000')) + }) + + test('keeps full precision on fractional values', () => { + // Parsing the decimal string directly avoids the float rounding a Number would introduce. + LiberdusFlags.daoMaxMintThresholdLibStr = '0.000000000000000001' + expect(maxMintThresholdWei()).toBe(1n) + }) + + test('throws on a malformed value rather than falling back', () => { + // A ceiling that silently becomes something other than what was configured is worse than a + // failed transaction, so this must not default. + LiberdusFlags.daoMaxMintThresholdLibStr = 'not-a-number' + expect(() => maxMintThresholdWei()).toThrow('not a valid decimal LIB string') + }) + + test('throws on a negative value', () => { + LiberdusFlags.daoMaxMintThresholdLibStr = '-1' + expect(() => maxMintThresholdWei()).toThrow('must not be negative') + }) + + test('zero is valid and blocks every mint', () => { + // A deliberate kill switch, distinct from a malformed value. + LiberdusFlags.daoMaxMintThresholdLibStr = '0' + expect(maxMintThresholdWei()).toBe(0n) + expect(exceedsMintThreshold(1n)).toBe(true) + expect(exceedsMintThreshold(0n)).toBe(false) + }) +}) + +describe('exceedsMintThreshold', () => { + test('is strictly greater — a mint exactly at the ceiling is allowed', () => { + LiberdusFlags.daoMaxMintThresholdLibStr = '100' + const ceiling = ethers.parseEther('100') + expect(exceedsMintThreshold(ceiling - 1n)).toBe(false) + expect(exceedsMintThreshold(ceiling)).toBe(false) + expect(exceedsMintThreshold(ceiling + 1n)).toBe(true) + }) + + test('tracks a runtime change to the flag', () => { + // The flag is settable via /debug-set-liberdus-flag, so the value is read per call rather + // than captured at module load. + LiberdusFlags.daoMaxMintThresholdLibStr = '10' + expect(exceedsMintThreshold(ethers.parseEther('50'))).toBe(true) + LiberdusFlags.daoMaxMintThresholdLibStr = '100' + expect(exceedsMintThreshold(ethers.parseEther('50'))).toBe(false) + }) +}) + +describe('projectMintAmountWei', () => { + // Stand-in for utils.usdStrToWei at a 1:1 rate, so the arithmetic under test is the summing. + const toWei = (usdStr: string): bigint => ethers.parseEther(usdStr) + + function milestone(costUsdStr: string, bonusUsdStr: string, penaltyUsdStr = '0'): DaoMilestone { + return { costUsdStr, bonusUsdStr, penaltyUsdStr } as DaoMilestone + } + + test('sums cost plus bonus across milestones', () => { + const total = projectMintAmountWei([milestone('100', '10'), milestone('200', '20')], toWei) + expect(total).toBe(ethers.parseEther('330')) + }) + + test('excludes penalties', () => { + // A penalty only ever reduces what a contractor is paid. Folding it in would inflate the escrow + // and mint more than the project can legitimately pay out. + const withPenalty = projectMintAmountWei([milestone('100', '10', '999')], toWei) + expect(withPenalty).toBe(ethers.parseEther('110')) + }) + + test('is zero for milestones that pay nothing', () => { + expect(projectMintAmountWei([milestone('0', '0')], toWei)).toBe(0n) + }) + + test('propagates a malformed amount rather than silently skipping it', () => { + // Never mint on a total we could not compute. + expect(() => projectMintAmountWei([milestone('abc', '0')], toWei)).toThrow() + }) +}) + +describe('degenerateMilestoneAtRate', () => { + function milestone(costUsdStr: string, penaltyUsdStr: string): DaoMilestone { + return { costUsdStr, penaltyUsdStr, bonusUsdStr: '0' } as DaoMilestone + } + + // Converts USD to wei the way the handler does: parseEther(usd) * 1e18 / parseEther(rate), + // one truncating division per amount, which is where distinct USD values can collapse together. + const atRate = + (rateUsdStr: string) => + (usdStr: string): bigint => + (ethers.parseEther(usdStr) * 10n ** 18n) / ethers.parseEther(rateUsdStr) + + test('accepts milestones that convert to distinct wei values', () => { + expect(degenerateMilestoneAtRate([milestone('1000', '100')], atRate('1'))).toBeUndefined() + }) + + test('rejects a pair that is valid in USD but collapses to the same wei value', () => { + // The case the creation-time rule cannot see: penalty < cost as decimal strings, yet both + // truncate to zero once divided by a large rate. + const milestones = [milestone('0.000000000000000002', '0.000000000000000001')] + expect(degenerateMilestoneAtRate(milestones, atRate('1'))).toBeUndefined() + expect(degenerateMilestoneAtRate(milestones, atRate('1000000'))).toMatch('zero payout') + }) + + test('rejects when both amounts truncate to zero', () => { + const error = degenerateMilestoneAtRate([milestone('0.000000000000000001', '0.000000000000000001')], atRate('1000000')) + expect(error).toMatch('0 wei') + }) + + test('names the offending milestone index', () => { + const milestones = [milestone('1000', '100'), milestone('10', '20')] + expect(degenerateMilestoneAtRate(milestones, atRate('1'))).toMatch('milestones[1]') + }) + + test('propagates a malformed amount rather than passing the milestone', () => { + expect(() => degenerateMilestoneAtRate([milestone('abc', '1')], atRate('1'))).toThrow() + }) +}) diff --git a/test/daoProjectPayout.test.ts b/test/daoProjectPayout.test.ts new file mode 100644 index 00000000..10a0e4b7 --- /dev/null +++ b/test/daoProjectPayout.test.ts @@ -0,0 +1,125 @@ +import { ethers } from 'ethers' +import { DaoMilestone, DaoProjectData } from '../src/@types' +import { classifyDelivery, milestonePayoutWei, usdToWeiAtRate } from '../src/utils/daoProjectPayout' + +const DAY = 86_400_000 + +function milestone(over: Partial = {}): DaoMilestone { + return { duration: 10 * DAY, costUsdStr: '1000', bonusUsdStr: '100', penaltyUsdStr: '200', startTime: 0, endTime: 10 * DAY, ...over } as DaoMilestone +} + +// The payout reads its rate and both percentages from the project, so rate variation is expressed +// by varying rateUsdStr — the same way the handlers do it. +function project(over: Partial = {}): DaoProjectData { + return { rateUsdStr: '1', durationBonusPercentage: 20, durationPenaltyPercentage: 20, ...over } as DaoProjectData +} + +describe('classifyDelivery', () => { + test('classifies against the ±percentage bands', () => { + expect(classifyDelivery(7 * DAY, 10 * DAY, 20, 20)).toBe('early') + expect(classifyDelivery(10 * DAY, 10 * DAY, 20, 20)).toBe('ontime') + expect(classifyDelivery(13 * DAY, 10 * DAY, 20, 20)).toBe('late') + }) + + test('the boundaries themselves are on time', () => { + // Strict comparisons, so landing exactly on a threshold earns neither bonus nor penalty. + expect(classifyDelivery(8 * DAY, 10 * DAY, 20, 20)).toBe('ontime') + expect(classifyDelivery(12 * DAY, 10 * DAY, 20, 20)).toBe('ontime') + // One millisecond past each is not. + expect(classifyDelivery(8 * DAY - 1, 10 * DAY, 20, 20)).toBe('early') + expect(classifyDelivery(12 * DAY + 1, 10 * DAY, 20, 20)).toBe('late') + }) + + test('an instant delivery is early and an enormous overrun is late', () => { + expect(classifyDelivery(0, 10 * DAY, 20, 20)).toBe('early') + expect(classifyDelivery(1000 * DAY, 10 * DAY, 20, 20)).toBe('late') + }) +}) + +describe('milestonePayoutWei', () => { + test('early pays cost plus bonus', () => { + const result = milestonePayoutWei(milestone({ endTime: 5 * DAY }), project()) + expect(result.speed).toBe('early') + expect(result.amountWei).toBe(ethers.parseEther('1100')) + }) + + test('on time pays the plain cost', () => { + const result = milestonePayoutWei(milestone(), project()) + expect(result.speed).toBe('ontime') + expect(result.amountWei).toBe(ethers.parseEther('1000')) + }) + + test('late pays cost minus penalty, with no bonus', () => { + const result = milestonePayoutWei(milestone({ endTime: 20 * DAY }), project()) + expect(result.speed).toBe('late') + expect(result.amountWei).toBe(ethers.parseEther('800')) + }) + + test('a penalty larger than the cost floors at zero rather than inverting', () => { + // Without the floor the subtraction would go negative and read as a credit to the contractor. + const result = milestonePayoutWei(milestone({ endTime: 20 * DAY, penaltyUsdStr: '5000' }), project()) + expect(result.amountWei).toBe(0n) + }) + + test('a penalty exactly equal to the cost also pays zero', () => { + const result = milestonePayoutWei(milestone({ endTime: 20 * DAY, penaltyUsdStr: '1000' }), project()) + expect(result.amountWei).toBe(0n) + }) + + test('percentages are per-project, so the same timing can pay differently', () => { + // 12 days against a 10-day plan: on time at ±20%, late at ±10%. + const m = milestone({ endTime: 12 * DAY }) + expect(milestonePayoutWei(m, project()).speed).toBe('ontime') + expect(milestonePayoutWei(m, project({ durationBonusPercentage: 10, durationPenaltyPercentage: 10 })).speed).toBe('late') + }) +}) + +describe('usdToWeiAtRate', () => { + test('converts at the supplied rate, not a live one', () => { + expect(usdToWeiAtRate('100', '1')).toBe(ethers.parseEther('100')) + // A LIB worth $0.50 means twice as much LIB for the same USD. + expect(usdToWeiAtRate('100', '0.5')).toBe(ethers.parseEther('200')) + }) + + test('rejects a zero rate instead of dividing by it', () => { + expect(() => usdToWeiAtRate('100', '0')).toThrow('rate is zero') + }) +}) + +describe('the payout always uses the project rate', () => { + test('the same milestone pays differently under a different stored rate', () => { + // The regression this guards: computing a payout at the live rate instead of the one snapshotted + // at mint. That shipped once. Taking the project rather than a converter makes it unexpressible, + // and this pins the rate as the thing that moves the number. + const m = milestone() + expect(milestonePayoutWei(m, project({ rateUsdStr: '1' })).amountWei).toBe(ethers.parseEther('1000')) + expect(milestonePayoutWei(m, project({ rateUsdStr: '0.5' })).amountWei).toBe(ethers.parseEther('2000')) + }) +}) + +describe('a milestone that cannot state its duration pays nothing', () => { + // Fail closed. Defaulting a missing timestamp to zero made the duration hugely negative, which + // classifies as `early` — so the least trustworthy data earned the most generous payout. + test('a missing start or end time throws rather than defaulting', () => { + expect(() => milestonePayoutWei(milestone({ startTime: undefined }), project())).toThrow('missing a start or end time') + expect(() => milestonePayoutWei(milestone({ endTime: undefined }), project())).toThrow('missing a start or end time') + }) + + test('an end time before the start time throws', () => { + // Not implied by both being present: the two are proposed and endorsed separately, and nothing + // else compares them. + expect(() => milestonePayoutWei(milestone({ startTime: 5 * DAY, endTime: DAY }), project())).toThrow('is before its start time') + }) + + test('equal times are a legitimate zero-length milestone, not an error', () => { + const result = milestonePayoutWei(milestone({ startTime: DAY, endTime: DAY }), project()) + expect(result.speed).toBe('early') + expect(result.amountWei).toBe(ethers.parseEther('1100')) + }) + + test('what the old default would have paid', () => { + // A missing start time used to read as duration = endTime - 0, and a missing end time as + // -startTime. Both landed in the early band. This pins that the throw replaces a payout. + expect(() => milestonePayoutWei(milestone({ endTime: undefined }), project())).toThrow() + }) +})