From cfcd7ca900218e8832462e37d7cd82e80b611b7a Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 14:29:51 +0800 Subject: [PATCH 01/27] feat(dao): add project proposal type and milestone data model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First commit of DAO Phase 2. Types and data only — no handlers, and no proposal can reach a project status yet, so behaviour is unchanged. - Add 'project' to DaoProposalType and 'executing'/'completed'/'terminated' to DaoProposalStatus. The first three are parameter-proposal states; the last three are project-only. - Add DaoMilestone, DaoProjectData, DaoTerminateVote and DaoProjectLogEntry. Endorsements are a flat address array with the proposer at index 0, matching the policy shape; proposedTime/endorsedTime are shared by milestone start and end and must be cleared on each commit. - Hang project data off DaoProposalAccount rather than a new account type, and leave it lazily created like unapplyVotes, so non-project proposals carry no empty object. - Widen the dao_claim_reward and dao_burn_reward status allowlists to include the project statuses. Not cosmetic: a project leaves 'accepted' at dao_project_start and never returns, so without this its voter reward pool would be permanently unclaimable and unburnable. The allowlists are hand-maintained string comparisons that widening the union does not flag. dao_proposal_create still rejects 'project' at runtime; accepting it is the next commit. --- src/@types/index.ts | 73 +++++++++++++++++++++++- src/@types/transactionSchemas.ts | 2 +- src/accounts/daoProposalAccount.ts | 1 + src/transactions/dao/dao_burn_reward.ts | 13 ++++- src/transactions/dao/dao_claim_reward.ts | 13 ++++- 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/@types/index.ts b/src/@types/index.ts index c4187f8a..e6c53a87 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -521,6 +521,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 @@ -794,8 +799,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 +826,63 @@ 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 +} + +/** Append-only audit trail, started when the project enters `executing`. Uncapped by decision. */ +export interface DaoProjectLogEntry { + caller: string + timestamp: number + txType: string + params?: string +} + +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 actually paid out, in wei. Zero until claimed. */ + 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 +970,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..7eea719e 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 }, 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/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 } From efab99abba21f87470a96cd89bbcf418240d0f7f Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 14:35:15 +0800 Subject: [PATCH 02/27] feat(dao): accept project proposals in dao_proposal_create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project proposals can now be created, reviewed and voted on. They cannot yet be executed — dao_project_start and the milestone transactions come next. - Route payload validation by type. Projects carry milestones, not parameter changes, so they never reach validateProposalChangeSets, which would reject them for having no change sets at all. - Add validateProjectMilestones with creation-time bounds: milestone count, title and text lengths, a positive finite duration, and parseable non-negative USD strings. The proposer picks the size of an account that every later project transaction rewrites and re-hashes, so this is what stops one proposal making all of its own transactions expensive. - Reject emergency project proposals. Projects mint new coins, so they must always face a community vote rather than the committee-only path. - Require exactly two ballot options for projects. A project has one flat milestone array with no per-option variant, so a third option would select nothing. - Snapshot the bonus and penalty percentages onto the project at creation, so a project is judged by the rules it was created under. validateProjectMilestones deliberately does not validate the contractor address: importing the utils barrel from a fresh util pulls in the existing config/utils import cycle and leaves libToWei undefined at config evaluation time. The address is a sibling field, so the caller checks it. --- src/config/index.ts | 6 ++ src/transactions/dao/dao_proposal_create.ts | 56 ++++++++++--- src/utils/daoBallotOptions.ts | 9 +- src/utils/daoProjectMilestones.ts | 75 +++++++++++++++++ test/daoProjectMilestones.test.ts | 92 +++++++++++++++++++++ 5 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 src/utils/daoProjectMilestones.ts create mode 100644 test/daoProjectMilestones.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index 3d880e8f..8b5dfeef 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -268,6 +268,10 @@ 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 minCommitteeMembers: number maxCommitteeMembers: number enableAJVValidation: boolean @@ -319,6 +323,8 @@ export const LiberdusFlags: LiberdusFlags = { enableNewDAOTransactions: true, // turned on by migration 2.5.1 enableDaoCancel: true, daoUnapplyCommitteeThreshold: 3, + daoProjectDurationBonusPercentage: 20, + daoProjectDurationPenaltyPercentage: 20, minCommitteeMembers: 4, maxCommitteeMembers: 10, enableAJVValidation: false, 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/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/daoProjectMilestones.ts b/src/utils/daoProjectMilestones.ts new file mode 100644 index 00000000..12d488db --- /dev/null +++ b/src/utils/daoProjectMilestones.ts @@ -0,0 +1,75 @@ +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 + } + } + + return undefined +} diff --git a/test/daoProjectMilestones.test.ts b/test/daoProjectMilestones.test.ts new file mode 100644 index 00000000..ba33d5bf --- /dev/null +++ b/test/daoProjectMilestones.test.ts @@ -0,0 +1,92 @@ +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('zero cost, penalty and bonus are allowed', () => { + // A milestone with no payment is unusual but not malformed — it may exist purely as a checkpoint. + expect(validateProjectMilestones([milestone({ costUsdStr: '0', penaltyUsdStr: '0', bonusUsdStr: '0' })])).toBeUndefined() + }) + + test('reports the index of the offending milestone', () => { + const error = validateProjectMilestones([milestone(), milestone({ duration: 0 })]) + expect(error).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') + }) +}) From 88538522baf997f194a0e1f20f2d79b8aec12c21 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 14:37:00 +0800 Subject: [PATCH 03/27] feat(dao): add the project mint ceiling flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone commit: this is the only guard on the one DAO operation that creates LIB, so it gets reviewed on its own before dao_project_start consumes it. - Add daoMaxMintThresholdLibStr to LiberdusFlags as a decimal LIB string, so it stays JSON-safe over /debug-liberdus-flags and parses to exact wei with no float rounding. - maxMintThresholdWei() throws on a malformed or negative value instead of falling back to a default. A ceiling that silently becomes something other than what an operator configured is worse than a failed transaction; the throw lands inside transaction validation, so a bad value stops mints rather than widening them. - exceedsMintThreshold() is strictly greater, so a mint exactly at the ceiling is allowed. Zero is a valid value and blocks every mint — a deliberate kill switch, distinct from a malformed one. Two limits are deliberate and both are commented at the flag. This is a per-project cap, not a supply cap: without current_supply, N projects can each pass it and still mint arbitrarily much in aggregate. And the default value is a placeholder that must be set from tokenomics before this reaches a real network. --- src/config/index.ts | 12 ++++++++ src/utils/daoProjectMint.ts | 32 +++++++++++++++++++ test/daoProjectMint.test.ts | 61 +++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/utils/daoProjectMint.ts create mode 100644 test/daoProjectMint.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index 8b5dfeef..fb8f7f5c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -272,6 +272,17 @@ interface LiberdusFlags { // 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 minCommitteeMembers: number maxCommitteeMembers: number enableAJVValidation: boolean @@ -325,6 +336,7 @@ export const LiberdusFlags: LiberdusFlags = { daoUnapplyCommitteeThreshold: 3, daoProjectDurationBonusPercentage: 20, daoProjectDurationPenaltyPercentage: 20, + daoMaxMintThresholdLibStr: '1000000', minCommitteeMembers: 4, maxCommitteeMembers: 10, enableAJVValidation: false, diff --git a/src/utils/daoProjectMint.ts b/src/utils/daoProjectMint.ts new file mode 100644 index 00000000..15301295 --- /dev/null +++ b/src/utils/daoProjectMint.ts @@ -0,0 +1,32 @@ +import { ethers } from 'ethers' +import { LiberdusFlags } from '../config' + +/** + * The configured per-project mint ceiling, in wei. + * + * Parsed rather than stored as a bigint so the flag stays JSON-safe on /debug-liberdus-flags and + * settable through the debug endpoint. Parsing is exact — no float rounding — because + * ethers.parseEther works on the decimal string directly. + * + * Throws on a malformed value rather than falling back to a default: a mint ceiling that silently + * becomes something other than what an operator configured is worse than a failed transaction. The + * throw surfaces inside transaction validation, so a bad value stops mints instead of 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() +} diff --git a/test/daoProjectMint.test.ts b/test/daoProjectMint.test.ts new file mode 100644 index 00000000..9e7eaf1b --- /dev/null +++ b/test/daoProjectMint.test.ts @@ -0,0 +1,61 @@ +import { ethers } from 'ethers' +import { LiberdusFlags } from '../src/config' +import { exceedsMintThreshold, maxMintThresholdWei } 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) + }) +}) From b22567b61d13d256c8d1240f2623b217a8d7541d Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 14:57:46 +0800 Subject: [PATCH 04/27] feat(dao): add dao_project_start The mint. An accepted project proposal moves to executing, its balance is minted, and the USD/LIB rate is fixed for every later payout. This is the only DAO transaction that creates LIB, so it is its own commit. - Committee-only, and the grace period always applies. Projects can never be emergency proposals, so unlike dao_apply_parameters there is no path that skips the wait. - Mint is sum(cost + bonus) across milestones, deliberately excluding penalties: a penalty only reduces what a contractor is paid, so folding it in would inflate the escrow and mint more than the project can legitimately pay out. - The amount is recomputed in apply() rather than carried over from validate(), so apply() depends only on wrappedStates, which Shardus snapshots identically for every node. - A malformed milestone amount and a malformed mint ceiling both fail the transaction. Never mint on a total that could not be computed. - rateUsdStr is snapshotted from the network's stability factor. Every later payout converts at this rate, so the contractor carries the LIB price risk from here and the DAO's exposure is fixed at the amount minted. - Start the project log, which every subsequent project transaction appends to. appendProjectLog lives in its own util so the eight project transactions share one implementation. It is uncapped, with the reasoning and a TODO at the function: the log grows with committee behaviour, not milestone count, since re-proposing a time or address is unlimited and each attempt appends. --- src/@types/index.ts | 7 + src/@types/transactionSchemas.ts | 13 ++ src/index.ts | 1 + src/transactions/dao/dao_project_start.ts | 211 ++++++++++++++++++++++ src/transactions/index.ts | 2 + src/utils/daoProjectLog.ts | 18 ++ src/utils/daoProjectMint.ts | 15 ++ test/daoProjectMint.test.ts | 33 +++- 8 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 src/transactions/dao/dao_project_start.ts create mode 100644 src/utils/daoProjectLog.ts diff --git a/src/@types/index.ts b/src/@types/index.ts index e6c53a87..d5363ffa 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -86,6 +86,7 @@ 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', } export enum TXTypes { @@ -154,6 +155,7 @@ 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', } export interface BaseLiberdusTx { @@ -585,6 +587,11 @@ export namespace Tx { from: string proposalId: string } + + export interface DaoProjectStart extends BaseLiberdusTx { + from: string + proposalId: string + } } export interface Signature { diff --git a/src/@types/transactionSchemas.ts b/src/@types/transactionSchemas.ts index 7eea719e..2f8e67d7 100644 --- a/src/@types/transactionSchemas.ts +++ b/src/@types/transactionSchemas.ts @@ -892,6 +892,18 @@ export const schemaDaoBurnRewardTX = { 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 +995,7 @@ function addSchemas(): void { [TXTypes.dao_claim_reward]: schemaDaoClaimRewardTX, [TXTypes.dao_burn_reward]: schemaDaoBurnRewardTX, [TXTypes.dao_cancel]: schemaDaoCancelTX, + [TXTypes.dao_project_start]: schemaDaoProjectStartTX, } // Loop through TXTypes and register corresponding schemas Object.entries(txSchemaMap).forEach(([txType, schema]) => { diff --git a/src/index.ts b/src/index.ts index 98a01ee4..c5d5c8ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,7 @@ const daoPreCrackTxTypes = new Set([ TXTypes.dao_claim_reward, TXTypes.dao_burn_reward, TXTypes.dao_cancel, + TXTypes.dao_project_start, ]) let isReadyToJoinLatestValue = false diff --git a/src/transactions/dao/dao_project_start.ts b/src/transactions/dao/dao_project_start.ts new file mode 100644 index 00000000..e81df6a7 --- /dev/null +++ b/src/transactions/dao/dao_project_start.ts @@ -0,0 +1,211 @@ +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 { isUserAccount, isDaoProposalAccount } from '../../@types/accountTypeGuards' +import { daoProposalsMetaId } from '../../accounts/daoProposalsMetaAccount' +import { recordProposalStatus } from '../../utils/daoProposalIndex' +import { getApplyEligibleAt } from '../../accounts/daoProposalAccount' +import { exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../../utils/daoProjectMint' +import { appendProjectLog } from '../../utils/daoProjectLog' + +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 from = wrappedStates[tx.from]?.data as UserAccount + const proposal = wrappedStates[tx.proposalId]?.data as DaoProposalAccount + const network = wrappedStates[config.networkAccount]?.data as NetworkAccount + + if (!from || !isUserAccount(from)) { + response.reason = 'from account not found or is not a UserAccount' + return response + } + if (!proposal || !isDaoProposalAccount(proposal)) { + response.reason = 'Proposal account not found or is not a DaoProposalAccount' + return response + } + if (!network) { + response.reason = 'Network account not found' + return response + } + if (proposal.proposalType !== 'project') { + response.reason = `Proposal type "${proposal.proposalType}" is not a project` + return response + } + if (proposal.status !== 'accepted') { + response.reason = `Proposal is not in accepted status (current: ${proposal.status})` + return response + } + if (!proposal.project) { + response.reason = 'Project proposal is missing its project data' + 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 + } + } 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', `mint=${mintWei} rate=${project.rateUsdStr}`) + + // 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) + applyResponse.appReceiptDataHash = appReceiptDataHash + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_start tx', from.id, tx.proposalId, 'minted', mintWei) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectStart, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { + txId, + timestamp: txTimestamp, + success: false, + from: tx.from, + to: tx.proposalId, + type: tx.type, + transactionFee: 0n, + additionalInfo: { reason }, + } +} + +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/index.ts b/src/transactions/index.ts index 56ba0b2f..57fc94f0 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -57,6 +57,7 @@ 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' export default { init_network, @@ -118,4 +119,5 @@ export default { dao_claim_reward, dao_burn_reward, dao_cancel, + dao_project_start, } diff --git a/src/utils/daoProjectLog.ts b/src/utils/daoProjectLog.ts new file mode 100644 index 00000000..f64b5f04 --- /dev/null +++ b/src/utils/daoProjectLog.ts @@ -0,0 +1,18 @@ +import { DaoProjectData, DaoProjectLogEntry } from '../@types' + +/** + * Appends to the project's audit trail. Every project transaction records who called, when, and + * what it did — the policy keeps this "in case of any dispute between the contractor and DAO". + * + * Deliberately uncapped for now. The log grows with committee behaviour rather than with the + * milestone count: re-proposing a start time, end time or address is unlimited, and each attempt + * appends. Bounding the milestones does not bound this. Acceptable because every appender is a + * committee member or the contractor, so growth needs insiders being persistent or adversarial. + * TODO: cap with oldest-first eviction if project accounts get large. + */ +export function appendProjectLog(project: DaoProjectData, caller: string, timestamp: number, txType: string, params?: string): void { + if (!Array.isArray(project.logs)) project.logs = [] + const entry: DaoProjectLogEntry = { caller, timestamp, txType } + if (params !== undefined) entry.params = params + project.logs.push(entry) +} diff --git a/src/utils/daoProjectMint.ts b/src/utils/daoProjectMint.ts index 15301295..8ea204ee 100644 --- a/src/utils/daoProjectMint.ts +++ b/src/utils/daoProjectMint.ts @@ -1,5 +1,6 @@ import { ethers } from 'ethers' import { LiberdusFlags } from '../config' +import { DaoMilestone } from '../@types' /** * The configured per-project mint ceiling, in wei. @@ -30,3 +31,17 @@ export function maxMintThresholdWei(): bigint { 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 deliberately excluded. A penalty only ever reduces what a contractor is paid, so + * folding it in here would inflate the escrow and mint more than the project can legitimately pay + * out. The policy's phrase "including early bonuses" means exactly this sum. + * + * The USD-to-wei converter is injected rather than imported so this module stays clear of the utils + * barrel, which drags in the config/utils import cycle. + */ +export function projectMintAmountWei(milestones: DaoMilestone[], usdStrToWei: (usdStr: string) => bigint): bigint { + return milestones.reduce((total, m) => total + usdStrToWei(m.costUsdStr) + usdStrToWei(m.bonusUsdStr), 0n) +} diff --git a/test/daoProjectMint.test.ts b/test/daoProjectMint.test.ts index 9e7eaf1b..c034caf9 100644 --- a/test/daoProjectMint.test.ts +++ b/test/daoProjectMint.test.ts @@ -1,6 +1,7 @@ import { ethers } from 'ethers' import { LiberdusFlags } from '../src/config' -import { exceedsMintThreshold, maxMintThresholdWei } from '../src/utils/daoProjectMint' +import { DaoMilestone } from '../src/@types' +import { exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../src/utils/daoProjectMint' const original = LiberdusFlags.daoMaxMintThresholdLibStr @@ -59,3 +60,33 @@ describe('exceedsMintThreshold', () => { 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() + }) +}) From 11b6ff01d7d777ba39bf4872c73ba0bf83ce26e3 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 15:03:43 +0800 Subject: [PATCH 05/27] feat(dao): add milestone lifecycle transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dao_project_milestone_start, _end and _terminate, plus the endorsement machinery the three of them share. - applyEndorsement implements D7: the proposer always counts as the first endorsement, so three total means three agreeing parties, never four. Read the policy's "endorsed by two committee members", "two more committee members" and "once three committee members submit" with the proposer counted and all three already say three. - The threshold clamps to what is reachable, and the reachable maximum differs by path. On start and end the contractor occupies index 0 and the committee supplies the rest, so the ceiling is committeeSize + 1; clamping those to committeeSize would commit a milestone one endorsement early on a small committee. - The contractor may propose but not endorse. Otherwise they could hold two of the three slots, and re-proposing would let them reset the count every time the committee got close to agreeing. - Re-proposing clears the endorsement list and re-seeds it with the new proposer, and committing clears both fields, so endorsements collected for a start cannot carry into the end. The two questions share the same storage. - Milestones run strictly in order. canStartMilestone checks every earlier milestone rather than only the previous one, which costs nothing and closes the case where an earlier one was left pending. - Milestone numbers are 1-based in transactions and 0-based in storage, with both boundaries rejected explicitly: an off-by-one here misroutes a payment. - Terminating releases the milestone's cost and bonus back out of the project balance, mirroring what was minted for it, and requires a reason on every submission — the log is what a dispute would be argued from. validate() dry-runs the endorsement against a copy of the list so it reports the same rejection apply() would, without mutating consensus state. --- src/@types/index.ts | 29 +++ src/@types/transactionSchemas.ts | 32 +++ src/index.ts | 3 + .../dao/dao_project_milestone_end.ts | 190 +++++++++++++++++ .../dao/dao_project_milestone_start.ts | 191 ++++++++++++++++++ .../dao/dao_project_milestone_terminate.ts | 176 ++++++++++++++++ src/transactions/index.ts | 6 + src/utils/daoProjectEndorsement.ts | 80 ++++++++ src/utils/daoProjectMilestoneState.ts | 42 ++++ src/utils/daoProjectTxContext.ts | 50 +++++ test/daoProjectEndorsement.test.ts | 127 ++++++++++++ test/daoProjectMilestoneState.test.ts | 57 ++++++ 12 files changed, 983 insertions(+) create mode 100644 src/transactions/dao/dao_project_milestone_end.ts create mode 100644 src/transactions/dao/dao_project_milestone_start.ts create mode 100644 src/transactions/dao/dao_project_milestone_terminate.ts create mode 100644 src/utils/daoProjectEndorsement.ts create mode 100644 src/utils/daoProjectMilestoneState.ts create mode 100644 src/utils/daoProjectTxContext.ts create mode 100644 test/daoProjectEndorsement.test.ts create mode 100644 test/daoProjectMilestoneState.test.ts diff --git a/src/@types/index.ts b/src/@types/index.ts index d5363ffa..e45fb113 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -87,6 +87,9 @@ export enum AJVSchemaEnum { 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', } export enum TXTypes { @@ -156,6 +159,9 @@ export enum TXTypes { 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', } export interface BaseLiberdusTx { @@ -592,6 +598,29 @@ export namespace Tx { from: string proposalId: string } + + export interface DaoProjectMilestoneStart extends BaseLiberdusTx { + from: string + proposalId: string + /** 1-based, matching how proposals are addressed externally. */ + milestoneNumber: number + /** Present when proposing a time; absent when endorsing the pending one. */ + proposedTime?: number + } + + export interface DaoProjectMilestoneEnd extends BaseLiberdusTx { + from: string + proposalId: string + milestoneNumber: number + proposedTime?: number + } + + export interface DaoProjectMilestoneTerminate extends BaseLiberdusTx { + from: string + proposalId: string + milestoneNumber: number + reason: string + } } export interface Signature { diff --git a/src/@types/transactionSchemas.ts b/src/@types/transactionSchemas.ts index 2f8e67d7..6883bc9e 100644 --- a/src/@types/transactionSchemas.ts +++ b/src/@types/transactionSchemas.ts @@ -892,6 +892,35 @@ 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 }, + milestoneNumber: { type: 'number', minimum: 1 }, + proposedTime: { type: 'number', minimum: 0 }, + 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: { @@ -996,6 +1025,9 @@ function addSchemas(): void { [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, } // Loop through TXTypes and register corresponding schemas Object.entries(txSchemaMap).forEach(([txType, schema]) => { diff --git a/src/index.ts b/src/index.ts index c5d5c8ce..9f101938 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,9 @@ const daoPreCrackTxTypes = new Set([ 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, ]) let isReadyToJoinLatestValue = false 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..be644ffe --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -0,0 +1,190 @@ +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 { applyEndorsement } from '../../utils/daoProjectEndorsement' +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 (typeof tx.milestoneNumber !== 'number' || !Number.isInteger(tx.milestoneNumber) || tx.milestoneNumber < 1) { + response.reason = 'tx "milestoneNumber" must be a positive integer' + 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, tx.milestoneNumber) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project, milestone } = ctx + + if (milestone.status !== 'executing') { + response.reason = `Milestone ${tx.milestoneNumber} is not executing (current: ${milestone.status})` + return response + } + // 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 + } + + // Dry-run the endorsement against a copy so validate() reports the same rejection apply() would, + // without mutating consensus state here. + const dryRun = applyEndorsement( + [...milestone.endorsedTime], + tx.from, + tx.proposedTime !== undefined, + proposal.committeeAddresses, + project.address, + milestone.proposedTime !== undefined, + ) + if (dryRun.error) { + response.reason = dryRun.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 + const milestone = project.milestones[tx.milestoneNumber - 1] + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + const isProposing = tx.proposedTime !== undefined + if (isProposing) milestone.proposedTime = tx.proposedTime + const result = applyEndorsement( + milestone.endorsedTime, + tx.from, + isProposing, + proposal.committeeAddresses, + project.address, + milestone.proposedTime !== undefined, + ) + + 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 = [] + } + + appendProjectLog( + project, + tx.from, + txTimestamp, + 'dao_project_milestone_end', + `milestone=${tx.milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, + ) + + 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, + endorsements: milestone.endorsedTime.length, + committed: result.committed === true, + }, + } + applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_milestone_end tx', from.id, tx.proposalId, tx.milestoneNumber) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectMilestoneEnd, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +} + +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..b8fed179 --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -0,0 +1,191 @@ +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 { applyEndorsement } from '../../utils/daoProjectEndorsement' +import { canStartMilestone } 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 (typeof tx.milestoneNumber !== 'number' || !Number.isInteger(tx.milestoneNumber) || tx.milestoneNumber < 1) { + response.reason = 'tx "milestoneNumber" must be a positive integer' + 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, tx.milestoneNumber) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, proposal, project, milestone, milestoneIndex } = ctx + + if (milestone.status !== 'pending') { + response.reason = `Milestone ${tx.milestoneNumber} is not pending (current: ${milestone.status})` + return response + } + const orderError = canStartMilestone(project, milestoneIndex) + if (orderError) { + response.reason = orderError + return response + } + + // Dry-run the endorsement against a copy so validate() reports the same rejection apply() would, + // without mutating consensus state here. + const dryRun = applyEndorsement( + [...milestone.endorsedTime], + tx.from, + tx.proposedTime !== undefined, + proposal.committeeAddresses, + project.address, + milestone.proposedTime !== undefined, + ) + if (dryRun.error) { + response.reason = dryRun.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 + const milestone = project.milestones[tx.milestoneNumber - 1] + + const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) + from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + + const isProposing = tx.proposedTime !== undefined + if (isProposing) milestone.proposedTime = tx.proposedTime + const result = applyEndorsement( + milestone.endorsedTime, + tx.from, + isProposing, + proposal.committeeAddresses, + project.address, + milestone.proposedTime !== undefined, + ) + + 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', + `milestone=${tx.milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, + ) + + 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, + endorsements: milestone.endorsedTime.length, + committed: result.committed === true, + }, + } + applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_milestone_start tx', from.id, tx.proposalId, tx.milestoneNumber) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectMilestoneStart, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +} + +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..dab98727 --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_terminate.ts @@ -0,0 +1,176 @@ +import * as crypto from '../../crypto' +import { Shardus, ShardusTypes } from '@shardus/core' +import * as config from '../../config' +import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount, NetworkAccount } 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 { 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 + + // 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 network = wrappedStates[config.networkAccount].data as NetworkAccount + 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. + const releasedWei = utils.usdStrToWei(milestone.costUsdStr, network) + utils.usdStrToWei(milestone.bonusUsdStr, network) + 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', + `milestone=${tx.milestoneNumber} votes=${milestone.terminateVotes.length} committed=${committed} 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, + }, + } + applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_milestone_terminate tx', from.id, tx.proposalId, tx.milestoneNumber) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectMilestoneTerminate, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +} + +export const keys = (tx: Tx.DaoProjectMilestoneTerminate, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { + result.sourceKeys = [tx.from] + result.targetKeys = [tx.proposalId, config.networkAccount] + 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: [config.networkAccount] } +} + +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/index.ts b/src/transactions/index.ts index 57fc94f0..43240e31 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -58,6 +58,9 @@ 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' export default { init_network, @@ -120,4 +123,7 @@ export default { dao_burn_reward, dao_cancel, dao_project_start, + dao_project_milestone_start, + dao_project_milestone_end, + dao_project_milestone_terminate, } diff --git a/src/utils/daoProjectEndorsement.ts b/src/utils/daoProjectEndorsement.ts new file mode 100644 index 00000000..72660e36 --- /dev/null +++ b/src/utils/daoProjectEndorsement.ts @@ -0,0 +1,80 @@ +/** + * 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 +} + +/** + * Applies one propose-or-endorse submission to an endorsement list, in place. + * + * The three project paths that need agreement — milestone start, milestone end, contractor address + * — share this shape exactly: a submission carrying a value replaces whatever was pending and + * re-seeds the endorsements with its sender; a submission without one endorses what is pending. + * + * `endorsements` is the live array and is mutated. Callers own the proposed value itself, because + * its type differs per path (a timestamp or an address). + */ +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' } + } + // The contractor gets exactly one move: opening a proposal. Letting them endorse would let them + // occupy two of the three slots, and letting them re-propose would let them reset the count every + // time the committee got close to agreeing. + 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 } +} diff --git a/src/utils/daoProjectMilestoneState.ts b/src/utils/daoProjectMilestoneState.ts new file mode 100644 index 00000000..fd348e93 --- /dev/null +++ b/src/utils/daoProjectMilestoneState.ts @@ -0,0 +1,42 @@ +import { DaoMilestone, DaoProjectData } from '../@types' + +/** + * Resolves a transaction's 1-based milestone number to its array index. + * + * Transactions number milestones from 1 to match how proposals are already addressed externally + * ("dao proposal #N"); storage is a zero-indexed array. 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: a milestone may only start once every earlier one has finished, + * one way or the other. The policy states it as "the previous milestone should be in the completed + * or terminated state or this must be the first milestone in pending status". + * + * Checking every earlier milestone rather than only the immediately preceding one costs nothing and + * closes the case where an earlier milestone was somehow left pending. + */ +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 +} + +/** 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/daoProjectTxContext.ts b/src/utils/daoProjectTxContext.ts new file mode 100644 index 00000000..4fc6b60c --- /dev/null +++ b/src/utils/daoProjectTxContext.ts @@ -0,0 +1,50 @@ +import { WrappedStates, UserAccount, DaoProposalAccount, DaoProjectData, DaoMilestone } from '../@types' +import { isUserAccount, isDaoProposalAccount } from '../@types/accountTypeGuards' +import { resolveMilestone } from './daoProjectMilestoneState' + +export interface ProjectTxContext { + from?: UserAccount + proposal?: DaoProposalAccount + project?: DaoProjectData + milestone?: DaoMilestone + milestoneIndex?: number + error?: string +} + +/** + * The preamble every milestone transaction repeats: load the accounts, confirm this really is a + * running project, and resolve the milestone number. + * + * Shared so the six milestone transactions cannot drift apart on what "a valid project transaction" + * means — a mismatch between, say, start and claim on which statuses are acceptable is exactly the + * kind of gap that lets a payment through on a project that should be finished. + */ +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 (proposal.status !== 'executing') { + return { error: `Project is not executing (current: ${proposal.status})` } + } + + const context: ProjectTxContext = { from, proposal, project: proposal.project } + if (milestoneNumber !== undefined) { + const resolved = resolveMilestone(proposal.project, milestoneNumber) + if (resolved.error) return { error: resolved.error } + context.milestone = resolved.milestone + context.milestoneIndex = resolved.index + } + return context +} diff --git a/test/daoProjectEndorsement.test.ts b/test/daoProjectEndorsement.test.ts new file mode 100644 index 00000000..522b56fb --- /dev/null +++ b/test/daoProjectEndorsement.test.ts @@ -0,0 +1,127 @@ +import { applyEndorsement, PROJECT_ENDORSEMENT_THRESHOLD, requiredEndorsements } 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('re-proposing clears the list and re-seeds it with the new proposer', () => { + // A new value is a different question — endorsements of the old one must not carry over. + const e: string[] = [] + applyEndorsement(e, C1, true, COMMITTEE, undefined, false) + applyEndorsement(e, C2, false, COMMITTEE, undefined, true) + expect(e).toEqual([C1, C2]) + + const result = applyEndorsement(e, C3, true, COMMITTEE, undefined, true) + expect(e).toEqual([C3]) + expect(result.committed).toBe(false) + }) + + 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) + }) +}) diff --git a/test/daoProjectMilestoneState.test.ts b/test/daoProjectMilestoneState.test.ts new file mode 100644 index 00000000..6dac5e70 --- /dev/null +++ b/test/daoProjectMilestoneState.test.ts @@ -0,0 +1,57 @@ +import { DaoProjectData } from '../src/@types' +import { allMilestonesFinished, canStartMilestone, 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) + }) +}) From 772fd8fd2587ccc770b529ace8ef9ce53f92d3a1 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 15:08:09 +0800 Subject: [PATCH 06/27] feat(dao): add dao_project_milestone_claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payout. A completed milestone pays the contractor from the project balance, adjusted for how early or late it was delivered. - classifyDelivery compares actual against planned duration using the project's own percentages. 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. - Early pays cost + bonus, on time pays cost, late pays cost - penalty with no bonus. The result floors at zero: a penalty larger than the cost reduces the payment to nothing but never makes the contractor owe the DAO and never adds back to the balance. Without the floor a large penalty inverts into a credit. - Conversions use usdToWeiAtRate against the project's stored rate, never the live one, so the DAO's exposure stays capped at what it actually minted. - The balance caps the payout regardless of what the milestone arithmetic says. - `paid` records the amount, not a boolean, so a zero payout from a heavy penalty still settles the milestone and `paid > 0n` blocks a second claim. Also fixes a bug in dao_project_milestone_terminate from the previous commit: it released a terminated milestone's escrow at the live rate while the mint had used the project's fixed rate, so the balance drifted whenever the stability factor moved. It now releases at the project rate, mirroring exactly what was minted, and no longer needs the network account at all. --- src/@types/index.ts | 8 + src/@types/transactionSchemas.ts | 14 ++ src/index.ts | 1 + .../dao/dao_project_milestone_claim.ts | 174 ++++++++++++++++++ .../dao/dao_project_milestone_terminate.ts | 12 +- src/transactions/index.ts | 2 + src/utils/daoProjectPayout.ts | 77 ++++++++ test/daoProjectPayout.test.ts | 82 +++++++++ 8 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 src/transactions/dao/dao_project_milestone_claim.ts create mode 100644 src/utils/daoProjectPayout.ts create mode 100644 test/daoProjectPayout.test.ts diff --git a/src/@types/index.ts b/src/@types/index.ts index e45fb113..3fdf5439 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -90,6 +90,7 @@ export enum AJVSchemaEnum { 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', } export enum TXTypes { @@ -162,6 +163,7 @@ export enum TXTypes { 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', } export interface BaseLiberdusTx { @@ -621,6 +623,12 @@ export namespace Tx { milestoneNumber: number reason: string } + + export interface DaoProjectMilestoneClaim extends BaseLiberdusTx { + from: string + proposalId: string + milestoneNumber: number + } } export interface Signature { diff --git a/src/@types/transactionSchemas.ts b/src/@types/transactionSchemas.ts index 6883bc9e..3421cda7 100644 --- a/src/@types/transactionSchemas.ts +++ b/src/@types/transactionSchemas.ts @@ -907,6 +907,19 @@ export const schemaDaoProjectMilestoneTimeTX = { 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: { @@ -1028,6 +1041,7 @@ function addSchemas(): void { [TXTypes.dao_project_milestone_start]: schemaDaoProjectMilestoneTimeTX, [TXTypes.dao_project_milestone_end]: schemaDaoProjectMilestoneTimeTX, [TXTypes.dao_project_milestone_terminate]: schemaDaoProjectMilestoneTerminateTX, + [TXTypes.dao_project_milestone_claim]: schemaDaoProjectMilestoneClaimTX, } // Loop through TXTypes and register corresponding schemas Object.entries(txSchemaMap).forEach(([txType, schema]) => { diff --git a/src/index.ts b/src/index.ts index 9f101938..a5e4d60a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,7 @@ const daoPreCrackTxTypes = new Set([ TXTypes.dao_project_milestone_start, TXTypes.dao_project_milestone_end, TXTypes.dao_project_milestone_terminate, + TXTypes.dao_project_milestone_claim, ]) let isReadyToJoinLatestValue = false 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..a585da94 --- /dev/null +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -0,0 +1,174 @@ +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, usdToWeiAtRate } 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 => { + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber) + if (ctx.error) { + response.reason = ctx.error + return response + } + const { from, project, milestone } = ctx + + // 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 completed (current: ${milestone.status})` + return response + } + if (milestone.paid > 0n) { + response.reason = `Milestone ${tx.milestoneNumber} has already been paid` + return response + } + + let payoutWei: bigint + try { + payoutWei = milestonePayoutWei(milestone, project.durationBonusPercentage, project.durationPenaltyPercentage, (usdStr) => + usdToWeiAtRate(usdStr, project.rateUsdStr), + ).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.durationBonusPercentage, project.durationPenaltyPercentage, (usdStr) => + usdToWeiAtRate(usdStr, project.rateUsdStr), + ) + project.balance = SafeBigIntMath.subtract(project.balance, payout.amountWei) + from.data.balance = SafeBigIntMath.add(from.data.balance, payout.amountWei) + // Records the amount, not a boolean: a zero payout from a heavy penalty still settles the + // milestone, and `paid > 0n` is what blocks a second claim. + milestone.paid = payout.amountWei + + appendProjectLog( + project, + tx.from, + txTimestamp, + 'dao_project_milestone_claim', + `milestone=${tx.milestoneNumber} speed=${payout.speed} paid=${payout.amountWei}`, + ) + + 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, + }, + } + applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_milestone_claim tx', from.id, tx.proposalId, tx.milestoneNumber, payout.amountWei) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectMilestoneClaim, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +} + +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_terminate.ts b/src/transactions/dao/dao_project_milestone_terminate.ts index dab98727..06a4b820 100644 --- a/src/transactions/dao/dao_project_milestone_terminate.ts +++ b/src/transactions/dao/dao_project_milestone_terminate.ts @@ -1,12 +1,13 @@ import * as crypto from '../../crypto' import { Shardus, ShardusTypes } from '@shardus/core' import * as config from '../../config' -import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount, NetworkAccount } from '../../@types' +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 = ( @@ -91,7 +92,6 @@ export const apply = ( ): 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 project = proposal.project const milestone = project.milestones[tx.milestoneNumber - 1] @@ -108,7 +108,9 @@ export const apply = ( 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. - const releasedWei = utils.usdStrToWei(milestone.costUsdStr, network) + utils.usdStrToWei(milestone.bonusUsdStr, network) + // 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 @@ -153,13 +155,13 @@ export const createFailedAppReceiptData = (tx: Tx.DaoProjectMilestoneTerminate, export const keys = (tx: Tx.DaoProjectMilestoneTerminate, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { result.sourceKeys = [tx.from] - result.targetKeys = [tx.proposalId, config.networkAccount] + 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: [config.networkAccount] } + return { rw: [tx.from, tx.proposalId], wo: [], on: [], ri: [], ro: [] } } export const createRelevantAccount = ( diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 43240e31..4942299d 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -61,6 +61,7 @@ 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' export default { init_network, @@ -126,4 +127,5 @@ export default { dao_project_milestone_start, dao_project_milestone_end, dao_project_milestone_terminate, + dao_project_milestone_claim, } diff --git a/src/utils/daoProjectPayout.ts b/src/utils/daoProjectPayout.ts new file mode 100644 index 00000000..6ed21ad9 --- /dev/null +++ b/src/utils/daoProjectPayout.ts @@ -0,0 +1,77 @@ +import { ethers } from 'ethers' +import { DaoMilestone } from '../@types' + +const WEI = 10n ** 18n + +/** + * Converts a USD string to wei at a *fixed* rate, not the live one. + * + * Every project payout uses the rate snapshotted when the balance was minted, so the DAO's exposure + * stays capped at what it actually minted and the contractor carries the LIB price movement. Mirrors + * utils.usdStrToWei's arithmetic, but takes the rate as an argument instead of reading the network + * account — which 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. + * + * The policy sets the thresholds as a percentage of the planned duration: finishing more than + * `bonusPercentage` faster earns the bonus, running more than `penaltyPercentage` over incurs the + * penalty, and anything between is on time and paid the plain cost. + * + * Both comparisons are strict, so landing exactly on a threshold is "on time". That is the + * conservative reading: 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. + * + * A late milestone earns no bonus, so the penalty is deducted from the cost alone. It floors at + * zero: a penalty larger than the cost reduces the payment to nothing but never makes the + * contractor owe the DAO, and never adds back to the project balance. Without the floor a large + * penalty would invert into a credit. + * + * The USD-to-wei converter is injected, and callers must supply one bound to the project's stored + * rate rather than the live one — the DAO's exposure was fixed at the amount minted. + */ +export function milestonePayoutWei( + milestone: DaoMilestone, + bonusPercentage: number, + penaltyPercentage: number, + usdStrToWei: (usdStr: string) => bigint, +): MilestonePayout { + const actualDuration = (milestone.endTime ?? 0) - (milestone.startTime ?? 0) + const speed = classifyDelivery(actualDuration, milestone.duration, bonusPercentage, penaltyPercentage) + + const cost = usdStrToWei(milestone.costUsdStr) + if (speed === 'early') { + return { speed, amountWei: cost + usdStrToWei(milestone.bonusUsdStr) } + } + if (speed === 'late') { + const penalty = usdStrToWei(milestone.penaltyUsdStr) + // Floor at zero: a penalty larger than the cost reduces the payment to nothing, but never makes + // the contractor owe the DAO and never adds back to the project balance. + return { speed, amountWei: penalty >= cost ? 0n : cost - penalty } + } + return { speed, amountWei: cost } +} diff --git a/test/daoProjectPayout.test.ts b/test/daoProjectPayout.test.ts new file mode 100644 index 00000000..9e1c14bc --- /dev/null +++ b/test/daoProjectPayout.test.ts @@ -0,0 +1,82 @@ +import { ethers } from 'ethers' +import { DaoMilestone } from '../src/@types' +import { classifyDelivery, milestonePayoutWei, usdToWeiAtRate } from '../src/utils/daoProjectPayout' + +const DAY = 86_400_000 +const at1to1 = (usdStr: string): bigint => usdToWeiAtRate(usdStr, '1') + +function milestone(over: Partial = {}): DaoMilestone { + return { duration: 10 * DAY, costUsdStr: '1000', bonusUsdStr: '100', penaltyUsdStr: '200', startTime: 0, endTime: 10 * DAY, ...over } as DaoMilestone +} + +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 }), 20, 20, at1to1) + expect(result.speed).toBe('early') + expect(result.amountWei).toBe(ethers.parseEther('1100')) + }) + + test('on time pays the plain cost', () => { + const result = milestonePayoutWei(milestone(), 20, 20, at1to1) + 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 }), 20, 20, at1to1) + 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' }), 20, 20, at1to1) + 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' }), 20, 20, at1to1) + 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, 20, 20, at1to1).speed).toBe('ontime') + expect(milestonePayoutWei(m, 10, 10, at1to1).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') + }) +}) From c844591345e3b3df8c08cb71c31c74709c2033e0 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 15:12:32 +0800 Subject: [PATCH 07/27] feat(dao): add project administration transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dao_project_change_address, dao_project_end and dao_project_reclaim_balance — the three transactions that manage a project outside its milestones. - change_address is committee-only and, per D14, not allowed while the project is merely accepted. Before dao_project_start there are no funds to redirect and the community voted on a proposal naming that contractor; substituting another party then changes what was approved without a vote. It stays open after the project ends only while a balance remains, since the contractor can still be claiming completed milestones. - end trims the balance to exactly what completed-but-unclaimed milestones are still owed, rather than zeroing it, because the contractor keeps claiming after the project ends. Terminated and already-paid milestones release. - end takes the last milestone's status (D4). Noted at the code: this can under-report failure, since a project whose earlier milestone was terminated but whose last one completed reads as completed. - reclaim_balance zeroes what is left after daoProjectReclaimDelayMs (90 days). Nothing is transferred — the balance was minted into the project and simply ceases to exist, because an unclaimed balance would otherwise inflate the supply forever for work that was never paid for. dao_project_end and dao_project_reclaim_balance carry no fields beyond from and proposalId, so they reuse schemaDaoProjectStartTX rather than duplicating an identical shape three times. --- src/@types/index.ts | 23 +++ src/@types/transactionSchemas.ts | 18 ++ src/config/index.ts | 3 + src/index.ts | 3 + .../dao/dao_project_change_address.ts | 179 ++++++++++++++++++ src/transactions/dao/dao_project_end.ts | 157 +++++++++++++++ .../dao/dao_project_reclaim_balance.ts | 148 +++++++++++++++ src/transactions/index.ts | 6 + 8 files changed, 537 insertions(+) create mode 100644 src/transactions/dao/dao_project_change_address.ts create mode 100644 src/transactions/dao/dao_project_end.ts create mode 100644 src/transactions/dao/dao_project_reclaim_balance.ts diff --git a/src/@types/index.ts b/src/@types/index.ts index 3fdf5439..1d429625 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -91,6 +91,9 @@ export enum AJVSchemaEnum { 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 { @@ -164,6 +167,9 @@ export enum TXTypes { 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 { @@ -629,6 +635,23 @@ export namespace Tx { 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 { diff --git a/src/@types/transactionSchemas.ts b/src/@types/transactionSchemas.ts index 3421cda7..f502c546 100644 --- a/src/@types/transactionSchemas.ts +++ b/src/@types/transactionSchemas.ts @@ -907,6 +907,21 @@ export const schemaDaoProjectMilestoneTimeTX = { 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: { @@ -1042,6 +1057,9 @@ function addSchemas(): void { [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/config/index.ts b/src/config/index.ts index fb8f7f5c..948271e3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -283,6 +283,8 @@ interface LiberdusFlags { // 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 @@ -337,6 +339,7 @@ export const LiberdusFlags: LiberdusFlags = { 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 a5e4d60a..a21dad49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,9 @@ const daoPreCrackTxTypes = new Set([ 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_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts new file mode 100644 index 00000000..416a3019 --- /dev/null +++ b/src/transactions/dao/dao_project_change_address.ts @@ -0,0 +1,179 @@ +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 { isUserAccount, isDaoProposalAccount } from '../../@types/accountTypeGuards' +import { appendProjectLog } from '../../utils/daoProjectLog' +import { applyEndorsement } 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 from = wrappedStates[tx.from]?.data as UserAccount + const proposal = wrappedStates[tx.proposalId]?.data as DaoProposalAccount + + if (!from || !isUserAccount(from)) { + response.reason = 'from account not found or is not a UserAccount' + return response + } + if (!proposal || !isDaoProposalAccount(proposal) || proposal.proposalType !== 'project' || !proposal.project) { + response.reason = 'Proposal is not a project proposal' + return response + } + const project = proposal.project + + // 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 + } + + const dryRun = applyEndorsement( + [...project.endorsedAddress], + tx.from, + tx.proposedAddress !== undefined, + proposal.committeeAddresses, + undefined, + project.proposedAddress !== undefined, + ) + if (dryRun.error) { + response.reason = dryRun.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) + + const isProposing = tx.proposedAddress !== undefined + if (isProposing) project.proposedAddress = tx.proposedAddress + // No contractor slot here — passing undefined keeps the threshold clamped to the committee size. + const result = applyEndorsement(project.endorsedAddress, tx.from, isProposing, proposal.committeeAddresses, undefined, project.proposedAddress !== undefined) + + 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', `proposed=${tx.proposedAddress ?? ''} committed=${result.committed === true}`) + + 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, + }, + } + applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_change_address tx', from.id, tx.proposalId) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectChangeAddress, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +} + +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..8bfc2a13 --- /dev/null +++ b/src/transactions/dao/dao_project_end.ts @@ -0,0 +1,157 @@ +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, usdToWeiAtRate } 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.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.durationBonusPercentage, project.durationPenaltyPercentage, (usdStr) => usdToWeiAtRate(usdStr, project.rateUsdStr)) + .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', `status=${proposal.status} owed=${owedWei}`) + + 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 }, + } + applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_end tx', from.id, tx.proposalId, proposal.status) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectEnd, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +} + +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_reclaim_balance.ts b/src/transactions/dao/dao_project_reclaim_balance.ts new file mode 100644 index 00000000..69108f84 --- /dev/null +++ b/src/transactions/dao/dao_project_reclaim_balance.ts @@ -0,0 +1,148 @@ +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 { isUserAccount, isDaoProposalAccount } from '../../@types/accountTypeGuards' +import { appendProjectLog } from '../../utils/daoProjectLog' + +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 from = wrappedStates[tx.from]?.data as UserAccount + const proposal = wrappedStates[tx.proposalId]?.data as DaoProposalAccount + + if (!from || !isUserAccount(from)) { + response.reason = 'from account not found or is not a UserAccount' + return response + } + if (!proposal || !isDaoProposalAccount(proposal) || proposal.proposalType !== 'project' || !proposal.project) { + response.reason = 'Proposal is not a project proposal' + return response + } + const project = proposal.project + + if (proposal.status !== 'completed' && proposal.status !== 'terminated') { + response.reason = `Project has not ended (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', `reclaimed=${reclaimedWei}`) + + 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 }, + } + applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) + applyResponse.appReceiptData = appReceiptData + + dapp.log('Applied dao_project_reclaim_balance tx', from.id, tx.proposalId, reclaimedWei) +} + +export const createFailedAppReceiptData = (tx: Tx.DaoProjectReclaimBalance, txId: string, txTimestamp: number, reason: string): AppReceiptData => { + return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +} + +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/index.ts b/src/transactions/index.ts index 4942299d..d051b35e 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -62,6 +62,9 @@ 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, @@ -128,4 +131,7 @@ export default { dao_project_milestone_end, dao_project_milestone_terminate, dao_project_milestone_claim, + dao_project_change_address, + dao_project_end, + dao_project_reclaim_balance, } From b944fbdd0011ae10a2ca70a9c9ede6c1520f313b Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 15:14:25 +0800 Subject: [PATCH 08/27] feat(dao): expose project data via API and client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dao/projects/:id, which returns the milestones, balance and rate but omits the log. dao/proposals/:id already returns the whole account, and for a project that is dominated by an unbounded logs array — a caller polling milestone progress should not pull the entire audit trail every time. logCount is returned so a client knows whether fetching it is worth it. - Add dao/projects/:id/logs for the trail itself, fetched deliberately. - Register both after dao/proposals/:id, following the LIFO note already in api/index.ts. - Add client commands for the full lifecycle: dao project and logs, start, end, reclaim, address, and the four milestone verbs. The propose-or-endorse commands take a blank input to endorse what is pending and a value to propose a replacement, mirroring how the transactions themselves distinguish the two. - Add the three project statuses to VALID_DAO_STATUSES so `dao proposals executing` works as a filter. That array is hand-maintained and not derived from DaoProposalStatus, so TypeScript cannot catch an omission. --- client.js | 116 ++++++++++++++++++++++++++++++++++++++- src/api/dao/proposals.ts | 59 ++++++++++++++++++++ src/api/index.ts | 2 + 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/client.js b/client.js index ef0e765d..b4bd4130 100644 --- a/client.js +++ b/client.js @@ -3090,7 +3090,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 +3140,120 @@ 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) this.log(`${new Date(l.timestamp).toISOString()} ${l.txType} by ${l.caller}${l.params ? ` | ${l.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() + }) + +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; a value proposes one and resets endorsements. The time may + // not be in the future — the server rejects that rather than 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, { milestoneNumber: args.milestone, ...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/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)) From 7640fce31fcd4fbb981fcb174afcaf6f31079098 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 16:26:13 +0800 Subject: [PATCH 09/27] fix(dao): correct project proposal review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from review, three of them real bugs. - Declare `project` in schemaDaoProposalCreateTX. The schema allowed proposalType 'project' but never declared the payload, and additionalProperties is false — so enabling AJV validation would have rejected every project proposal. Latent today since the flag is off, which is exactly why unit tests did not catch it. - Let the contractor claim after the project ends. loadProjectTxContext required 'executing', but dao_project_end deliberately trims the balance to what completed-but-unclaimed milestones still owe so the contractor can collect it. The code contradicted its own design. loadProjectTxContext now takes the acceptable statuses, defaulting to 'executing' for everything else. - Use `paid > 0n` as the settled marker, in the claim handler and in dao_project_end's unclaimed calculation. This is sound only while no payout can be zero, which the `penalty < cost` creation rule and the start-time wei guard establish — both land in later commits, so a zero payout is still reachable here and would leave such a milestone claimable forever. - Clear terminateVotes when a milestone completes. The plan said so and the implementation did not: abandoned termination intent could otherwise linger on a finished milestone and later reach the threshold, terminating work that was already accepted and paid for. - Let the client create project proposals. The type was in the transaction layer but not in the create prompt, so there was no path to submit one. Milestones and the contractor address are prompted only for projects, and the change-set prompt is skipped for them. --- client.js | 27 ++++++++++++++++--- src/@types/index.ts | 8 +++++- src/@types/transactionSchemas.ts | 1 + .../dao/dao_project_milestone_claim.ts | 9 ++++--- .../dao/dao_project_milestone_end.ts | 3 +++ src/utils/daoProjectPayout.ts | 13 +++++---- src/utils/daoProjectTxContext.ts | 15 ++++++++--- test/daoProjectMilestones.test.ts | 12 +++++++++ 8 files changed, 71 insertions(+), 17 deletions(-) diff --git a/client.js b/client.js index b4bd4130..65771d74 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 } diff --git a/src/@types/index.ts b/src/@types/index.ts index 1d429625..0873f381 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -928,7 +928,13 @@ export interface DaoMilestone { endorsedTime: string[] terminateVotes: DaoTerminateVote[] status: DaoMilestoneStatus - /** Amount actually paid out, in wei. Zero until claimed. */ + /** + * 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 } diff --git a/src/@types/transactionSchemas.ts b/src/@types/transactionSchemas.ts index f502c546..506a54c4 100644 --- a/src/@types/transactionSchemas.ts +++ b/src/@types/transactionSchemas.ts @@ -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' }, }, diff --git a/src/transactions/dao/dao_project_milestone_claim.ts b/src/transactions/dao/dao_project_milestone_claim.ts index a585da94..f3182982 100644 --- a/src/transactions/dao/dao_project_milestone_claim.ts +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -39,7 +39,8 @@ export const validate = ( wrappedStates: WrappedStates, response: ShardusTypes.IncomingTransactionResult, ): ShardusTypes.IncomingTransactionResult => { - const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber) + // Claiming outlives the project: dao_project_end leaves a balance for exactly this. + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber, ['executing', 'completed', 'terminated']) if (ctx.error) { response.reason = ctx.error return response @@ -55,8 +56,10 @@ export const validate = ( response.reason = `Milestone ${tx.milestoneNumber} is not completed (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 paid` + response.reason = `Milestone ${tx.milestoneNumber} has already been claimed` return response } @@ -108,8 +111,6 @@ export const apply = ( ) project.balance = SafeBigIntMath.subtract(project.balance, payout.amountWei) from.data.balance = SafeBigIntMath.add(from.data.balance, payout.amountWei) - // Records the amount, not a boolean: a zero payout from a heavy penalty still settles the - // milestone, and `paid > 0n` is what blocks a second claim. milestone.paid = payout.amountWei appendProjectLog( diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index be644ffe..31882a05 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -127,6 +127,9 @@ export const apply = ( // 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( diff --git a/src/utils/daoProjectPayout.ts b/src/utils/daoProjectPayout.ts index 6ed21ad9..c81d177d 100644 --- a/src/utils/daoProjectPayout.ts +++ b/src/utils/daoProjectPayout.ts @@ -47,9 +47,13 @@ export interface MilestonePayout { * What a completed milestone pays out. * * A late milestone earns no bonus, so the penalty is deducted from the cost alone. It floors at - * zero: a penalty larger than the cost reduces the payment to nothing but never makes the - * contractor owe the DAO, and never adds back to the project balance. Without the floor a large - * penalty would invert into a credit. + * zero so a penalty larger than the cost can never invert into a credit against the DAO. + * + * The floor is defence in depth rather than a reachable branch: `penalty < cost` is enforced at + * proposal creation and repeated in wei at dao_project_start, so a payout of zero cannot occur. + * That is what lets `paid > 0n` serve as the settled marker in dao_project_milestone_claim. Keep + * the floor anyway — it is the only thing standing between a future gap in those checks and a + * negative payout. * * The USD-to-wei converter is injected, and callers must supply one bound to the project's stored * rate rather than the live one — the DAO's exposure was fixed at the amount minted. @@ -69,8 +73,7 @@ export function milestonePayoutWei( } if (speed === 'late') { const penalty = usdStrToWei(milestone.penaltyUsdStr) - // Floor at zero: a penalty larger than the cost reduces the payment to nothing, but never makes - // the contractor owe the DAO and never adds back to the project balance. + // 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 index 4fc6b60c..99b481ba 100644 --- a/src/utils/daoProjectTxContext.ts +++ b/src/utils/daoProjectTxContext.ts @@ -19,7 +19,13 @@ export interface ProjectTxContext { * means — a mismatch between, say, start and claim on which statuses are acceptable is exactly the * kind of gap that lets a payment through on a project that should be finished. */ -export function loadProjectTxContext(wrappedStates: WrappedStates, fromAddress: string, proposalId: string, milestoneNumber?: unknown): ProjectTxContext { +export function loadProjectTxContext( + wrappedStates: WrappedStates, + fromAddress: string, + proposalId: string, + milestoneNumber?: unknown, + allowedStatuses: string[] = ['executing'], +): ProjectTxContext { const from = wrappedStates[fromAddress]?.data as UserAccount const proposal = wrappedStates[proposalId]?.data as DaoProposalAccount @@ -35,8 +41,11 @@ export function loadProjectTxContext(wrappedStates: WrappedStates, fromAddress: if (!proposal.project) { return { error: 'Project proposal is missing its project data' } } - if (proposal.status !== 'executing') { - return { error: `Project is not executing (current: ${proposal.status})` } + // Most milestone transactions only make sense on a running project, but claiming is deliberately + // allowed after it ends: dao_project_end trims the balance to what completed-but-unclaimed + // milestones still owe precisely so the contractor can collect it. + if (!allowedStatuses.includes(proposal.status)) { + return { error: `Project status ${proposal.status} does not allow this transaction (expected ${allowedStatuses.join(' or ')})` } } const context: ProjectTxContext = { from, proposal, project: proposal.project } diff --git a/test/daoProjectMilestones.test.ts b/test/daoProjectMilestones.test.ts index ba33d5bf..2b399216 100644 --- a/test/daoProjectMilestones.test.ts +++ b/test/daoProjectMilestones.test.ts @@ -90,3 +90,15 @@ describe('validateDaoOptions for project proposals', () => { expect(validateDaoOptions(['yes', 'no'], 'project')).toMatch('rejection choice') }) }) + +describe('milestone claimed marker', () => { + // Regression guard for a bug found in review: `paid` cannot double as the settled marker, + // because a penalty equal to or larger than the cost legitimately pays zero. Using `paid > 0n` + // left those milestones claimable forever. + test('a zero payout is distinguishable from an unclaimed milestone', () => { + const unclaimed = { paid: 0n, claimed: false } + const settledAtZero = { paid: 0n, claimed: true } + expect(unclaimed.paid).toBe(settledAtZero.paid) + expect(unclaimed.claimed).not.toBe(settledAtZero.claimed) + }) +}) From 92740f389bda0dc9c38e7b1f054545c47dd887f2 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 13:07:36 +0800 Subject: [PATCH 10/27] fix(dao): use the receipt API on both paths in project transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All eight project handlers had the wrong createFailedAppReceiptData contract, so a failed project transaction cost its sender nothing. The dispatcher calls it as (tx, txTimestamp, txId, wrappedStates, dapp, applyResponse, reason) returning void, and the function is responsible for both deducting the fee and attaching the receipt. These handlers declared (tx, txId, txTimestamp, reason) returning the receipt, which meant three things went wrong at once on every failure: - no fee was deducted, so failing was free and repeatable at no cost - txTimestamp and txId were transposed, and wrappedStates landed in the reason parameter - the returned receipt was discarded, so no failure receipt was attached at all Rewritten to match transfer exactly: charge the fee, or the sender's whole balance when that is smaller, stamp the account, and attach the receipt through dapp.applyResponseAddReceiptData. The success paths now go through the same API instead of assigning applyResponse.appReceiptData and appReceiptDataHash directly. That was functionally equivalent — the core method only sets those two fields — but every other handler uses the API, and going direct would silently skip anything core later adds to it. --- .../dao/dao_project_change_address.ts | 43 +++++++++++++++++-- src/transactions/dao/dao_project_end.ts | 43 +++++++++++++++++-- .../dao/dao_project_milestone_claim.ts | 43 +++++++++++++++++-- .../dao/dao_project_milestone_end.ts | 43 +++++++++++++++++-- .../dao/dao_project_milestone_start.ts | 43 +++++++++++++++++-- .../dao/dao_project_milestone_terminate.ts | 43 +++++++++++++++++-- .../dao/dao_project_reclaim_balance.ts | 43 +++++++++++++++++-- src/transactions/dao/dao_project_start.ts | 37 +++++++++++++--- 8 files changed, 304 insertions(+), 34 deletions(-) diff --git a/src/transactions/dao/dao_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts index 416a3019..1905a597 100644 --- a/src/transactions/dao/dao_project_change_address.ts +++ b/src/transactions/dao/dao_project_change_address.ts @@ -144,14 +144,49 @@ export const apply = ( committed: result.committed === true, }, } - applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptData = appReceiptData + 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, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +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 => { diff --git a/src/transactions/dao/dao_project_end.ts b/src/transactions/dao/dao_project_end.ts index 8bfc2a13..66f51b0f 100644 --- a/src/transactions/dao/dao_project_end.ts +++ b/src/transactions/dao/dao_project_end.ts @@ -122,14 +122,49 @@ export const apply = ( transactionFee: txFeeWei, additionalInfo: { proposalNumber: proposal.number, proposalStatus: proposal.status, remainingBalanceWei: project.balance }, } - applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptData = appReceiptData + 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, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +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 => { diff --git a/src/transactions/dao/dao_project_milestone_claim.ts b/src/transactions/dao/dao_project_milestone_claim.ts index f3182982..fddc1cd4 100644 --- a/src/transactions/dao/dao_project_milestone_claim.ts +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -140,14 +140,49 @@ export const apply = ( remainingBalanceWei: project.balance, }, } - applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptData = appReceiptData + 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, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +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 => { diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index 31882a05..de1eafbe 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -158,14 +158,49 @@ export const apply = ( committed: result.committed === true, }, } - applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptData = appReceiptData + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) dapp.log('Applied dao_project_milestone_end tx', from.id, tx.proposalId, tx.milestoneNumber) } -export const createFailedAppReceiptData = (tx: Tx.DaoProjectMilestoneEnd, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +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 => { diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts index b8fed179..a650f140 100644 --- a/src/transactions/dao/dao_project_milestone_start.ts +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -156,14 +156,49 @@ export const apply = ( committed: result.committed === true, }, } - applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptData = appReceiptData + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) dapp.log('Applied dao_project_milestone_start tx', from.id, tx.proposalId, tx.milestoneNumber) } -export const createFailedAppReceiptData = (tx: Tx.DaoProjectMilestoneStart, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +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 => { diff --git a/src/transactions/dao/dao_project_milestone_terminate.ts b/src/transactions/dao/dao_project_milestone_terminate.ts index 06a4b820..aa010c47 100644 --- a/src/transactions/dao/dao_project_milestone_terminate.ts +++ b/src/transactions/dao/dao_project_milestone_terminate.ts @@ -143,14 +143,49 @@ export const apply = ( committed, }, } - applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptData = appReceiptData + 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, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +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 => { diff --git a/src/transactions/dao/dao_project_reclaim_balance.ts b/src/transactions/dao/dao_project_reclaim_balance.ts index 69108f84..6e3322f0 100644 --- a/src/transactions/dao/dao_project_reclaim_balance.ts +++ b/src/transactions/dao/dao_project_reclaim_balance.ts @@ -113,14 +113,49 @@ export const apply = ( transactionFee: txFeeWei, additionalInfo: { proposalNumber: proposal.number, reclaimedWei }, } - applyResponse.appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptData = appReceiptData + 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, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { txId, timestamp: txTimestamp, success: false, from: tx.from, to: tx.proposalId, type: tx.type, transactionFee: 0n, additionalInfo: { reason } } +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 => { diff --git a/src/transactions/dao/dao_project_start.ts b/src/transactions/dao/dao_project_start.ts index e81df6a7..97ecec0a 100644 --- a/src/transactions/dao/dao_project_start.ts +++ b/src/transactions/dao/dao_project_start.ts @@ -161,23 +161,48 @@ export const apply = ( }, } const appReceiptDataHash = crypto.hashObj(appReceiptData) - applyResponse.appReceiptDataHash = appReceiptDataHash - applyResponse.appReceiptData = 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, txId: string, txTimestamp: number, reason: string): AppReceiptData => { - return { +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: 0n, - additionalInfo: { reason }, + transactionFee, } + const appReceiptDataHash = crypto.hashObj(appReceiptData) + dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) } export const keys = (tx: Tx.DaoProjectStart, result: ShardusTypes.TransactionKeys): ShardusTypes.TransactionKeys => { From d86468c833c91c354ecef0ec98ce8abcc502129f Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 18:59:55 +0800 Subject: [PATCH 11/27] feat(dao): require a milestone penalty below its cost - reject penalty >= cost in validateProjectMilestones, naming both amounts - drop the zero-cost allowance, which the new rule makes unreachable - keep every late payout strictly positive, so a claim can never settle at zero A penalty is meant to reduce a payment rather than erase it; a milestone where lateness forfeits everything is better expressed as a termination. Since penalty >= 0 was already enforced, penalty < cost implies cost > 0, so the zero-cost case falls out rather than needing its own check. --- src/utils/daoProjectMilestones.ts | 8 ++++++++ test/daoProjectMilestones.test.ts | 26 +++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/utils/daoProjectMilestones.ts b/src/utils/daoProjectMilestones.ts index 12d488db..d42d3e22 100644 --- a/src/utils/daoProjectMilestones.ts +++ b/src/utils/daoProjectMilestones.ts @@ -69,6 +69,14 @@ export function validateProjectMilestones(milestones: unknown): string | undefin 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/test/daoProjectMilestones.test.ts b/test/daoProjectMilestones.test.ts index 2b399216..c654fc37 100644 --- a/test/daoProjectMilestones.test.ts +++ b/test/daoProjectMilestones.test.ts @@ -59,14 +59,34 @@ describe('validateProjectMilestones', () => { expect(validateProjectMilestones([milestone({ costUsdStr: '-5' })])).toMatch('must not be negative') }) - test('zero cost, penalty and bonus are allowed', () => { - // A milestone with no payment is unusual but not malformed — it may exist purely as a checkpoint. - expect(validateProjectMilestones([milestone({ costUsdStr: '0', penaltyUsdStr: '0', bonusUsdStr: '0' })])).toBeUndefined() + 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]') }) }) From 49efce8eff05bf73fecb9ce91c61f225aa2d8f0a Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 19:00:01 +0800 Subject: [PATCH 12/27] feat(dao): reject degenerate milestones at project start - add degenerateMilestoneAtRate, repeating the penalty < cost rule in wei - call it in dao_project_start before the rate is snapshotted and the mint made - catch pairs that are valid as USD strings but truncate together at the rate Creation compares decimal strings, but every payout is a truncating division by the project's rate, so amounts that differ in USD can land on the same wei value. The rate is unknown at creation and known at start, and is fixed for the project's life once snapshotted, so one check here covers every later payout. A project can now pass creation and still fail to start. That is the intended outcome: better than minting escrow against milestones that cannot pay out. --- src/transactions/dao/dao_project_start.ts | 11 +++++- src/utils/daoProjectMint.ts | 23 +++++++++++++ test/daoProjectMint.test.ts | 41 ++++++++++++++++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/transactions/dao/dao_project_start.ts b/src/transactions/dao/dao_project_start.ts index 97ecec0a..e2560c1f 100644 --- a/src/transactions/dao/dao_project_start.ts +++ b/src/transactions/dao/dao_project_start.ts @@ -9,7 +9,7 @@ import { isUserAccount, isDaoProposalAccount } from '../../@types/accountTypeGua import { daoProposalsMetaId } from '../../accounts/daoProposalsMetaAccount' import { recordProposalStatus } from '../../utils/daoProposalIndex' import { getApplyEligibleAt } from '../../accounts/daoProposalAccount' -import { exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../../utils/daoProjectMint' +import { degenerateMilestoneAtRate, exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../../utils/daoProjectMint' import { appendProjectLog } from '../../utils/daoProjectLog' export const validate_fields = (tx: Tx.DaoProjectStart, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { @@ -86,6 +86,15 @@ export const validate = ( 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. diff --git a/src/utils/daoProjectMint.ts b/src/utils/daoProjectMint.ts index 8ea204ee..2d2648fe 100644 --- a/src/utils/daoProjectMint.ts +++ b/src/utils/daoProjectMint.ts @@ -45,3 +45,26 @@ export function exceedsMintThreshold(amountWei: bigint): boolean { 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 every payout is a truncating division by the project's rate. + * Amounts that differ in USD can therefore land on the same wei value — cost "0.000000000000000002" + * and penalty "0.000000000000000001" both truncate to 0 at a large enough rate — which 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 it is fixed for the project's life once + * snapshotted, so checking once at start covers every later payout. Returns the reason a milestone + * fails, or undefined when all of them convert soundly. + */ +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/test/daoProjectMint.test.ts b/test/daoProjectMint.test.ts index c034caf9..e55ad591 100644 --- a/test/daoProjectMint.test.ts +++ b/test/daoProjectMint.test.ts @@ -1,7 +1,7 @@ import { ethers } from 'ethers' import { LiberdusFlags } from '../src/config' import { DaoMilestone } from '../src/@types' -import { exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../src/utils/daoProjectMint' +import { degenerateMilestoneAtRate, exceedsMintThreshold, maxMintThresholdWei, projectMintAmountWei } from '../src/utils/daoProjectMint' const original = LiberdusFlags.daoMaxMintThresholdLibStr @@ -90,3 +90,42 @@ describe('projectMintAmountWei', () => { 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() + }) +}) From c8bad1a445c33601a82e57ebccaf3e7c8451c7f1 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 19:01:17 +0800 Subject: [PATCH 13/27] test(dao): cover the rules that make paid a settled marker - replace the claimed-marker guard with tests for the penalty < cost rule - assert a zero cost is rejected and a late payout stays strictly positive The old guard asserted that `paid` could not double as the settled marker. That premise no longer holds now that a zero payout is unreachable, so the tests cover the two rules that make it unreachable instead. --- test/daoProjectMilestones.test.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/daoProjectMilestones.test.ts b/test/daoProjectMilestones.test.ts index c654fc37..5378e027 100644 --- a/test/daoProjectMilestones.test.ts +++ b/test/daoProjectMilestones.test.ts @@ -111,14 +111,16 @@ describe('validateDaoOptions for project proposals', () => { }) }) -describe('milestone claimed marker', () => { - // Regression guard for a bug found in review: `paid` cannot double as the settled marker, - // because a penalty equal to or larger than the cost legitimately pays zero. Using `paid > 0n` - // left those milestones claimable forever. - test('a zero payout is distinguishable from an unclaimed milestone', () => { - const unclaimed = { paid: 0n, claimed: false } - const settledAtZero = { paid: 0n, claimed: true } - expect(unclaimed.paid).toBe(settledAtZero.paid) - expect(unclaimed.claimed).not.toBe(settledAtZero.claimed) +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() }) }) From bd0daf8bc7082a34378891cc6f0a338e58ab9d68 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 19:06:51 +0800 Subject: [PATCH 14/27] fix(dao): stop a milestone endorsement counting toward an unseen value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add writeOnceError, applied by the milestone paths before they endorse - reject a second proposed time, which also enforces the contractor's one call - read the pending value before writing it, so a first proposal is not mistaken for a re-proposal An endorsement binds to a milestone rather than to a value, so it could be retargeted: a re-proposal landing between a sender forming an endorsement and it being processed silently converted an endorsement of one time into an endorsement of another. Forbidding a second proposal closes that, since what a sender endorses can no longer change under them. It also enforces the policy's "the contractor can only call this once", which applyEndorsement's contractor check did not cover — nothing stopped them re-proposing, and every proposal resets the count, so they could stall their own milestone indefinitely. The check sits in the callers rather than in applyEndorsement because the contractor address path must not have it: proposedAddress is cleared only on a successful commit, so a first proposal nobody endorses would freeze the contractor address for the life of the project, and changing that address is the remedy for a lost or compromised contractor key. Policy line 353 provides for re-proposal there for that reason. The same race therefore stays open on that path, bounded by the threshold: a redirected endorsement still leaves two members who actually chose the committed address. --- .../dao/dao_project_change_address.ts | 9 ++++- .../dao/dao_project_milestone_end.ts | 21 +++++++++- .../dao/dao_project_milestone_start.ts | 21 +++++++++- src/utils/daoProjectEndorsement.ts | 32 ++++++++++++--- test/daoProjectEndorsement.test.ts | 39 ++++++++++++++++--- 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/transactions/dao/dao_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts index 1905a597..3dc26dc7 100644 --- a/src/transactions/dao/dao_project_change_address.ts +++ b/src/transactions/dao/dao_project_change_address.ts @@ -112,10 +112,17 @@ export const apply = ( const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) + // Presence, per policy line 352: "if called without an address it is endorsing the proposed + // address". Read hadPendingAddress before the assignment below so the endorse branch cannot see a + // pending value this transaction just created. const isProposing = tx.proposedAddress !== undefined + const hadPendingAddress = project.proposedAddress !== undefined if (isProposing) project.proposedAddress = tx.proposedAddress // No contractor slot here — passing undefined keeps the threshold clamped to the committee size. - const result = applyEndorsement(project.endorsedAddress, tx.from, isProposing, proposal.committeeAddresses, undefined, project.proposedAddress !== undefined) + const result = applyEndorsement(project.endorsedAddress, tx.from, isProposing, proposal.committeeAddresses, undefined, hadPendingAddress) + // validate() dry-runs the same call against a copy, so an error here means the two disagreed. + // Throwing rather than continuing keeps a half-applied endorsement out of consensus state. + if (result.error) throw new Error(`dao_project_change_address endorsement failed after validation: ${result.error}`) const previousAddress = project.address if (result.committed) { diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index de1eafbe..8f462e72 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -6,7 +6,7 @@ import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' -import { applyEndorsement } from '../../utils/daoProjectEndorsement' +import { applyEndorsement, writeOnceError } from '../../utils/daoProjectEndorsement' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' export const validate_fields = (tx: Tx.DaoProjectMilestoneEnd, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { @@ -67,6 +67,14 @@ export const validate = ( return response } + // Write-once, applied here rather than inside applyEndorsement because the address path must not + // have it. + const writeOnce = writeOnceError(milestone.proposedTime !== undefined, tx.proposedTime !== undefined) + if (writeOnce) { + response.reason = writeOnce + return response + } + // Dry-run the endorsement against a copy so validate() reports the same rejection apply() would, // without mutating consensus state here. const dryRun = applyEndorsement( @@ -110,6 +118,10 @@ export const apply = ( from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) const isProposing = tx.proposedTime !== undefined + // Read before the assignment below. Taken afterwards it would always be true when proposing, so + // write-once would reject the very first proposal and leave endorsedTime empty. + const hadPendingTime = milestone.proposedTime !== undefined + const writeOnceViolation = writeOnceError(hadPendingTime, isProposing) if (isProposing) milestone.proposedTime = tx.proposedTime const result = applyEndorsement( milestone.endorsedTime, @@ -117,8 +129,13 @@ export const apply = ( isProposing, proposal.committeeAddresses, project.address, - milestone.proposedTime !== undefined, + hadPendingTime, ) + // validate() checks both of these against the same wrappedStates, so reaching either here means + // the two disagreed. Throwing rather than continuing keeps a half-applied endorsement out of + // consensus state. + if (writeOnceViolation) throw new Error(`dao_project_milestone_end accepted a second proposal: ${writeOnceViolation}`) + if (result.error) throw new Error(`dao_project_milestone_end endorsement failed after validation: ${result.error}`) if (result.committed) { milestone.endTime = milestone.proposedTime diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts index a650f140..a2c53a6e 100644 --- a/src/transactions/dao/dao_project_milestone_start.ts +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -6,7 +6,7 @@ import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' -import { applyEndorsement } from '../../utils/daoProjectEndorsement' +import { applyEndorsement, writeOnceError } from '../../utils/daoProjectEndorsement' import { canStartMilestone } from '../../utils/daoProjectMilestoneState' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' @@ -68,6 +68,14 @@ export const validate = ( return response } + // Write-once, applied here rather than inside applyEndorsement because the address path must not + // have it. + const writeOnce = writeOnceError(milestone.proposedTime !== undefined, tx.proposedTime !== undefined) + if (writeOnce) { + response.reason = writeOnce + return response + } + // Dry-run the endorsement against a copy so validate() reports the same rejection apply() would, // without mutating consensus state here. const dryRun = applyEndorsement( @@ -111,6 +119,10 @@ export const apply = ( from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) const isProposing = tx.proposedTime !== undefined + // Read before the assignment below. Taken afterwards it would always be true when proposing, so + // write-once would reject the very first proposal and leave endorsedTime empty. + const hadPendingTime = milestone.proposedTime !== undefined + const writeOnceViolation = writeOnceError(hadPendingTime, isProposing) if (isProposing) milestone.proposedTime = tx.proposedTime const result = applyEndorsement( milestone.endorsedTime, @@ -118,8 +130,13 @@ export const apply = ( isProposing, proposal.committeeAddresses, project.address, - milestone.proposedTime !== undefined, + hadPendingTime, ) + // validate() checks both of these against the same wrappedStates, so reaching either here means + // the two disagreed. Throwing rather than continuing keeps a half-applied endorsement out of + // consensus state. + if (writeOnceViolation) throw new Error(`dao_project_milestone_start accepted a second proposal: ${writeOnceViolation}`) + if (result.error) throw new Error(`dao_project_milestone_start endorsement failed after validation: ${result.error}`) if (result.committed) { milestone.startTime = milestone.proposedTime diff --git a/src/utils/daoProjectEndorsement.ts b/src/utils/daoProjectEndorsement.ts index 72660e36..195ca07a 100644 --- a/src/utils/daoProjectEndorsement.ts +++ b/src/utils/daoProjectEndorsement.ts @@ -28,12 +28,36 @@ export interface EndorsementCheck { committed?: boolean } +/** + * Write-once: rejects a second proposal for a value that already has one pending. + * + * Applied by the milestone paths before endorsing, and deliberately not inside applyEndorsement, + * because the contractor address path must not have it. There, `proposedAddress` is cleared only on + * a successful commit, so a first proposal nobody endorses would freeze the contractor address for + * the life of the project — and changing that address is the remedy for a lost or compromised + * contractor key. Policy line 353 provides for re-proposal there for exactly that reason. + * + * On a milestone the rule is safe because a bad value still has an escape: + * dao_project_milestone_terminate accepts a milestone in `pending` state. What it buys is that a + * pending time cannot be replaced, so an endorsement cannot end up counting toward a time its + * sender never saw. It also enforces the policy's "the contractor can only call this once", which + * the contractor check in applyEndorsement does not cover — nothing there stops them re-proposing, + * and every proposal resets the count, so they could stall their own milestone indefinitely. + */ +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. * * The three project paths that need agreement — milestone start, milestone end, contractor address - * — share this shape exactly: a submission carrying a value replaces whatever was pending and - * re-seeds the endorsements with its sender; a submission without one endorses what is pending. + * — share this shape: a submission carrying a value replaces whatever was pending and re-seeds the + * endorsements with its sender; a submission without one endorses what is pending. + * + * Whether a second proposal is allowed at all is the caller's decision, not this function's — see + * writeOnceError, which the milestone paths apply and the address path deliberately does not. * * `endorsements` is the live array and is mutated. Callers own the proposed value itself, because * its type differs per path (a timestamp or an address). @@ -53,9 +77,7 @@ export function applyEndorsement( if (!isCommittee && !isContractor) { return { error: 'Only a committee member or the contractor may submit this transaction' } } - // The contractor gets exactly one move: opening a proposal. Letting them endorse would let them - // occupy two of the three slots, and letting them re-propose would let them reset the count every - // time the committee got close to agreeing. + // 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' } } diff --git a/test/daoProjectEndorsement.test.ts b/test/daoProjectEndorsement.test.ts index 522b56fb..02c061bf 100644 --- a/test/daoProjectEndorsement.test.ts +++ b/test/daoProjectEndorsement.test.ts @@ -1,4 +1,4 @@ -import { applyEndorsement, PROJECT_ENDORSEMENT_THRESHOLD, requiredEndorsements } from '../src/utils/daoProjectEndorsement' +import { applyEndorsement, PROJECT_ENDORSEMENT_THRESHOLD, requiredEndorsements, writeOnceError } from '../src/utils/daoProjectEndorsement' const C1 = 'c1' const C2 = 'c2' @@ -70,16 +70,16 @@ describe('applyEndorsement', () => { expect(e).toEqual([]) }) - test('re-proposing clears the list and re-seeds it with the new proposer', () => { - // A new value is a different question — endorsements of the old one must not carry over. + 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]) - const result = applyEndorsement(e, C3, true, COMMITTEE, undefined, true) + applyEndorsement(e, C3, true, COMMITTEE, undefined, true) expect(e).toEqual([C3]) - expect(result.committed).toBe(false) }) test('the same address cannot endorse twice', () => { @@ -125,3 +125,32 @@ describe('applyEndorsement', () => { 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() + }) +}) From 9694ccc6abd69c9a87ce0204836e2b805fc80d60 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 19:12:15 +0800 Subject: [PATCH 15/27] refactor(dao): state each project transaction's status rule inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop the status check and allowedStatuses parameter from the loader - state the status rule in each of the eight handlers, beside its other guards - return a discriminated union so callers cannot reach the accounts on error - adopt the loader in start, change address and reclaim, which loaded by hand The eight project transactions use five different status rules between them, so a shared default fitted half of them and had to be overridden by the rest. That default also caused the bug the loader existed to prevent: it silently applied "executing" to the claim handler and blocked the post-end claim that dao_project_end trims the balance for, with nothing at the call site showing which statuses were allowed. Removing it leaves nothing project-status-specific in the loader, so all eight handlers can share it rather than five. Behaviour is unchanged — each inline rule matches the status set its handler had before. --- .../dao/dao_project_change_address.ts | 16 ++--- src/transactions/dao/dao_project_end.ts | 5 ++ .../dao/dao_project_milestone_claim.ts | 10 ++- .../dao/dao_project_milestone_end.ts | 5 ++ .../dao/dao_project_milestone_start.ts | 5 ++ .../dao/dao_project_milestone_terminate.ts | 5 ++ .../dao/dao_project_reclaim_balance.ts | 16 ++--- src/transactions/dao/dao_project_start.ts | 24 ++----- src/utils/daoProjectTxContext.ts | 66 ++++++++++--------- 9 files changed, 80 insertions(+), 72 deletions(-) diff --git a/src/transactions/dao/dao_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts index 3dc26dc7..0479cfdc 100644 --- a/src/transactions/dao/dao_project_change_address.ts +++ b/src/transactions/dao/dao_project_change_address.ts @@ -4,8 +4,8 @@ import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } fr import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' -import { isUserAccount, isDaoProposalAccount } from '../../@types/accountTypeGuards' import { appendProjectLog } from '../../utils/daoProjectLog' +import { loadProjectTxContext } from '../../utils/daoProjectTxContext' import { applyEndorsement } from '../../utils/daoProjectEndorsement' export const validate_fields = (tx: Tx.DaoProjectChangeAddress, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { @@ -38,18 +38,12 @@ export const validate = ( wrappedStates: WrappedStates, response: ShardusTypes.IncomingTransactionResult, ): ShardusTypes.IncomingTransactionResult => { - const from = wrappedStates[tx.from]?.data as UserAccount - const proposal = wrappedStates[tx.proposalId]?.data as DaoProposalAccount - - if (!from || !isUserAccount(from)) { - response.reason = 'from account not found or is not a UserAccount' + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error return response } - if (!proposal || !isDaoProposalAccount(proposal) || proposal.proposalType !== 'project' || !proposal.project) { - response.reason = 'Proposal is not a project proposal' - return response - } - const project = proposal.project + 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 diff --git a/src/transactions/dao/dao_project_end.ts b/src/transactions/dao/dao_project_end.ts index 66f51b0f..82a8da74 100644 --- a/src/transactions/dao/dao_project_end.ts +++ b/src/transactions/dao/dao_project_end.ts @@ -44,6 +44,11 @@ export const validate = ( } const { from, proposal, project } = ctx + if (proposal.status !== 'executing') { + response.reason = `Project is not executing (current: ${proposal.status})` + return response + } + if (!proposal.committeeAddresses.includes(tx.from)) { response.reason = 'Only a committee member can end a project' return response diff --git a/src/transactions/dao/dao_project_milestone_claim.ts b/src/transactions/dao/dao_project_milestone_claim.ts index fddc1cd4..6d70cc9b 100644 --- a/src/transactions/dao/dao_project_milestone_claim.ts +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -40,12 +40,18 @@ export const validate = ( 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, ['executing', 'completed', 'terminated']) + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber) if (ctx.error) { response.reason = ctx.error return response } - const { from, project, milestone } = ctx + 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 status ${proposal.status} does not allow claiming (expected executing, completed or terminated)` + return response + } // Only the contractor is paid, and only for work the committee agreed was finished. if (tx.from !== project.address) { diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index 8f462e72..c5962754 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -57,6 +57,11 @@ export const validate = ( } const { from, proposal, project, milestone } = ctx + if (proposal.status !== 'executing') { + response.reason = `Project is not executing (current: ${proposal.status})` + return response + } + if (milestone.status !== 'executing') { response.reason = `Milestone ${tx.milestoneNumber} is not executing (current: ${milestone.status})` return response diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts index a2c53a6e..436feb2c 100644 --- a/src/transactions/dao/dao_project_milestone_start.ts +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -58,6 +58,11 @@ export const validate = ( } const { from, proposal, project, milestone, milestoneIndex } = ctx + if (proposal.status !== 'executing') { + response.reason = `Project is not executing (current: ${proposal.status})` + return response + } + if (milestone.status !== 'pending') { response.reason = `Milestone ${tx.milestoneNumber} is not pending (current: ${milestone.status})` return response diff --git a/src/transactions/dao/dao_project_milestone_terminate.ts b/src/transactions/dao/dao_project_milestone_terminate.ts index aa010c47..fd703095 100644 --- a/src/transactions/dao/dao_project_milestone_terminate.ts +++ b/src/transactions/dao/dao_project_milestone_terminate.ts @@ -56,6 +56,11 @@ export const validate = ( } const { from, proposal, project, milestone } = ctx + if (proposal.status !== 'executing') { + response.reason = `Project is not executing (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})` diff --git a/src/transactions/dao/dao_project_reclaim_balance.ts b/src/transactions/dao/dao_project_reclaim_balance.ts index 6e3322f0..fca9c16b 100644 --- a/src/transactions/dao/dao_project_reclaim_balance.ts +++ b/src/transactions/dao/dao_project_reclaim_balance.ts @@ -5,8 +5,8 @@ import { UserAccount, WrappedStates, Tx, AppReceiptData, DaoProposalAccount } fr import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' -import { isUserAccount, isDaoProposalAccount } from '../../@types/accountTypeGuards' 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) { @@ -34,18 +34,12 @@ export const validate = ( wrappedStates: WrappedStates, response: ShardusTypes.IncomingTransactionResult, ): ShardusTypes.IncomingTransactionResult => { - const from = wrappedStates[tx.from]?.data as UserAccount - const proposal = wrappedStates[tx.proposalId]?.data as DaoProposalAccount - - if (!from || !isUserAccount(from)) { - response.reason = 'from account not found or is not a UserAccount' + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) + if (ctx.error) { + response.reason = ctx.error return response } - if (!proposal || !isDaoProposalAccount(proposal) || proposal.proposalType !== 'project' || !proposal.project) { - response.reason = 'Proposal is not a project proposal' - return response - } - const project = proposal.project + const { from, proposal, project } = ctx if (proposal.status !== 'completed' && proposal.status !== 'terminated') { response.reason = `Project has not ended (current: ${proposal.status})` diff --git a/src/transactions/dao/dao_project_start.ts b/src/transactions/dao/dao_project_start.ts index e2560c1f..837670ac 100644 --- a/src/transactions/dao/dao_project_start.ts +++ b/src/transactions/dao/dao_project_start.ts @@ -5,12 +5,12 @@ import { NetworkAccount, UserAccount, WrappedStates, Tx, AppReceiptData, DaoProp import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' -import { isUserAccount, isDaoProposalAccount } from '../../@types/accountTypeGuards' 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) { @@ -38,34 +38,22 @@ export const validate = ( wrappedStates: WrappedStates, response: ShardusTypes.IncomingTransactionResult, ): ShardusTypes.IncomingTransactionResult => { - const from = wrappedStates[tx.from]?.data as UserAccount - const proposal = wrappedStates[tx.proposalId]?.data as DaoProposalAccount const network = wrappedStates[config.networkAccount]?.data as NetworkAccount - - if (!from || !isUserAccount(from)) { - response.reason = 'from account not found or is not a UserAccount' - return response - } - if (!proposal || !isDaoProposalAccount(proposal)) { - response.reason = 'Proposal account not found or is not a DaoProposalAccount' + 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.proposalType !== 'project') { - response.reason = `Proposal type "${proposal.proposalType}" is not a project` - return response - } if (proposal.status !== 'accepted') { response.reason = `Proposal is not in accepted status (current: ${proposal.status})` return response } - if (!proposal.project) { - response.reason = 'Project proposal is missing its project data' - 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)) { diff --git a/src/utils/daoProjectTxContext.ts b/src/utils/daoProjectTxContext.ts index 99b481ba..5c6a0284 100644 --- a/src/utils/daoProjectTxContext.ts +++ b/src/utils/daoProjectTxContext.ts @@ -2,30 +2,44 @@ import { WrappedStates, UserAccount, DaoProposalAccount, DaoProjectData, DaoMile import { isUserAccount, isDaoProposalAccount } from '../@types/accountTypeGuards' import { resolveMilestone } from './daoProjectMilestoneState' -export interface ProjectTxContext { - from?: UserAccount - proposal?: DaoProposalAccount - project?: DaoProjectData +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 - error?: string } /** - * The preamble every milestone transaction repeats: load the accounts, confirm this really is a - * running project, and resolve the milestone number. + * Either the loaded accounts or the reason they could not be loaded, never a mix of both. * - * Shared so the six milestone transactions cannot drift apart on what "a valid project transaction" - * means — a mismatch between, say, start and claim on which statuses are acceptable is exactly the - * kind of gap that lets a payment through on a project that should be finished. + * 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 function loadProjectTxContext( - wrappedStates: WrappedStates, - fromAddress: string, - proposalId: string, - milestoneNumber?: unknown, - allowedStatuses: string[] = ['executing'], -): ProjectTxContext { +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 @@ -41,19 +55,11 @@ export function loadProjectTxContext( if (!proposal.project) { return { error: 'Project proposal is missing its project data' } } - // Most milestone transactions only make sense on a running project, but claiming is deliberately - // allowed after it ends: dao_project_end trims the balance to what completed-but-unclaimed - // milestones still owe precisely so the contractor can collect it. - if (!allowedStatuses.includes(proposal.status)) { - return { error: `Project status ${proposal.status} does not allow this transaction (expected ${allowedStatuses.join(' or ')})` } - } - const context: ProjectTxContext = { from, proposal, project: proposal.project } - if (milestoneNumber !== undefined) { - const resolved = resolveMilestone(proposal.project, milestoneNumber) - if (resolved.error) return { error: resolved.error } - context.milestone = resolved.milestone - context.milestoneIndex = resolved.index + if (milestoneNumber === undefined) { + return { from, proposal, project: proposal.project } } - return context + 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 } } From cad6961bca1d0e6ca327a5ae0ffeafb8a1a69042 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 19:16:29 +0800 Subject: [PATCH 16/27] feat(dao): derive the milestone for a start or end transaction - add findNextPendingMilestone and findExecutingMilestone - drop milestoneNumber from the start and end transactions, schema and client - report the derived number in receipts and logs, so neither loses which milestone was acted on - keep milestoneNumber on terminate and claim, which the policy requires The policy names a different rule per transaction: "start the next milestone" and "end the current milestone" identify a milestone by state, while terminate and claim name one explicitly. All four had been flattened into "the sender supplies a number". Both derivations are pure functions of wrappedStates, so every node resolves the same milestone. A start can still retarget if its milestone finishes between the sender forming the transaction and it being processed, but canStartMilestone then rejects the new target because its predecessor is not finished; C5's write-once rule closes the matching hole on the value being endorsed. --- client.js | 13 +++--- src/@types/index.ts | 8 ++-- src/@types/transactionSchemas.ts | 3 +- .../dao/dao_project_milestone_end.ts | 29 +++++++------ .../dao/dao_project_milestone_start.ts | 31 +++++++------ src/utils/daoProjectMilestoneState.ts | 31 +++++++++++++ test/daoProjectMilestoneState.test.ts | 43 ++++++++++++++++++- 7 files changed, 122 insertions(+), 36 deletions(-) diff --git a/client.js b/client.js index 65771d74..b4981016 100644 --- a/client.js +++ b/client.js @@ -3246,16 +3246,19 @@ vorpal 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'], + ['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; a value proposes one and resets endorsements. The time may - // not be in the future — the server rejects that rather than crediting unserved duration. + // 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, { milestoneNumber: args.milestone, ...extra })) + await submitProjectTx(this, projectTx(type, args.number, extra)) callback() }) } diff --git a/src/@types/index.ts b/src/@types/index.ts index 0873f381..01d3c462 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -607,19 +607,21 @@ export namespace Tx { 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 - /** 1-based, matching how proposals are addressed externally. */ - milestoneNumber: number /** 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 - milestoneNumber: number proposedTime?: number } diff --git a/src/@types/transactionSchemas.ts b/src/@types/transactionSchemas.ts index 506a54c4..54eb0356 100644 --- a/src/@types/transactionSchemas.ts +++ b/src/@types/transactionSchemas.ts @@ -900,11 +900,10 @@ export const schemaDaoProjectMilestoneTimeTX = { ...baseTxProperties, from: { type: 'string' }, proposalId: { type: 'string', minLength: 64, maxLength: 64 }, - milestoneNumber: { type: 'number', minimum: 1 }, proposedTime: { type: 'number', minimum: 0 }, networkId: { type: 'string' }, }, - required: [...baseTxRequired, 'from', 'proposalId', 'milestoneNumber'], + required: [...baseTxRequired, 'from', 'proposalId'], additionalProperties: false, } diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index c5962754..d1803584 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -7,6 +7,7 @@ import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' import { applyEndorsement, writeOnceError } 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 => { @@ -18,10 +19,6 @@ export const validate_fields = (tx: Tx.DaoProjectMilestoneEnd, response: Shardus 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.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' @@ -50,22 +47,26 @@ export const validate = ( wrappedStates: WrappedStates, response: ShardusTypes.IncomingTransactionResult, ): ShardusTypes.IncomingTransactionResult => { - const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber) + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) if (ctx.error) { response.reason = ctx.error return response } - const { from, proposal, project, milestone } = ctx + const { from, proposal, project } = ctx if (proposal.status !== 'executing') { response.reason = `Project is not executing (current: ${proposal.status})` return response } - if (milestone.status !== 'executing') { - response.reason = `Milestone ${tx.milestoneNumber} is not executing (current: ${milestone.status})` + // 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})` @@ -117,7 +118,11 @@ export const apply = ( 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] + // 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) @@ -159,7 +164,7 @@ export const apply = ( tx.from, txTimestamp, 'dao_project_milestone_end', - `milestone=${tx.milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, + `milestone=${milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, ) from.timestamp = txTimestamp @@ -174,7 +179,7 @@ export const apply = ( type: tx.type, transactionFee: txFeeWei, additionalInfo: { - milestoneNumber: tx.milestoneNumber, + milestoneNumber, milestoneStatus: milestone.status, endorsements: milestone.endorsedTime.length, committed: result.committed === true, @@ -183,7 +188,7 @@ export const apply = ( const appReceiptDataHash = crypto.hashObj(appReceiptData) dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) - dapp.log('Applied dao_project_milestone_end tx', from.id, tx.proposalId, tx.milestoneNumber) + dapp.log('Applied dao_project_milestone_end tx', from.id, tx.proposalId, milestoneNumber) } export const createFailedAppReceiptData = ( diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts index 436feb2c..9d22c93b 100644 --- a/src/transactions/dao/dao_project_milestone_start.ts +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -7,7 +7,7 @@ import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' import { applyEndorsement, writeOnceError } from '../../utils/daoProjectEndorsement' -import { canStartMilestone } from '../../utils/daoProjectMilestoneState' +import { canStartMilestone, findNextPendingMilestone } from '../../utils/daoProjectMilestoneState' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' export const validate_fields = (tx: Tx.DaoProjectMilestoneStart, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { @@ -19,10 +19,6 @@ export const validate_fields = (tx: Tx.DaoProjectMilestoneStart, response: Shard 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.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' @@ -51,22 +47,27 @@ export const validate = ( wrappedStates: WrappedStates, response: ShardusTypes.IncomingTransactionResult, ): ShardusTypes.IncomingTransactionResult => { - const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId, tx.milestoneNumber) + const ctx = loadProjectTxContext(wrappedStates, tx.from, tx.proposalId) if (ctx.error) { response.reason = ctx.error return response } - const { from, proposal, project, milestone, milestoneIndex } = ctx + const { from, proposal, project } = ctx if (proposal.status !== 'executing') { response.reason = `Project is not executing (current: ${proposal.status})` return response } - if (milestone.status !== 'pending') { - response.reason = `Milestone ${tx.milestoneNumber} is not pending (current: ${milestone.status})` + // 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 @@ -118,7 +119,11 @@ export const apply = ( 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] + // 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) @@ -157,7 +162,7 @@ export const apply = ( tx.from, txTimestamp, 'dao_project_milestone_start', - `milestone=${tx.milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, + `milestone=${milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, ) from.timestamp = txTimestamp @@ -172,7 +177,7 @@ export const apply = ( type: tx.type, transactionFee: txFeeWei, additionalInfo: { - milestoneNumber: tx.milestoneNumber, + milestoneNumber, milestoneStatus: milestone.status, endorsements: milestone.endorsedTime.length, committed: result.committed === true, @@ -181,7 +186,7 @@ export const apply = ( const appReceiptDataHash = crypto.hashObj(appReceiptData) dapp.applyResponseAddReceiptData(applyResponse, appReceiptData, appReceiptDataHash) - dapp.log('Applied dao_project_milestone_start tx', from.id, tx.proposalId, tx.milestoneNumber) + dapp.log('Applied dao_project_milestone_start tx', from.id, tx.proposalId, milestoneNumber) } export const createFailedAppReceiptData = ( diff --git a/src/utils/daoProjectMilestoneState.ts b/src/utils/daoProjectMilestoneState.ts index fd348e93..59aa3fc1 100644 --- a/src/utils/daoProjectMilestoneState.ts +++ b/src/utils/daoProjectMilestoneState.ts @@ -36,6 +36,37 @@ export function canStartMilestone(project: DaoProjectData, index: number): strin 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 does not supply + * a number. A pure function of the project data, so every node derives the same milestone. + * + * `canStartMilestone` still runs at the call site: this only says which milestone is next in line, + * not that it may start yet. + */ +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, because a milestone cannot start while an earlier one is unfinished, so + * "the current milestone" resolves unambiguously without the sender naming it. + */ +export function findExecutingMilestone(project: DaoProjectData): { milestone?: DaoMilestone; index?: number; error?: string } { + const index = project.milestones.findIndex((m) => m.status === 'executing') + if (index === -1) { + return { error: 'No milestone is executing; there is nothing to end' } + } + return { milestone: project.milestones[index], index } +} + /** 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/test/daoProjectMilestoneState.test.ts b/test/daoProjectMilestoneState.test.ts index 6dac5e70..ef0abf27 100644 --- a/test/daoProjectMilestoneState.test.ts +++ b/test/daoProjectMilestoneState.test.ts @@ -1,5 +1,5 @@ import { DaoProjectData } from '../src/@types' -import { allMilestonesFinished, canStartMilestone, resolveMilestone } from '../src/utils/daoProjectMilestoneState' +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 @@ -55,3 +55,44 @@ describe('allMilestonesFinished', () => { 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() + }) +}) From 461139745ea6c3a1a84197c4178c6102b9153a56 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 19:20:35 +0800 Subject: [PATCH 17/27] refactor(dao): give project log entries structured parameters - replace the formatted params string with a typed key-value object - type txType as DaoProjectTxType rather than a bare string - record what identifies an action, not what resulted from it - render the object as key=value pairs in the client The trail exists for disputes between the contractor and the DAO, so a reader should be able to query it rather than parse a sentence. Amounts minted, paid, owed and reclaimed are dropped: they are products of the handler and already recoverable from the account state and the transaction receipt. Keeping them out also means no value in params is ever a bigint. Milestone start and end still record their milestone number even though C3 derives it, since an entry that cannot say which milestone was acted on is not much of an audit entry. --- client.js | 8 ++- src/@types/index.ts | 23 ++++++- .../dao/dao_project_change_address.ts | 2 +- src/transactions/dao/dao_project_end.ts | 2 +- .../dao/dao_project_milestone_claim.ts | 2 +- .../dao/dao_project_milestone_end.ts | 2 +- .../dao/dao_project_milestone_start.ts | 4 +- .../dao/dao_project_milestone_terminate.ts | 2 +- .../dao/dao_project_reclaim_balance.ts | 2 +- src/transactions/dao/dao_project_start.ts | 2 +- src/utils/daoProjectLog.ts | 23 ++++--- test/daoProjectLog.test.ts | 63 +++++++++++++++++++ 12 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 test/daoProjectLog.test.ts diff --git a/client.js b/client.js index b4981016..5524febf 100644 --- a/client.js +++ b/client.js @@ -3214,7 +3214,13 @@ vorpal.command('dao project logs ', 'show a project audit trail').action 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) this.log(`${new Date(l.timestamp).toISOString()} ${l.txType} by ${l.caller}${l.params ? ` | ${l.params}` : ''}`) + 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) } diff --git a/src/@types/index.ts b/src/@types/index.ts index 01d3c462..0171a280 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -903,11 +903,30 @@ export interface DaoTerminateVote { } /** Append-only audit trail, started when the project enters `executing`. Uncapped by decision. */ +/** 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' + export interface DaoProjectLogEntry { caller: string timestamp: number - txType: string - params?: string + 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 { diff --git a/src/transactions/dao/dao_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts index 0479cfdc..9b291954 100644 --- a/src/transactions/dao/dao_project_change_address.ts +++ b/src/transactions/dao/dao_project_change_address.ts @@ -125,7 +125,7 @@ export const apply = ( project.endorsedAddress = [] } - appendProjectLog(project, tx.from, txTimestamp, 'dao_project_change_address', `proposed=${tx.proposedAddress ?? ''} committed=${result.committed === true}`) + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_change_address', { proposedAddress: tx.proposedAddress }) from.timestamp = txTimestamp proposal.timestamp = txTimestamp diff --git a/src/transactions/dao/dao_project_end.ts b/src/transactions/dao/dao_project_end.ts index 82a8da74..8d44847d 100644 --- a/src/transactions/dao/dao_project_end.ts +++ b/src/transactions/dao/dao_project_end.ts @@ -108,7 +108,7 @@ export const apply = ( proposal.status = lastMilestone.status === 'terminated' ? 'terminated' : 'completed' proposal.timestamp = txTimestamp - appendProjectLog(project, tx.from, txTimestamp, 'dao_project_end', `status=${proposal.status} owed=${owedWei}`) + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_end') if (proposal.status !== previousStatus) { recordProposalStatus(meta, proposal.number, proposal.status, proposal.emergency, txTimestamp) diff --git a/src/transactions/dao/dao_project_milestone_claim.ts b/src/transactions/dao/dao_project_milestone_claim.ts index 6d70cc9b..23a32bc1 100644 --- a/src/transactions/dao/dao_project_milestone_claim.ts +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -124,7 +124,7 @@ export const apply = ( tx.from, txTimestamp, 'dao_project_milestone_claim', - `milestone=${tx.milestoneNumber} speed=${payout.speed} paid=${payout.amountWei}`, + { milestoneNumber: tx.milestoneNumber }, ) from.timestamp = txTimestamp diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index d1803584..9cd97d5e 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -164,7 +164,7 @@ export const apply = ( tx.from, txTimestamp, 'dao_project_milestone_end', - `milestone=${milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, + tx.proposedTime === undefined ? { milestoneNumber } : { milestoneNumber, proposedTime: tx.proposedTime }, ) from.timestamp = txTimestamp diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts index 9d22c93b..96066025 100644 --- a/src/transactions/dao/dao_project_milestone_start.ts +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -162,7 +162,9 @@ export const apply = ( tx.from, txTimestamp, 'dao_project_milestone_start', - `milestone=${milestoneNumber} proposed=${tx.proposedTime ?? ''} committed=${result.committed === true}`, + // 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 diff --git a/src/transactions/dao/dao_project_milestone_terminate.ts b/src/transactions/dao/dao_project_milestone_terminate.ts index fd703095..f6d23122 100644 --- a/src/transactions/dao/dao_project_milestone_terminate.ts +++ b/src/transactions/dao/dao_project_milestone_terminate.ts @@ -127,7 +127,7 @@ export const apply = ( tx.from, txTimestamp, 'dao_project_milestone_terminate', - `milestone=${tx.milestoneNumber} votes=${milestone.terminateVotes.length} committed=${committed} reason=${tx.reason}`, + { milestoneNumber: tx.milestoneNumber, reason: tx.reason }, ) from.timestamp = txTimestamp diff --git a/src/transactions/dao/dao_project_reclaim_balance.ts b/src/transactions/dao/dao_project_reclaim_balance.ts index fca9c16b..2934237c 100644 --- a/src/transactions/dao/dao_project_reclaim_balance.ts +++ b/src/transactions/dao/dao_project_reclaim_balance.ts @@ -92,7 +92,7 @@ export const apply = ( const reclaimedWei = project.balance project.balance = 0n - appendProjectLog(project, tx.from, txTimestamp, 'dao_project_reclaim_balance', `reclaimed=${reclaimedWei}`) + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_reclaim_balance') from.timestamp = txTimestamp proposal.timestamp = txTimestamp diff --git a/src/transactions/dao/dao_project_start.ts b/src/transactions/dao/dao_project_start.ts index 837670ac..1cc07ad6 100644 --- a/src/transactions/dao/dao_project_start.ts +++ b/src/transactions/dao/dao_project_start.ts @@ -131,7 +131,7 @@ export const apply = ( proposal.status = 'executing' proposal.timestamp = txTimestamp - appendProjectLog(project, tx.from, txTimestamp, 'dao_project_start', `mint=${mintWei} rate=${project.rateUsdStr}`) + 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. diff --git a/src/utils/daoProjectLog.ts b/src/utils/daoProjectLog.ts index f64b5f04..5e855ec7 100644 --- a/src/utils/daoProjectLog.ts +++ b/src/utils/daoProjectLog.ts @@ -1,18 +1,27 @@ -import { DaoProjectData, DaoProjectLogEntry } from '../@types' +import { DaoProjectData, DaoProjectLogEntry, DaoProjectTxType } from '../@types' /** * Appends to the project's audit trail. Every project transaction records who called, when, and * what it did — the policy keeps this "in case of any dispute between the contractor and DAO". * + * `params` is a structured object rather than a formatted string so a reader can query the trail + * without parsing it, and so the shape of an entry is checked at the call site. It carries what + * identifies the action, not what resulted from it — see DaoProjectLogEntry. + * * Deliberately uncapped for now. The log grows with committee behaviour rather than with the - * milestone count: re-proposing a start time, end time or address is unlimited, and each attempt - * appends. Bounding the milestones does not bound this. Acceptable because every appender is a - * committee member or the contractor, so growth needs insiders being persistent or adversarial. + * milestone count: every attempt to propose or endorse appends. Bounding the milestones does not + * bound this. Acceptable because every appender is a committee member or the contractor, so growth + * needs insiders being persistent or adversarial. * TODO: cap with oldest-first eviction if project accounts get large. */ -export function appendProjectLog(project: DaoProjectData, caller: string, timestamp: number, txType: string, params?: string): void { +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 } - if (params !== undefined) entry.params = params + const entry: DaoProjectLogEntry = { caller, timestamp, txType, params } project.logs.push(entry) } diff --git a/test/daoProjectLog.test.ts b/test/daoProjectLog.test.ts new file mode 100644 index 00000000..0a6af7c0 --- /dev/null +++ b/test/daoProjectLog.test.ts @@ -0,0 +1,63 @@ +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') + }) +}) From 1b42fb9573a89bad44bcf15b875cf4112b58e63b Mon Sep 17 00:00:00 2001 From: jairajdev Date: Wed, 2 Sep 2026 20:07:26 +0800 Subject: [PATCH 18/27] fix(dao): record the endorsed address, and fail closed on ambiguity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - log the address a sender backed, resolving it from the pending value when the transaction omits it - reject rather than guess when more than one milestone is executing - move the audit-trail doc comment back onto DaoProjectLogEntry A blank endorsement is legal on the address path, so logging tx.proposedAddress directly recorded undefined — off-type for the params record, dropped entirely on serialisation, and it left the trail unable to say which address a member endorsed. That is the one fact a dispute turns on, so it is resolved from the pending value and captured before a commit clears it. findExecutingMilestone returned the first match. Two executing milestones should be unreachable while canStartMilestone holds, but "the current milestone" has to mean one milestone, and silently ending the earliest of several would write a payout against the wrong one. --- src/@types/index.ts | 2 +- .../dao/dao_project_change_address.ts | 6 +++++- src/utils/daoProjectMilestoneState.ts | 12 +++++++++--- test/daoProjectLog.test.ts | 14 ++++++++++++++ test/daoProjectMilestoneState.test.ts | 15 +++++++++++++++ 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/@types/index.ts b/src/@types/index.ts index 0171a280..1b918423 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -902,7 +902,6 @@ export interface DaoTerminateVote { timestamp: number } -/** Append-only audit trail, started when the project enters `executing`. Uncapped by decision. */ /** The eight transactions that can append to a project's audit trail. */ export type DaoProjectTxType = | 'dao_project_start' @@ -914,6 +913,7 @@ export type DaoProjectTxType = | '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 diff --git a/src/transactions/dao/dao_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts index 9b291954..70caa32a 100644 --- a/src/transactions/dao/dao_project_change_address.ts +++ b/src/transactions/dao/dao_project_change_address.ts @@ -111,6 +111,10 @@ export const apply = ( // pending value this transaction just created. const isProposing = tx.proposedAddress !== undefined const hadPendingAddress = project.proposedAddress !== undefined + // The address this sender backed, whichever way they submitted it. Captured before the mutation + // below 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 if (isProposing) project.proposedAddress = tx.proposedAddress // No contractor slot here — passing undefined keeps the threshold clamped to the committee size. const result = applyEndorsement(project.endorsedAddress, tx.from, isProposing, proposal.committeeAddresses, undefined, hadPendingAddress) @@ -125,7 +129,7 @@ export const apply = ( project.endorsedAddress = [] } - appendProjectLog(project, tx.from, txTimestamp, 'dao_project_change_address', { proposedAddress: tx.proposedAddress }) + appendProjectLog(project, tx.from, txTimestamp, 'dao_project_change_address', { proposedAddress: supportedAddress }) from.timestamp = txTimestamp proposal.timestamp = txTimestamp diff --git a/src/utils/daoProjectMilestoneState.ts b/src/utils/daoProjectMilestoneState.ts index 59aa3fc1..5d3b6a47 100644 --- a/src/utils/daoProjectMilestoneState.ts +++ b/src/utils/daoProjectMilestoneState.ts @@ -60,11 +60,17 @@ export function findNextPendingMilestone(project: DaoProjectData): { milestone?: * "the current milestone" resolves unambiguously without the sender naming it. */ export function findExecutingMilestone(project: DaoProjectData): { milestone?: DaoMilestone; index?: number; error?: string } { - const index = project.milestones.findIndex((m) => m.status === 'executing') - if (index === -1) { + 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' } } - return { milestone: project.milestones[index], index } + // 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. */ diff --git a/test/daoProjectLog.test.ts b/test/daoProjectLog.test.ts index 0a6af7c0..89c5f14b 100644 --- a/test/daoProjectLog.test.ts +++ b/test/daoProjectLog.test.ts @@ -61,3 +61,17 @@ describe('appendProjectLog', () => { 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 index ef0abf27..06ed2433 100644 --- a/test/daoProjectMilestoneState.test.ts +++ b/test/daoProjectMilestoneState.test.ts @@ -96,3 +96,18 @@ describe('findExecutingMilestone', () => { 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) + }) +}) From 893eefa9b1856c3b3abfe5ae331e5f501d48dd82 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 14:55:22 +0800 Subject: [PATCH 19/27] refactor(dao): compute a milestone payout from the project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - milestonePayoutWei takes the project instead of two percentages and a converter - read the rate, bonus and penalty percentages from it directly - note on projectMintAmountWei why the mint still injects its converter All three call sites passed the same four arguments, every one of them derived from the project, and the injected converter bought nothing here: usdToWeiAtRate lives in the same module. What it did buy was the chance to pass a converter bound to the live rate rather than the rate snapshotted at mint — the bug that shipped in dao_project_milestone_terminate and was caught by audit rather than by a test. Taking the project makes it unexpressible. dao_project_milestone_terminate keeps calling usdToWeiAtRate directly. It releases escrow rather than paying out — cost plus bonus at the stored rate, mirroring what the mint put in — so it never goes through milestonePayoutWei, which would wrongly apply delivery speed to a release. --- src/transactions/dao/dao_project_end.ts | 8 ++--- .../dao/dao_project_milestone_claim.ts | 10 ++---- src/utils/daoProjectMint.ts | 4 ++- src/utils/daoProjectPayout.ts | 17 ++++------ test/daoProjectPayout.test.ts | 34 ++++++++++++++----- 5 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/transactions/dao/dao_project_end.ts b/src/transactions/dao/dao_project_end.ts index 8d44847d..52be5ee5 100644 --- a/src/transactions/dao/dao_project_end.ts +++ b/src/transactions/dao/dao_project_end.ts @@ -8,7 +8,7 @@ import { daoProposalsMetaId } from '../../accounts/daoProposalsMetaAccount' import { recordProposalStatus } from '../../utils/daoProposalIndex' import { appendProjectLog } from '../../utils/daoProjectLog' import { allMilestonesFinished } from '../../utils/daoProjectMilestoneState' -import { milestonePayoutWei, usdToWeiAtRate } from '../../utils/daoProjectPayout' +import { milestonePayoutWei } from '../../utils/daoProjectPayout' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' export const validate_fields = (tx: Tx.DaoProjectEnd, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { @@ -92,11 +92,7 @@ export const apply = ( // 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.durationBonusPercentage, project.durationPenaltyPercentage, (usdStr) => usdToWeiAtRate(usdStr, project.rateUsdStr)) - .amountWei - ) + return total + milestonePayoutWei(m, project).amountWei }, 0n) project.balance = owedWei project.endTime = txTimestamp diff --git a/src/transactions/dao/dao_project_milestone_claim.ts b/src/transactions/dao/dao_project_milestone_claim.ts index 23a32bc1..65c03ee8 100644 --- a/src/transactions/dao/dao_project_milestone_claim.ts +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -6,7 +6,7 @@ import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' -import { milestonePayoutWei, usdToWeiAtRate } from '../../utils/daoProjectPayout' +import { milestonePayoutWei } from '../../utils/daoProjectPayout' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' export const validate_fields = (tx: Tx.DaoProjectMilestoneClaim, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { @@ -71,9 +71,7 @@ export const validate = ( let payoutWei: bigint try { - payoutWei = milestonePayoutWei(milestone, project.durationBonusPercentage, project.durationPenaltyPercentage, (usdStr) => - usdToWeiAtRate(usdStr, project.rateUsdStr), - ).amountWei + payoutWei = milestonePayoutWei(milestone, project).amountWei } catch (err) { response.reason = err instanceof Error ? err.message : String(err) return response @@ -112,9 +110,7 @@ export const apply = ( const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) - const payout = milestonePayoutWei(milestone, project.durationBonusPercentage, project.durationPenaltyPercentage, (usdStr) => - usdToWeiAtRate(usdStr, project.rateUsdStr), - ) + 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 diff --git a/src/utils/daoProjectMint.ts b/src/utils/daoProjectMint.ts index 2d2648fe..b8b8079b 100644 --- a/src/utils/daoProjectMint.ts +++ b/src/utils/daoProjectMint.ts @@ -40,7 +40,9 @@ export function exceedsMintThreshold(amountWei: bigint): boolean { * out. The policy's phrase "including early bonuses" means exactly this sum. * * The USD-to-wei converter is injected rather than imported so this module stays clear of the utils - * barrel, which drags in the config/utils import cycle. + * barrel, which drags in the config/utils import 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, while a payout converts at the rate the project already stores. */ export function projectMintAmountWei(milestones: DaoMilestone[], usdStrToWei: (usdStr: string) => bigint): bigint { return milestones.reduce((total, m) => total + usdStrToWei(m.costUsdStr) + usdStrToWei(m.bonusUsdStr), 0n) diff --git a/src/utils/daoProjectPayout.ts b/src/utils/daoProjectPayout.ts index c81d177d..f395ad95 100644 --- a/src/utils/daoProjectPayout.ts +++ b/src/utils/daoProjectPayout.ts @@ -1,5 +1,5 @@ import { ethers } from 'ethers' -import { DaoMilestone } from '../@types' +import { DaoMilestone, DaoProjectData } from '../@types' const WEI = 10n ** 18n @@ -55,17 +55,14 @@ export interface MilestonePayout { * the floor anyway — it is the only thing standing between a future gap in those checks and a * negative payout. * - * The USD-to-wei converter is injected, and callers must supply one bound to the project's stored - * rate rather than the live one — the DAO's exposure was fixed at the amount minted. + * Takes the project rather than a rate or a converter, so a payout cannot be computed at the live + * rate by mistake — the DAO's exposure was fixed at the amount minted, and that error has been made + * here once already. */ -export function milestonePayoutWei( - milestone: DaoMilestone, - bonusPercentage: number, - penaltyPercentage: number, - usdStrToWei: (usdStr: string) => bigint, -): MilestonePayout { +export function milestonePayoutWei(milestone: DaoMilestone, project: DaoProjectData): MilestonePayout { + const usdStrToWei = (usdStr: string): bigint => usdToWeiAtRate(usdStr, project.rateUsdStr) const actualDuration = (milestone.endTime ?? 0) - (milestone.startTime ?? 0) - const speed = classifyDelivery(actualDuration, milestone.duration, bonusPercentage, penaltyPercentage) + const speed = classifyDelivery(actualDuration, milestone.duration, project.durationBonusPercentage, project.durationPenaltyPercentage) const cost = usdStrToWei(milestone.costUsdStr) if (speed === 'early') { diff --git a/test/daoProjectPayout.test.ts b/test/daoProjectPayout.test.ts index 9e1c14bc..f0302419 100644 --- a/test/daoProjectPayout.test.ts +++ b/test/daoProjectPayout.test.ts @@ -1,14 +1,19 @@ import { ethers } from 'ethers' -import { DaoMilestone } from '../src/@types' +import { DaoMilestone, DaoProjectData } from '../src/@types' import { classifyDelivery, milestonePayoutWei, usdToWeiAtRate } from '../src/utils/daoProjectPayout' const DAY = 86_400_000 -const at1to1 = (usdStr: string): bigint => usdToWeiAtRate(usdStr, '1') 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') @@ -33,39 +38,39 @@ describe('classifyDelivery', () => { describe('milestonePayoutWei', () => { test('early pays cost plus bonus', () => { - const result = milestonePayoutWei(milestone({ endTime: 5 * DAY }), 20, 20, at1to1) + 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(), 20, 20, at1to1) + 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 }), 20, 20, at1to1) + 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' }), 20, 20, at1to1) + 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' }), 20, 20, at1to1) + 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, 20, 20, at1to1).speed).toBe('ontime') - expect(milestonePayoutWei(m, 10, 10, at1to1).speed).toBe('late') + expect(milestonePayoutWei(m, project()).speed).toBe('ontime') + expect(milestonePayoutWei(m, project({ durationBonusPercentage: 10, durationPenaltyPercentage: 10 })).speed).toBe('late') }) }) @@ -80,3 +85,14 @@ describe('usdToWeiAtRate', () => { 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')) + }) +}) From 5f319127d023e4c8fb1273e400b3b371a95f0450 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 14:57:34 +0800 Subject: [PATCH 20/27] refactor(dao): decide an endorsement once, then apply what it returns - add planMilestoneTimeEndorsement and planAddressEndorsement - each reads the pending value and endorsement list itself, and returns the next state instead of mutating - validate and apply call the same function, so they cannot disagree - keep applyEndorsement and writeOnceError for their own unit tests validate dry-ran the endorsement against a copy while apply ran it for real, so the same six positional arguments were written twice per handler and had to agree. They diverged once already: apply read "is a value pending" after writing proposedTime, so every opening proposal looked like a re-proposal, the error was discarded, and no endorsement was ever seeded. Nothing below the E2E could catch it. applyEndorsement takes that flag as a parameter, so its tests passed throughout and would pass again. Reading the state inside the helper and applying only what it returns removes the caller's opportunity to mutate first, and the new tests target the function the handlers actually call. Two helpers rather than one: the paths differ in value type, in whether the contractor may take part, and in whether write-once applies. A single helper would take all of that as flags, which is the indirection this removes. --- .../dao/dao_project_change_address.ts | 38 +++--- .../dao/dao_project_milestone_end.ts | 51 ++------ .../dao/dao_project_milestone_start.ts | 51 ++------ src/utils/daoProjectEndorsement.ts | 83 +++++++++++++ test/daoProjectEndorsement.test.ts | 111 +++++++++++++++++- 5 files changed, 231 insertions(+), 103 deletions(-) diff --git a/src/transactions/dao/dao_project_change_address.ts b/src/transactions/dao/dao_project_change_address.ts index 70caa32a..bcaeb8e8 100644 --- a/src/transactions/dao/dao_project_change_address.ts +++ b/src/transactions/dao/dao_project_change_address.ts @@ -6,7 +6,7 @@ import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' -import { applyEndorsement } from '../../utils/daoProjectEndorsement' +import { planAddressEndorsement } from '../../utils/daoProjectEndorsement' export const validate_fields = (tx: Tx.DaoProjectChangeAddress, response: ShardusTypes.IncomingTransactionResult): ShardusTypes.IncomingTransactionResult => { if (utils.isValidAddress(tx.from) === false) { @@ -67,16 +67,10 @@ export const validate = ( return response } - const dryRun = applyEndorsement( - [...project.endorsedAddress], - tx.from, - tx.proposedAddress !== undefined, - proposal.committeeAddresses, - undefined, - project.proposedAddress !== undefined, - ) - if (dryRun.error) { - response.reason = dryRun.error + // 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 } @@ -106,21 +100,17 @@ export const apply = ( const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) - // Presence, per policy line 352: "if called without an address it is endorsing the proposed - // address". Read hadPendingAddress before the assignment below so the endorse branch cannot see a - // pending value this transaction just created. - const isProposing = tx.proposedAddress !== undefined - const hadPendingAddress = project.proposedAddress !== undefined - // The address this sender backed, whichever way they submitted it. Captured before the mutation - // below 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. + // 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 - if (isProposing) project.proposedAddress = tx.proposedAddress - // No contractor slot here — passing undefined keeps the threshold clamped to the committee size. - const result = applyEndorsement(project.endorsedAddress, tx.from, isProposing, proposal.committeeAddresses, undefined, hadPendingAddress) - // validate() dry-runs the same call against a copy, so an error here means the two disagreed. - // Throwing rather than continuing keeps a half-applied endorsement out of consensus state. + + // 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) { diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index 9cd97d5e..6cffc7c7 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -6,7 +6,7 @@ import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' -import { applyEndorsement, writeOnceError } from '../../utils/daoProjectEndorsement' +import { planMilestoneTimeEndorsement } from '../../utils/daoProjectEndorsement' import { findExecutingMilestone } from '../../utils/daoProjectMilestoneState' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' @@ -73,26 +73,11 @@ export const validate = ( return response } - // Write-once, applied here rather than inside applyEndorsement because the address path must not - // have it. - const writeOnce = writeOnceError(milestone.proposedTime !== undefined, tx.proposedTime !== undefined) - if (writeOnce) { - response.reason = writeOnce - return response - } - - // Dry-run the endorsement against a copy so validate() reports the same rejection apply() would, - // without mutating consensus state here. - const dryRun = applyEndorsement( - [...milestone.endorsedTime], - tx.from, - tx.proposedTime !== undefined, - proposal.committeeAddresses, - project.address, - milestone.proposedTime !== undefined, - ) - if (dryRun.error) { - response.reason = dryRun.error + // 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 } @@ -127,25 +112,13 @@ export const apply = ( const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) - const isProposing = tx.proposedTime !== undefined - // Read before the assignment below. Taken afterwards it would always be true when proposing, so - // write-once would reject the very first proposal and leave endorsedTime empty. - const hadPendingTime = milestone.proposedTime !== undefined - const writeOnceViolation = writeOnceError(hadPendingTime, isProposing) - if (isProposing) milestone.proposedTime = tx.proposedTime - const result = applyEndorsement( - milestone.endorsedTime, - tx.from, - isProposing, - proposal.committeeAddresses, - project.address, - hadPendingTime, - ) - // validate() checks both of these against the same wrappedStates, so reaching either here means - // the two disagreed. Throwing rather than continuing keeps a half-applied endorsement out of - // consensus state. - if (writeOnceViolation) throw new Error(`dao_project_milestone_end accepted a second proposal: ${writeOnceViolation}`) + // 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 diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts index 96066025..4841eb6f 100644 --- a/src/transactions/dao/dao_project_milestone_start.ts +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -6,7 +6,7 @@ import { SafeBigIntMath } from '../../utils/safeBigIntMath' import * as AccountsStorage from '../../storage/accountStorage' import * as utils from '../../utils' import { appendProjectLog } from '../../utils/daoProjectLog' -import { applyEndorsement, writeOnceError } from '../../utils/daoProjectEndorsement' +import { planMilestoneTimeEndorsement } from '../../utils/daoProjectEndorsement' import { canStartMilestone, findNextPendingMilestone } from '../../utils/daoProjectMilestoneState' import { loadProjectTxContext } from '../../utils/daoProjectTxContext' @@ -74,26 +74,11 @@ export const validate = ( return response } - // Write-once, applied here rather than inside applyEndorsement because the address path must not - // have it. - const writeOnce = writeOnceError(milestone.proposedTime !== undefined, tx.proposedTime !== undefined) - if (writeOnce) { - response.reason = writeOnce - return response - } - - // Dry-run the endorsement against a copy so validate() reports the same rejection apply() would, - // without mutating consensus state here. - const dryRun = applyEndorsement( - [...milestone.endorsedTime], - tx.from, - tx.proposedTime !== undefined, - proposal.committeeAddresses, - project.address, - milestone.proposedTime !== undefined, - ) - if (dryRun.error) { - response.reason = dryRun.error + // 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 } @@ -128,25 +113,13 @@ export const apply = ( const txFeeWei = utils.getTransactionFeeWei(AccountsStorage.cachedNetworkAccount) from.data.balance = SafeBigIntMath.subtract(from.data.balance, txFeeWei) - const isProposing = tx.proposedTime !== undefined - // Read before the assignment below. Taken afterwards it would always be true when proposing, so - // write-once would reject the very first proposal and leave endorsedTime empty. - const hadPendingTime = milestone.proposedTime !== undefined - const writeOnceViolation = writeOnceError(hadPendingTime, isProposing) - if (isProposing) milestone.proposedTime = tx.proposedTime - const result = applyEndorsement( - milestone.endorsedTime, - tx.from, - isProposing, - proposal.committeeAddresses, - project.address, - hadPendingTime, - ) - // validate() checks both of these against the same wrappedStates, so reaching either here means - // the two disagreed. Throwing rather than continuing keeps a half-applied endorsement out of - // consensus state. - if (writeOnceViolation) throw new Error(`dao_project_milestone_start accepted a second proposal: ${writeOnceViolation}`) + // 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 diff --git a/src/utils/daoProjectEndorsement.ts b/src/utils/daoProjectEndorsement.ts index 195ca07a..401447c8 100644 --- a/src/utils/daoProjectEndorsement.ts +++ b/src/utils/daoProjectEndorsement.ts @@ -100,3 +100,86 @@ export function applyEndorsement( 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. + * + * The helper reads the pending time and endorsement list itself and returns the state that should + * replace them. It deliberately does not accept a "is something pending" flag: a caller that + * computed one after writing `proposedTime` turned every first proposal into a rejected + * re-proposal, and because validate() and apply() each built the arguments separately, nothing but + * a live network caught it. Reading the state here and applying what comes back leaves no window + * for the two to disagree. + * + * Milestone times are write-once and the contractor may open one. Addresses differ on both counts — + * see planAddressEndorsement. + */ +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, with the two policy differences made explicit. + * + * No write-once: `proposedAddress` is cleared only on a successful commit, so refusing a second + * proposal would freeze the contractor address for the life of the project — and changing it is the + * remedy for a lost or compromised key. Policy line 353 provides for re-proposal for that reason. + * + * No contractor slot: the committee alone decides who replaces them, so `undefined` is passed as + * the contractor and 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/test/daoProjectEndorsement.test.ts b/test/daoProjectEndorsement.test.ts index 02c061bf..c642ab9b 100644 --- a/test/daoProjectEndorsement.test.ts +++ b/test/daoProjectEndorsement.test.ts @@ -1,4 +1,11 @@ -import { applyEndorsement, PROJECT_ENDORSEMENT_THRESHOLD, requiredEndorsements, writeOnceError } from '../src/utils/daoProjectEndorsement' +import { + applyEndorsement, + planAddressEndorsement, + planMilestoneTimeEndorsement, + PROJECT_ENDORSEMENT_THRESHOLD, + requiredEndorsements, + writeOnceError, +} from '../src/utils/daoProjectEndorsement' const C1 = 'c1' const C2 = 'c2' @@ -154,3 +161,105 @@ describe('writeOnceError', () => { 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] }) + }) +}) From 04398957d51ad161077726c93f046c8fee13d179 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 14:59:07 +0800 Subject: [PATCH 21/27] fix(dao): refuse a payout for a milestone that cannot state its duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - require both timestamps and endTime >= startTime before computing a payout - say when the milestone order check fires, not just that it must run - give every project status rejection the shape the rest of the repo uses A missing timestamp used to default to zero, which made the duration hugely negative, classified the milestone as early, and paid cost plus bonus. The least trustworthy data earned the most generous outcome. It is unreachable today — dao_project_milestone_end sets endTime and the completed status together — but the failure mode was backwards. endTime >= startTime is a separate check rather than an implication of both being present: the two times are proposed and endorsed independently, and the end time is only bounded above by the transaction timestamp. Equal times are a legitimate zero-length milestone; inverted ones are not. Callers already route this correctly — the claim path turns it into a rejection in validate and throws in apply, and dao_project_end throws, since a corrupt milestone there is a broken invariant rather than a user error. The order check note records what findNextPendingMilestone cannot catch: the next pending milestone may still sit behind one that is executing. That rejection is what makes deriving the milestone safe, so it should not be dropped as redundant. --- src/transactions/dao/dao_project_end.ts | 2 +- .../dao/dao_project_milestone_claim.ts | 4 +-- .../dao/dao_project_milestone_end.ts | 2 +- .../dao/dao_project_milestone_start.ts | 2 +- .../dao/dao_project_milestone_terminate.ts | 2 +- .../dao/dao_project_reclaim_balance.ts | 2 +- src/utils/daoProjectMilestoneState.ts | 11 ++++++-- src/utils/daoProjectPayout.ts | 16 ++++++++++- test/daoProjectPayout.test.ts | 27 +++++++++++++++++++ 9 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/transactions/dao/dao_project_end.ts b/src/transactions/dao/dao_project_end.ts index 52be5ee5..3ad8c59c 100644 --- a/src/transactions/dao/dao_project_end.ts +++ b/src/transactions/dao/dao_project_end.ts @@ -45,7 +45,7 @@ export const validate = ( const { from, proposal, project } = ctx if (proposal.status !== 'executing') { - response.reason = `Project is not executing (current: ${proposal.status})` + response.reason = `Project is not in executing status (current: ${proposal.status})` return response } diff --git a/src/transactions/dao/dao_project_milestone_claim.ts b/src/transactions/dao/dao_project_milestone_claim.ts index 65c03ee8..b2225b10 100644 --- a/src/transactions/dao/dao_project_milestone_claim.ts +++ b/src/transactions/dao/dao_project_milestone_claim.ts @@ -49,7 +49,7 @@ export const validate = ( // 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 status ${proposal.status} does not allow claiming (expected executing, completed or terminated)` + response.reason = `Project is not in executing, completed or terminated status (current: ${proposal.status})` return response } @@ -59,7 +59,7 @@ export const validate = ( return response } if (milestone.status !== 'completed') { - response.reason = `Milestone ${tx.milestoneNumber} is not completed (current: ${milestone.status})` + 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, diff --git a/src/transactions/dao/dao_project_milestone_end.ts b/src/transactions/dao/dao_project_milestone_end.ts index 6cffc7c7..f4f3b464 100644 --- a/src/transactions/dao/dao_project_milestone_end.ts +++ b/src/transactions/dao/dao_project_milestone_end.ts @@ -55,7 +55,7 @@ export const validate = ( const { from, proposal, project } = ctx if (proposal.status !== 'executing') { - response.reason = `Project is not executing (current: ${proposal.status})` + response.reason = `Project is not in executing status (current: ${proposal.status})` return response } diff --git a/src/transactions/dao/dao_project_milestone_start.ts b/src/transactions/dao/dao_project_milestone_start.ts index 4841eb6f..b4e44b01 100644 --- a/src/transactions/dao/dao_project_milestone_start.ts +++ b/src/transactions/dao/dao_project_milestone_start.ts @@ -55,7 +55,7 @@ export const validate = ( const { from, proposal, project } = ctx if (proposal.status !== 'executing') { - response.reason = `Project is not executing (current: ${proposal.status})` + response.reason = `Project is not in executing status (current: ${proposal.status})` return response } diff --git a/src/transactions/dao/dao_project_milestone_terminate.ts b/src/transactions/dao/dao_project_milestone_terminate.ts index f6d23122..9d1b32b8 100644 --- a/src/transactions/dao/dao_project_milestone_terminate.ts +++ b/src/transactions/dao/dao_project_milestone_terminate.ts @@ -57,7 +57,7 @@ export const validate = ( const { from, proposal, project, milestone } = ctx if (proposal.status !== 'executing') { - response.reason = `Project is not executing (current: ${proposal.status})` + response.reason = `Project is not in executing status (current: ${proposal.status})` return response } diff --git a/src/transactions/dao/dao_project_reclaim_balance.ts b/src/transactions/dao/dao_project_reclaim_balance.ts index 2934237c..aa893c44 100644 --- a/src/transactions/dao/dao_project_reclaim_balance.ts +++ b/src/transactions/dao/dao_project_reclaim_balance.ts @@ -42,7 +42,7 @@ export const validate = ( const { from, proposal, project } = ctx if (proposal.status !== 'completed' && proposal.status !== 'terminated') { - response.reason = `Project has not ended (current: ${proposal.status})` + response.reason = `Project is not in completed or terminated status (current: ${proposal.status})` return response } if (!proposal.committeeAddresses.includes(tx.from)) { diff --git a/src/utils/daoProjectMilestoneState.ts b/src/utils/daoProjectMilestoneState.ts index 5d3b6a47..e6f63cb6 100644 --- a/src/utils/daoProjectMilestoneState.ts +++ b/src/utils/daoProjectMilestoneState.ts @@ -25,6 +25,10 @@ export function resolveMilestone(project: DaoProjectData, milestoneNumber: unkno * * Checking every earlier milestone rather than only the immediately preceding one costs nothing and * closes the case where an earlier milestone was somehow left pending. + * + * This 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 on the assumption that "next pending" already means "startable". */ export function canStartMilestone(project: DaoProjectData, index: number): string | undefined { for (let i = 0; i < index; i++) { @@ -42,8 +46,11 @@ export function canStartMilestone(project: DaoProjectData, index: number): strin * The policy says "start the next milestone" rather than naming one, so the sender does not supply * a number. A pure function of the project data, so every node derives the same milestone. * - * `canStartMilestone` still runs at the call site: this only says which milestone is next in line, - * not that it may start yet. + * `canStartMilestone` still runs at the call site: this says which milestone is next in line, not + * that it may start yet. The gap is real rather than theoretical — the first `pending` milestone can + * still be blocked by an earlier one that is `executing` rather than finished, which is exactly the + * case that keeps a stale start transaction from acting on the wrong milestone once the derived + * target moves. */ export function findNextPendingMilestone(project: DaoProjectData): { milestone?: DaoMilestone; index?: number; error?: string } { const index = project.milestones.findIndex((m) => m.status === 'pending') diff --git a/src/utils/daoProjectPayout.ts b/src/utils/daoProjectPayout.ts index f395ad95..b1eb1040 100644 --- a/src/utils/daoProjectPayout.ts +++ b/src/utils/daoProjectPayout.ts @@ -61,7 +61,21 @@ export interface MilestonePayout { */ export function milestonePayoutWei(milestone: DaoMilestone, project: DaoProjectData): MilestonePayout { const usdStrToWei = (usdStr: string): bigint => usdToWeiAtRate(usdStr, project.rateUsdStr) - const actualDuration = (milestone.endTime ?? 0) - (milestone.startTime ?? 0) + + // Fail closed on a milestone that cannot state how long it took. Defaulting a missing timestamp + // to zero made the duration hugely negative, which classifies as `early` and pays cost *plus* + // bonus — the most generous outcome for the least trustworthy data. + // + // endTime >= startTime is not implied by the two being present: both are proposed and endorsed + // separately, and the end time is only bounded above by the transaction timestamp. 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) diff --git a/test/daoProjectPayout.test.ts b/test/daoProjectPayout.test.ts index f0302419..10a0e4b7 100644 --- a/test/daoProjectPayout.test.ts +++ b/test/daoProjectPayout.test.ts @@ -96,3 +96,30 @@ describe('the payout always uses the project rate', () => { 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() + }) +}) From 26c984c60c2e0091c9db8fc2e859d382757cc025 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 15:02:43 +0800 Subject: [PATCH 22/27] docs(dao): tighten the project util comments - cut restatements of what the code plainly does - keep every invariant a reader cannot recover from the code The utils sat at 43-51% comment lines against 3-8% in the handlers, with several docs longer than the function beneath them. What survives is the reasoning that took a bug to learn: payouts convert at the rate snapshotted at mint and never the live one, penaltyUsdStr is an amount while durationPenaltyPercentage is the trigger, paid > 0n is sound only because a zero payout is unreachable, write-once covers milestone times but not addresses, and why one module injects its converter while the other takes the project. --- src/utils/daoProjectEndorsement.ts | 54 +++++++++++---------------- src/utils/daoProjectLog.ts | 16 ++++---- src/utils/daoProjectMilestoneState.ts | 35 +++++++---------- src/utils/daoProjectMint.ts | 36 ++++++++---------- src/utils/daoProjectPayout.ts | 45 ++++++++-------------- 5 files changed, 74 insertions(+), 112 deletions(-) diff --git a/src/utils/daoProjectEndorsement.ts b/src/utils/daoProjectEndorsement.ts index 401447c8..b54fd01e 100644 --- a/src/utils/daoProjectEndorsement.ts +++ b/src/utils/daoProjectEndorsement.ts @@ -31,18 +31,14 @@ export interface EndorsementCheck { /** * Write-once: rejects a second proposal for a value that already has one pending. * - * Applied by the milestone paths before endorsing, and deliberately not inside applyEndorsement, - * because the contractor address path must not have it. There, `proposedAddress` is cleared only on - * a successful commit, so a first proposal nobody endorses would freeze the contractor address for - * the life of the project — and changing that address is the remedy for a lost or compromised - * contractor key. Policy line 353 provides for re-proposal there for exactly that reason. + * 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. * - * On a milestone the rule is safe because a bad value still has an escape: - * dao_project_milestone_terminate accepts a milestone in `pending` state. What it buys is that a - * pending time cannot be replaced, so an endorsement cannot end up counting toward a time its - * sender never saw. It also enforces the policy's "the contractor can only call this once", which - * the contractor check in applyEndorsement does not cover — nothing there stops them re-proposing, - * and every proposal resets the count, so they could stall their own milestone indefinitely. + * 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' @@ -52,15 +48,11 @@ export function writeOnceError(hasPendingValue: boolean, isProposingNewValue: bo /** * Applies one propose-or-endorse submission to an endorsement list, in place. * - * The three project paths that need agreement — milestone start, milestone end, contractor address - * — share this shape: a submission carrying a value replaces whatever was pending and re-seeds the - * endorsements with its sender; a submission without one endorses what is pending. + * 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. * - * Whether a second proposal is allowed at all is the caller's decision, not this function's — see - * writeOnceError, which the milestone paths apply and the address path deliberately does not. - * - * `endorsements` is the live array and is mutated. Callers own the proposed value itself, because - * its type differs per path (a timestamp or an address). + * `endorsements` is mutated. Prefer the plan* functions below, which decide without mutating. */ export function applyEndorsement( endorsements: string[], @@ -120,15 +112,12 @@ export interface AddressPlan extends EndorsementPlan { /** * Decides what a milestone start or end submission does, without changing anything. * - * The helper reads the pending time and endorsement list itself and returns the state that should - * replace them. It deliberately does not accept a "is something pending" flag: a caller that - * computed one after writing `proposedTime` turned every first proposal into a rejected - * re-proposal, and because validate() and apply() each built the arguments separately, nothing but - * a live network caught it. Reading the state here and applying what comes back leaves no window - * for the two to disagree. + * 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 — - * see planAddressEndorsement. + * Milestone times are write-once and the contractor may open one — addresses differ on both counts. */ export function planMilestoneTimeEndorsement( tx: { from: string; proposedTime?: number }, @@ -155,14 +144,13 @@ export function planMilestoneTimeEndorsement( } /** - * The same for a contractor address change, with the two policy differences made explicit. + * The same for a contractor address change, differing on both policy points. * - * No write-once: `proposedAddress` is cleared only on a successful commit, so refusing a second - * proposal would freeze the contractor address for the life of the project — and changing it is the - * remedy for a lost or compromised key. Policy line 353 provides for re-proposal for that reason. + * 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 `undefined` is passed as - * the contractor and the threshold clamps to the committee size. + * 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 }, diff --git a/src/utils/daoProjectLog.ts b/src/utils/daoProjectLog.ts index 5e855ec7..56e7fa23 100644 --- a/src/utils/daoProjectLog.ts +++ b/src/utils/daoProjectLog.ts @@ -1,17 +1,15 @@ import { DaoProjectData, DaoProjectLogEntry, DaoProjectTxType } from '../@types' /** - * Appends to the project's audit trail. Every project transaction records who called, when, and - * what it did — the policy keeps this "in case of any dispute between the contractor and DAO". + * 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 a structured object rather than a formatted string so a reader can query the trail - * without parsing it, and so the shape of an entry is checked at the call site. It carries what - * identifies the action, not what resulted from it — see DaoProjectLogEntry. + * `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. * - * Deliberately uncapped for now. The log grows with committee behaviour rather than with the - * milestone count: every attempt to propose or endorse appends. Bounding the milestones does not - * bound this. Acceptable because every appender is a committee member or the contractor, so growth - * needs insiders being persistent or adversarial. + * 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( diff --git a/src/utils/daoProjectMilestoneState.ts b/src/utils/daoProjectMilestoneState.ts index e6f63cb6..f4469eb5 100644 --- a/src/utils/daoProjectMilestoneState.ts +++ b/src/utils/daoProjectMilestoneState.ts @@ -3,9 +3,9 @@ import { DaoMilestone, DaoProjectData } from '../@types' /** * Resolves a transaction's 1-based milestone number to its array index. * - * Transactions number milestones from 1 to match how proposals are already addressed externally - * ("dao proposal #N"); storage is a zero-indexed array. An off-by-one here misroutes a payment, so - * both boundaries are rejected explicitly rather than left to produce `undefined`. + * 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)) { @@ -19,16 +19,12 @@ export function resolveMilestone(project: DaoProjectData, milestoneNumber: unkno } /** - * Milestones run strictly in order: a milestone may only start once every earlier one has finished, - * one way or the other. The policy states it as "the previous milestone should be in the completed - * or terminated state or this must be the first milestone in pending status". + * 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. * - * Checking every earlier milestone rather than only the immediately preceding one costs nothing and - * closes the case where an earlier milestone was somehow left pending. - * - * This 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 on the assumption that "next pending" already means "startable". + * 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++) { @@ -43,14 +39,11 @@ export function canStartMilestone(project: DaoProjectData, index: number): strin /** * 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 does not supply - * a number. A pure function of the project data, so every node derives the same milestone. + * 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. * - * `canStartMilestone` still runs at the call site: this says which milestone is next in line, not - * that it may start yet. The gap is real rather than theoretical — the first `pending` milestone can - * still be blocked by an earlier one that is `executing` rather than finished, which is exactly the - * case that keeps a stale start transaction from acting on the wrong milestone once the derived - * target moves. + * 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') @@ -63,8 +56,8 @@ export function findNextPendingMilestone(project: DaoProjectData): { milestone?: /** * The milestone an end transaction acts on: the one currently `executing`. * - * At most one can be, because a milestone cannot start while an earlier one is unfinished, so - * "the current milestone" resolves unambiguously without the sender naming it. + * 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), []) diff --git a/src/utils/daoProjectMint.ts b/src/utils/daoProjectMint.ts index b8b8079b..676a5ecf 100644 --- a/src/utils/daoProjectMint.ts +++ b/src/utils/daoProjectMint.ts @@ -5,13 +5,12 @@ import { DaoMilestone } from '../@types' /** * The configured per-project mint ceiling, in wei. * - * Parsed rather than stored as a bigint so the flag stays JSON-safe on /debug-liberdus-flags and - * settable through the debug endpoint. Parsing is exact — no float rounding — because - * ethers.parseEther works on the decimal string directly. + * 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 falling back to a default: a mint ceiling that silently - * becomes something other than what an operator configured is worse than a failed transaction. The - * throw surfaces inside transaction validation, so a bad value stops mints instead of widening them. + * 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 @@ -35,14 +34,12 @@ export function exceedsMintThreshold(amountWei: bigint): boolean { /** * The most a project could ever owe: every milestone's cost plus its early-delivery bonus. * - * Penalties are deliberately excluded. A penalty only ever reduces what a contractor is paid, so - * folding it in here would inflate the escrow and mint more than the project can legitimately pay - * out. The policy's phrase "including early bonuses" means exactly this sum. + * 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 USD-to-wei converter is injected rather than imported so this module stays clear of the utils - * barrel, which drags in the config/utils import 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, while a payout converts at the rate the project already stores. + * 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) @@ -51,14 +48,13 @@ export function projectMintAmountWei(milestones: DaoMilestone[], usdStrToWei: (u /** * Repeats the creation-time `penalty < cost` rule in wei, at the rate the project is about to fix. * - * Creation compares USD strings, but every payout is a truncating division by the project's rate. - * Amounts that differ in USD can therefore land on the same wei value — cost "0.000000000000000002" - * and penalty "0.000000000000000001" both truncate to 0 at a large enough rate — which would make a - * late payout zero and leave the milestone claimable forever under `paid > 0n`. + * 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 it is fixed for the project's life once - * snapshotted, so checking once at start covers every later payout. Returns the reason a milestone - * fails, or undefined when all of them convert soundly. + * 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()) { diff --git a/src/utils/daoProjectPayout.ts b/src/utils/daoProjectPayout.ts index b1eb1040..b6e72e19 100644 --- a/src/utils/daoProjectPayout.ts +++ b/src/utils/daoProjectPayout.ts @@ -4,12 +4,11 @@ import { DaoMilestone, DaoProjectData } from '../@types' const WEI = 10n ** 18n /** - * Converts a USD string to wei at a *fixed* rate, not the live one. + * Converts USD to wei at a fixed rate, not the live one. * - * Every project payout uses the rate snapshotted when the balance was minted, so the DAO's exposure - * stays capped at what it actually minted and the contractor carries the LIB price movement. Mirrors - * utils.usdStrToWei's arithmetic, but takes the rate as an argument instead of reading the network - * account — which also keeps this module clear of the utils barrel and its import cycle. + * 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) @@ -22,12 +21,8 @@ export type MilestoneDeliverySpeed = 'early' | 'ontime' | 'late' /** * Classifies a completed milestone against its planned duration. * - * The policy sets the thresholds as a percentage of the planned duration: finishing more than - * `bonusPercentage` faster earns the bonus, running more than `penaltyPercentage` over incurs the - * penalty, and anything between is on time and paid the plain cost. - * - * Both comparisons are strict, so landing exactly on a threshold is "on time". That is the - * conservative reading: the DAO neither pays a bonus nor levies a penalty for a boundary case. + * 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) @@ -46,29 +41,21 @@ export interface MilestonePayout { /** * What a completed milestone pays out. * - * A late milestone earns no bonus, so the penalty is deducted from the cost alone. It floors at - * zero so a penalty larger than the cost can never invert into a credit against the DAO. - * - * The floor is defence in depth rather than a reachable branch: `penalty < cost` is enforced at - * proposal creation and repeated in wei at dao_project_start, so a payout of zero cannot occur. - * That is what lets `paid > 0n` serve as the settled marker in dao_project_milestone_claim. Keep - * the floor anyway — it is the only thing standing between a future gap in those checks and a - * negative payout. - * * Takes the project rather than a rate or a converter, so a payout cannot be computed at the live - * rate by mistake — the DAO's exposure was fixed at the amount minted, and that error has been made - * here once already. + * 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 on a milestone that cannot state how long it took. Defaulting a missing timestamp - // to zero made the duration hugely negative, which classifies as `early` and pays cost *plus* - // bonus — the most generous outcome for the least trustworthy data. - // - // endTime >= startTime is not implied by the two being present: both are proposed and endorsed - // separately, and the end time is only bounded above by the transaction timestamp. Equal times - // are a legitimate zero-length milestone; inverted ones are not. + // 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') } From 0df978957175dac5e88c9625b0869e0d9b728146 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Tue, 1 Sep 2026 15:29:27 +0800 Subject: [PATCH 23/27] test(dao): end-to-end project proposal lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenario 21 drives a four-milestone project from creation to reclaim, covering the paths that only exist on a live network. - Creation negatives: emergency projects, three-option project ballots and milestones whose penalty is not smaller than their cost are all rejected. - dao_project_start is refused before the vote and from a non-committee sender, then asserts the mint is sum(cost + bonus) — penalties excluded — converted at the project's own stored rate. Checking against project.rateUsdStr rather than the live factor also proves the rate was snapshotted at all. - Milestone ordering is enforced by the derivation itself: start and end no longer send a number, so the 1-based boundary checks are asserted on the claim path, which still names a milestone. - Write-once: once a start time is proposed, neither another committee member nor the contractor can replace it, and the original survives the attempt. - Endorsement flow end to end: the contractor proposes, cannot endorse their own proposal, two committee endorsements commit it, and the endorsement list is cleared so nothing carries into the end question. The four milestones each exercise a different path, chosen so the two fixes from review are tested rather than assumed: - 1 delivered early, claimed while executing: pays cost + bonus, second claim rejected. - 2 terminated by three distinct committee members — one member voting twice is rejected — releasing exactly cost + bonus at the project rate, mirroring the mint. The contractor then cannot claim it. - 3 delivered late with a penalty below its cost, paying cost - penalty. The test asserts the exact payout, that the balance falls by precisely that much, and that a second claim is rejected — `paid > 0n` settles the milestone, which is sound now that no payout can be zero. - 4 completed but left unclaimed until after dao_project_end, so the widened claim allowlist is exercised. The project ends with a balance equal to what milestone 4 is owed, which is then claimed while the project reads completed. Both halves of D9 are covered, and their placement is forced by the fixture: the claim window is 150s from voting end while the project lifecycle runs for minutes, so the reward claim happens as soon as the project reaches executing — which is also the exact status the allowlist was missing. A second voter never claims, leaving a residue that the tail step burns from a completed project. Money expectations mirror the handler's arithmetic term by term, not just its net USD, because every conversion truncates. Sums go through usdSumToLibWei, one amount at a time, since the handlers convert each milestone's cost and bonus separately. The late payout subtracts two separately converted amounts for the same reason: floor(cost) - floor(penalty) can be a wei below floor(cost - penalty), so converting the difference would not match. Contractor address changes are covered separately, following the policy: a proposal, a blank endorsement of it, a re-proposal that resets the count, a duplicate endorsement rejected, and a non-committee sender rejected. It deliberately stops short of three endorsements, since committing would replace the contractor and break every later claim. Also asserts the under-reporting D4 accepts (the project reads completed despite milestone 2 being terminated) and that reclaim is refused once the balance is zero. sequentialOnly: the milestone chain depends on each endorsement having committed before the next step, and it asserts on balances only this scenario moves. --- scripts/test-dao-e2e.ts | 636 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 629 insertions(+), 7 deletions(-) diff --git a/scripts/test-dao-e2e.ts b/scripts/test-dao-e2e.ts index 2d5b302c..a527c643 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 @@ -822,6 +822,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 +1226,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 +1516,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 +1527,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 +1557,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,7 +1575,8 @@ async function createDaoProposal(opts: ProposalCreateOptions): Promise { description: opts.description, options: opts.options ?? ['no', 'yes'], gracePeriod: opts.gracePeriodMs, - [proposalPayloadKey(proposalType)]: { changes: asChangeSets(opts.changes) }, + // Projects carry milestones and never reach the change-set validator. + ...(proposalType === 'project' ? { project: opts.project } : { [proposalPayloadKey(proposalType)]: { changes: asChangeSets(opts.changes ?? []) } }), timestamp: Date.now(), } if (opts.startTime !== undefined) tx.startTime = opts.startTime @@ -2142,6 +2240,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). @@ -5034,10 +5133,533 @@ 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' }, + ] + 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), timestamp: Date.now() }), + 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), timestamp: Date.now() }), + 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), + timestamp: Date.now(), + }), + proposer3, + 'must be less than', + ) + } + }, + ], + [ + '21.3 Reject dao_project_start before the vote, then drive the proposal to accepted', + async () => { + await injectExpectReject( + { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + 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( + { type: 'dao_project_start', networkId: currentNetworkId, from: proposer3.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + proposer3, + 'Only a committee member', + ) + const { receipt } = await injectAndAssert( + { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + 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( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber, timestamp: Date.now() }, + 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( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime, timestamp: Date.now() }, + voter16, + ) + // The contractor holds slot 0 and may not endorse their own proposal. + await injectExpectReject( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + 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( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000, timestamp: Date.now() }, + committee[2], + 'already been proposed', + ) + await injectExpectReject( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000, timestamp: Date.now() }, + 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( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + 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( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + 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 proposalId = daoProposalId(proposalN.sc21Project) + const addressA = voter13.address + const addressB = voter14.address + + await injectAndAssert( + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[0].address, proposalId, proposedAddress: addressA, timestamp: Date.now() }, + 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( + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[1].address, proposalId, timestamp: Date.now() }, + 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( + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId, proposedAddress: addressB, timestamp: Date.now() }, + 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( + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId, timestamp: Date.now() }, + committee[2], + 'already endorsed', + ) + // Only committee members may take part, and the contractor is not one of them here. + await injectExpectReject( + { type: 'dao_project_change_address', networkId: currentNetworkId, from: voter16.address, proposalId, timestamp: Date.now() }, + 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 injectAndAssert( + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime, timestamp: Date.now() }, + voter16, + ) + for (const member of [committee[0], committee[1]]) { + await injectAndAssert( + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + member, + ) + } + await waitForMilestoneStatus(proposalN.sc21Project, 1, 'completed') + + // Only the contractor is paid. + await injectExpectReject( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1, timestamp: Date.now() }, + committee[0], + 'Only the contractor', + ) + + const expectedPay = usdSumToLibWei(before.project.rateUsdStr, '100', '10') // cost + bonus + const { receipt } = await injectAndAssert( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1, timestamp: Date.now() }, + 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( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1, timestamp: Date.now() }, + 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( + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'contractor asks', timestamp: Date.now() }, + voter16, + 'Only a committee member', + ) + for (const member of [committee[0], committee[1]]) { + await injectAndAssert( + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped', timestamp: Date.now() }, + member, + ) + } + // The same member cannot vote twice to reach the threshold alone. + await injectExpectReject( + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'again', timestamp: Date.now() }, + committee[0], + 'already voted', + ) + await injectAndAssert( + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped', timestamp: Date.now() }, + 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 = usdSumToLibWei(before.project.rateUsdStr, '200', '20') + 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( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, timestamp: Date.now() }, + voter16, + 'not in completed status', + ) + }, + ], + [ + '21.9 Run milestone 3 late and claim cost minus penalty', + async () => { + const startTime = Date.now() + await injectAndAssert( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime, timestamp: Date.now() }, + voter16, + ) + for (const member of [committee[0], committee[1]]) { + await injectAndAssert( + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + member, + ) + } + 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 injectAndAssert( + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime, timestamp: Date.now() }, + voter16, + ) + for (const member of [committee[0], committee[1]]) { + await injectAndAssert( + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + member, + ) + } + 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( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3, timestamp: Date.now() }, + voter16, + { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, + ) + assert(receipt.additionalInfo?.deliverySpeed === 'late', `Expected late delivery, got ${receipt.additionalInfo?.deliverySpeed}`) + // Late payout mirrors the handler: convert cost and penalty separately, then subtract. + // That preserves the same per-term truncation used for minting and claiming. + const expectedLatePayout = usdStrToLibWei('50', before.project.rateUsdStr) - usdStrToLibWei('10', 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( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3, timestamp: Date.now() }, + voter16, + 'already been claimed', + ) + }, + ], + [ + '21.9b Complete milestone 4 but leave it unclaimed', + async () => { + const startTime = Date.now() + for (const [i, member] of [voter16, committee[0], committee[1]].entries()) { + await injectAndAssert( + { + type: 'dao_project_milestone_start', + networkId: currentNetworkId, + from: member.address, + proposalId: daoProposalId(proposalN.sc21Project), + ...(i === 0 ? { proposedTime: startTime } : {}), + timestamp: Date.now(), + }, + member, + ) + } + 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 + for (const [i, member] of [voter16, committee[0], committee[1]].entries()) { + await injectAndAssert( + { + type: 'dao_project_milestone_end', + networkId: currentNetworkId, + from: member.address, + proposalId: daoProposalId(proposalN.sc21Project), + ...(i === 0 ? { proposedTime: endTime } : {}), + timestamp: Date.now(), + }, + member, + ) + } + 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( + { type: 'dao_project_end', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + 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 = usdSumToLibWei(view.project.rateUsdStr, '80', '8') + 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 = usdSumToLibWei(before.project.rateUsdStr, '80', '8') + const { receipt } = await injectAndAssert( + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 4, timestamp: Date.now() }, + 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( + { type: 'dao_project_reclaim_balance', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + 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), timestamp: Date.now() }, + 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) From bf8c209fd89287411f3d7f1a3184e0c90e37c135 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 15:08:49 +0800 Subject: [PATCH 24/27] test(dao-e2e): stop setting a timestamp the harness overwrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop timestamp: Date.now() from 129 transaction literals - say on refreshTxTimestamp that it owns the field, and what to do instead - correct injectLate's reason, which no longer holds injectAndAssert and injectExpectReject both call refreshTxTimestamp before signing, so every timestamp in a transaction literal was discarded. Beyond the noise it read as meaningful, and once was: Scenario 9.1b set a deliberate offset that was silently reset, and the step passed for the wrong reason until the discrepancy was chased down. fundAccount keeps its timestamp — it builds and signs directly rather than going through either helper, so its value is the one actually used. --- scripts/test-dao-e2e.ts | 208 +++++++++++++++------------------------- 1 file changed, 77 insertions(+), 131 deletions(-) diff --git a/scripts/test-dao-e2e.ts b/scripts/test-dao-e2e.ts index a527c643..e095c0a4 100644 --- a/scripts/test-dao-e2e.ts +++ b/scripts/test-dao-e2e.ts @@ -498,6 +498,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() @@ -1577,7 +1585,6 @@ async function createDaoProposal(opts: ProposalCreateOptions): Promise { gracePeriod: opts.gracePeriodMs, // Projects carry milestones and never reach the change-set validator. ...(proposalType === 'project' ? { project: opts.project } : { [proposalPayloadKey(proposalType)]: { changes: asChangeSets(opts.changes ?? []) } }), - timestamp: Date.now(), } if (opts.startTime !== undefined) tx.startTime = opts.startTime await injectAndAssert(tx, opts.proposer, { expectedBalanceDelta: opts.expectedBalanceDelta }) @@ -1611,7 +1618,6 @@ async function committeeAcceptToVoting( from: committee[i].address, proposalId: daoProposalId(proposalNumber), vote: 'accept', - timestamp: Date.now(), }, committee[i], ) @@ -1624,7 +1630,6 @@ async function committeeAcceptToVoting( networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalNumber), - timestamp: Date.now(), }, actor, ) @@ -1647,7 +1652,6 @@ async function castVote( proposalId: daoProposalId(proposalNumber), weights, spend: libToWei(spendLib), - timestamp: Date.now(), }, voter, { expectedBalanceDelta }, @@ -1663,7 +1667,6 @@ async function finalizeVote(proposalNumber: number, actor: TestAccount, sleepBuf networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalNumber), - timestamp: Date.now(), }, actor, ) @@ -1685,7 +1688,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'`) @@ -1793,7 +1796,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, ) } @@ -1875,7 +1878,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) }, @@ -2307,8 +2309,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) @@ -2403,7 +2406,6 @@ async function main(): Promise { from: committee[0].address, proposalId: daoProposalId(proposalN.sc1), vote: 'accept', - timestamp: Date.now(), }, committee[0], ) @@ -2424,7 +2426,6 @@ async function main(): Promise { from: committee[1].address, proposalId: daoProposalId(proposalN.sc1), vote: 'accept', - timestamp: Date.now(), }, committee[1], ) @@ -2445,7 +2446,6 @@ async function main(): Promise { from: committee[2].address, proposalId: daoProposalId(proposalN.sc1), vote: 'accept', - timestamp: Date.now(), }, committee[2], ) @@ -2469,7 +2469,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, proposer, ) @@ -2503,7 +2502,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, proposer, ) @@ -2537,7 +2535,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, proposer, 'did not vote', @@ -2568,7 +2565,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, voter1, 'already claimed', @@ -2602,7 +2598,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter3.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, voter3, { expectedBalanceDelta: receipt => -asBigInt(receipt.transactionFee ?? 0n) }, @@ -2618,7 +2613,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter3.address, proposalId: daoProposalId(proposalN.sc1), - timestamp: Date.now(), }, voter3, 'Nothing left to burn', @@ -2665,7 +2659,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc2), vote: 'withhold', withheldReason: 'Test withhold', - timestamp: Date.now(), }, committee[i], ) @@ -2683,7 +2676,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) @@ -2720,7 +2713,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc2), - timestamp: Date.now(), }, proposer, 'withheld', @@ -2771,7 +2763,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc3), - timestamp: Date.now(), }, proposer, ) @@ -2788,7 +2779,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc3), - timestamp: Date.now(), }, proposer, 'not in review status', @@ -2806,7 +2796,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc3), - timestamp: Date.now(), }, proposer, ) @@ -2845,7 +2834,6 @@ async function main(): Promise { governance: { changes: [[{ key: 'pctBurned', value: '70', current: '50' }]], }, - timestamp: Date.now(), }), voter1, 'committee', @@ -2875,7 +2863,6 @@ async function main(): Promise { [{ key: 'pctBurned', value: '65', current: '50' }], ], }, - timestamp: Date.now(), }), committee[0], 'emergency', @@ -2916,7 +2903,6 @@ async function main(): Promise { from: committee[i].address, proposalId: daoProposalId(proposalN.sc4), vote: 'accept', - timestamp: Date.now(), }, committee[i], ) @@ -2971,7 +2957,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter1.address, proposalId: daoProposalId(proposalN.sc4), - timestamp: Date.now(), }, voter1, 'committee member', @@ -3006,7 +2991,6 @@ async function main(): Promise { networkId: currentNetworkId, from: actor.address, proposalId: daoProposalId(proposalN.sc4), - timestamp: Date.now(), }, actor, 'Nothing left to burn', @@ -3045,7 +3029,6 @@ async function main(): Promise { from: voter1.address, proposalId: daoProposalId(proposalN.sc5), vote: 'accept', - timestamp: Date.now(), }, voter1, 'committee', @@ -3065,7 +3048,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc5), weights: [1, 0], spend: libToWei(minVoteSpendLib), - timestamp: Date.now(), }, voter1, 'voting', @@ -3082,7 +3064,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer.address, proposalId: daoProposalId(proposalN.sc5), - timestamp: Date.now(), }, proposer, 'voting', @@ -3148,7 +3129,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter9.address, proposalId: daoProposalId(proposalN.sc6), - timestamp: Date.now(), }, voter9, ) @@ -3171,7 +3151,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter10.address, proposalId: daoProposalId(proposalN.sc6), - timestamp: Date.now(), }, voter10, ) @@ -3191,7 +3170,6 @@ async function main(): Promise { networkId: currentNetworkId, from: proposer4.address, proposalId: daoProposalId(proposalN.sc6), - timestamp: Date.now(), }, proposer4, 'accepted status', @@ -3318,7 +3296,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', @@ -3342,7 +3319,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', @@ -3493,7 +3469,6 @@ async function main(): Promise { { key: 'countEndpointStart', value: '-3', current: '-1' }, ]], }, - timestamp: Date.now(), }), proposer7, 'overlapping targets', @@ -3603,7 +3578,6 @@ async function main(): Promise { from: committee[1].address, proposalId: daoProposalId(proposalN.sc9), vote: 'accept', - timestamp: Date.now(), }, committee[1], 'has not started', @@ -3622,7 +3596,6 @@ async function main(): Promise { from: committee[1].address, proposalId: daoProposalId(proposalN.sc9), vote: 'accept', - timestamp: Date.now(), }, committee[1], ) @@ -3659,7 +3632,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( @@ -3670,7 +3643,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc10), vote: 'withhold', withheldReason: 'Need more analysis', - timestamp: Date.now(), }, committee[3], ) @@ -3692,7 +3664,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc10), vote: 'withhold', withheldReason: 'Committee withhold regression test', - timestamp: Date.now(), }, committee[i], ) @@ -3709,7 +3680,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) @@ -3746,7 +3717,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], ) } @@ -3759,7 +3730,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc11), vote: 'withhold', withheldReason: 'Tie regression test', - timestamp: Date.now(), }, committee[i], ) @@ -3774,7 +3744,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) @@ -3877,7 +3847,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, ) @@ -3912,7 +3881,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc13), weights: c.weights, spend: c.spend, - timestamp: Date.now(), }, voter12, c.reason, @@ -3928,7 +3896,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', ) @@ -3995,7 +3963,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc14EmergencyWithhold), vote: 'withhold', withheldReason: 'Emergency withhold E2E test', - timestamp: Date.now(), }, committee[i], ) @@ -4021,7 +3988,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc14EmergencyWithhold), - timestamp: Date.now(), }, voter13, 'current: withheld', @@ -4037,7 +4003,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc14EmergencyWithhold), - timestamp: Date.now(), }, voter13, 'already burned', @@ -4075,7 +4040,6 @@ async function main(): Promise { from: committee[4].address, proposalId: daoProposalId(proposalN.sc15A), vote: 'accept', - timestamp: Date.now(), }, committee[4], 'has not started', @@ -4096,7 +4060,6 @@ async function main(): Promise { from: committee[4].address, proposalId: daoProposalId(proposalN.sc15A), vote: 'accept', - timestamp: Date.now(), }, committee[4], 'review period has ended', @@ -4113,7 +4076,6 @@ async function main(): Promise { from: committee[4].address, proposalId: daoProposalId(proposalN.sc15A), vote: 'withhold', - timestamp: Date.now(), }, committee[4], 'withheldReason', @@ -4126,7 +4088,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc15A), vote: 'withhold', withheldReason: '', - timestamp: Date.now(), }, committee[4], 'withheldReason', @@ -4229,7 +4190,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, @@ -4257,7 +4217,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter14.address, proposalId: daoProposalId(proposalN.sc15B), - timestamp: Date.now(), }, voter14, 'Claim period has not ended yet', @@ -4275,7 +4234,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter13.address, proposalId: daoProposalId(proposalN.sc15B), - timestamp: Date.now(), }, voter13, 'Claim period has ended', @@ -4303,7 +4261,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', @@ -4344,7 +4301,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( @@ -4355,7 +4312,6 @@ async function main(): Promise { proposalId: daoProposalId(proposalN.sc16EmergencyTimeout), vote: 'withhold', withheldReason: 'Emergency timeout split test', - timestamp: Date.now(), }, committee[2], ) @@ -4369,7 +4325,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) @@ -4387,7 +4343,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc16EmergencyTimeout), - timestamp: Date.now(), }, voter16, 'accepted status', @@ -4443,7 +4398,6 @@ async function main(): Promise { from: committee[i].address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), vote: 'accept', - timestamp: Date.now(), }, committee[i], ) @@ -4478,7 +4432,6 @@ async function main(): Promise { networkId: currentNetworkId, from: voter5.address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), - timestamp: Date.now(), }, voter5, 'committee member', @@ -4499,7 +4452,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) }, @@ -4540,7 +4492,6 @@ async function main(): Promise { networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), - timestamp: Date.now(), }, committee[0], 'already submitted', @@ -4560,7 +4511,6 @@ async function main(): Promise { networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc17EmergencyRecovery), - timestamp: Date.now(), }, committee[1], 'not in applied status', @@ -4613,7 +4563,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], ) } @@ -4622,7 +4572,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) @@ -4651,7 +4601,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) @@ -4758,7 +4708,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)}`) @@ -4774,7 +4724,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', ) @@ -4790,13 +4740,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], ) } @@ -4806,7 +4756,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], ) } @@ -4819,14 +4769,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) @@ -4848,7 +4798,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', ) @@ -4863,7 +4813,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)}`) @@ -4885,7 +4835,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) @@ -4898,7 +4848,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)}`) @@ -4930,7 +4880,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', ) @@ -4943,7 +4893,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}`) @@ -4955,18 +4905,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, @@ -5092,7 +5042,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) }, @@ -5193,13 +5142,13 @@ async function main(): Promise { } // 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), timestamp: Date.now() }), + 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), timestamp: Date.now() }), + n => ({ ...base, emergency: false, options: ['no', 'a', 'b'], proposalId: daoProposalId(n) }), proposer3, 'exactly 2 entries', ) @@ -5217,7 +5166,6 @@ async function main(): Promise { options: ['no', 'yes'], project: { milestones: [{ ...sc21Milestones[0], ...bad }], address: voter16.address }, proposalId: daoProposalId(n), - timestamp: Date.now(), }), proposer3, 'must be less than', @@ -5229,7 +5177,7 @@ async function main(): Promise { '21.3 Reject dao_project_start before the vote, then drive the proposal to accepted', async () => { await injectExpectReject( - { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, committee[0], 'not in accepted status', ) @@ -5249,12 +5197,12 @@ async function main(): Promise { await sleepUntilTimestamp(proposal.applyEligibleAt, 'applyEligibleAt', SLEEP_BUFFER_MS) // Minting is committee-only; the proposer has no special standing here. await injectExpectReject( - { type: 'dao_project_start', networkId: currentNetworkId, from: proposer3.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_start', networkId: currentNetworkId, from: proposer3.address, proposalId: daoProposalId(proposalN.sc21Project) }, proposer3, 'Only a committee member', ) const { receipt } = await injectAndAssert( - { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, committee[0], ) assert(receipt.additionalInfo?.proposalStatus === 'executing', `Expected executing, got ${JSON.stringify(receipt.additionalInfo)}`) @@ -5291,7 +5239,7 @@ async function main(): Promise { // 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( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber }, voter16, milestoneNumber === 0 ? 'positive integer' : 'outside the range', ) @@ -5303,12 +5251,12 @@ async function main(): Promise { async () => { const startTime = Date.now() await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime, timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime }, voter16, ) // The contractor holds slot 0 and may not endorse their own proposal. await injectExpectReject( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project) }, voter16, 'not endorse', ) @@ -5316,12 +5264,12 @@ async function main(): Promise { // 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( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000, timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000 }, committee[2], 'already been proposed', ) await injectExpectReject( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000, timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000 }, voter16, 'already been proposed', ) @@ -5331,7 +5279,7 @@ async function main(): Promise { `Rejected re-proposals must leave the original proposedTime ${startTime}, got ${stillPending.project.milestones[0].proposedTime}`, ) await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, committee[0], ) // Two of three so far — still pending. @@ -5339,7 +5287,7 @@ async function main(): Promise { assert(partway.project.milestones[0].status === 'pending', 'Milestone should not commit on two endorsements') await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc21Project) }, committee[1], ) const milestone = await waitForMilestoneStatus(proposalN.sc21Project, 1, 'executing') @@ -5358,7 +5306,7 @@ async function main(): Promise { const addressB = voter14.address await injectAndAssert( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[0].address, proposalId, proposedAddress: addressA, timestamp: Date.now() }, + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[0].address, proposalId, proposedAddress: addressA }, committee[0], ) let view = await getProject(proposalN.sc21Project) @@ -5366,7 +5314,7 @@ async function main(): Promise { // Policy line 352: called without an address, it endorses whatever is pending. await injectAndAssert( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[1].address, proposalId, timestamp: Date.now() }, + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[1].address, proposalId }, committee[1], ) view = await getProject(proposalN.sc21Project) @@ -5374,7 +5322,7 @@ async function main(): Promise { // Policy line 353: called with an address, it re-proposes and resets the count to zero. await injectAndAssert( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId, proposedAddress: addressB, timestamp: Date.now() }, + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId, proposedAddress: addressB }, committee[2], ) view = await getProject(proposalN.sc21Project) @@ -5383,13 +5331,13 @@ async function main(): Promise { // A member cannot endorse the same pending value twice. await injectExpectReject( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId, timestamp: Date.now() }, + { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId }, committee[2], 'already endorsed', ) // Only committee members may take part, and the contractor is not one of them here. await injectExpectReject( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: voter16.address, proposalId, timestamp: Date.now() }, + { type: 'dao_project_change_address', networkId: currentNetworkId, from: voter16.address, proposalId }, voter16, 'Only a committee member', ) @@ -5406,12 +5354,12 @@ async function main(): Promise { const endTime = startedAt + 1_000 await injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime, timestamp: Date.now() }, + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime }, voter16, ) for (const member of [committee[0], committee[1]]) { await injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project) }, member, ) } @@ -5419,14 +5367,14 @@ async function main(): Promise { // Only the contractor is paid. await injectExpectReject( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1 }, committee[0], 'Only the contractor', ) const expectedPay = usdSumToLibWei(before.project.rateUsdStr, '100', '10') // cost + bonus const { receipt } = await injectAndAssert( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1 }, voter16, { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, ) @@ -5438,7 +5386,7 @@ async function main(): Promise { // 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( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1 }, voter16, 'already been claimed', ) @@ -5450,24 +5398,24 @@ async function main(): Promise { const before = await getProject(proposalN.sc21Project) // Committee only, and a reason is required on every submission. await injectExpectReject( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'contractor asks', timestamp: Date.now() }, + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'contractor asks' }, voter16, 'Only a committee member', ) for (const member of [committee[0], committee[1]]) { await injectAndAssert( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped', timestamp: Date.now() }, + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped' }, member, ) } // The same member cannot vote twice to reach the threshold alone. await injectExpectReject( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'again', timestamp: Date.now() }, + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'again' }, committee[0], 'already voted', ) await injectAndAssert( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped', timestamp: Date.now() }, + { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped' }, committee[2], ) await waitForMilestoneStatus(proposalN.sc21Project, 2, 'terminated') @@ -5480,7 +5428,7 @@ async function main(): Promise { // And the contractor cannot be paid for it. await injectExpectReject( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2 }, voter16, 'not in completed status', ) @@ -5491,12 +5439,12 @@ async function main(): Promise { async () => { const startTime = Date.now() await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime, timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime }, voter16, ) for (const member of [committee[0], committee[1]]) { await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project) }, member, ) } @@ -5507,12 +5455,12 @@ async function main(): Promise { const endTime = startTime + Math.round(LATE_MILESTONE_DURATION_MS * 1.5) await sleepUntilTimestamp(endTime, 'milestone 3 late end', SLEEP_BUFFER_MS) await injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime, timestamp: Date.now() }, + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime }, voter16, ) for (const member of [committee[0], committee[1]]) { await injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project) }, member, ) } @@ -5521,7 +5469,7 @@ async function main(): Promise { const before = await getProject(proposalN.sc21Project) // Late, so no bonus applies and the penalty comes off the cost: 50 - 10. const { receipt } = await injectAndAssert( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3 }, voter16, { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, ) @@ -5541,7 +5489,7 @@ async function main(): Promise { ) assert(asBigInt(after.project.milestones[2].paid) === expectedLatePayout, 'paid records the amount and settles the milestone') await injectExpectReject( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3 }, voter16, 'already been claimed', ) @@ -5559,7 +5507,6 @@ async function main(): Promise { from: member.address, proposalId: daoProposalId(proposalN.sc21Project), ...(i === 0 ? { proposedTime: startTime } : {}), - timestamp: Date.now(), }, member, ) @@ -5578,7 +5525,6 @@ async function main(): Promise { from: member.address, proposalId: daoProposalId(proposalN.sc21Project), ...(i === 0 ? { proposedTime: endTime } : {}), - timestamp: Date.now(), }, member, ) @@ -5592,7 +5538,7 @@ async function main(): Promise { '21.10 End the project with milestone 4 still owed', async () => { const { receipt } = await injectAndAssert( - { type: 'dao_project_end', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_end', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, committee[0], ) // Milestone 4 was last and completed, so the project reads completed (D4) even though @@ -5618,7 +5564,7 @@ async function main(): Promise { const before = await getProject(proposalN.sc21Project) const expectedPay = usdSumToLibWei(before.project.rateUsdStr, '80', '8') const { receipt } = await injectAndAssert( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 4, timestamp: Date.now() }, + { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 4 }, voter16, { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, ) @@ -5628,7 +5574,7 @@ async function main(): Promise { 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( - { type: 'dao_project_reclaim_balance', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), timestamp: Date.now() }, + { type: 'dao_project_reclaim_balance', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, committee[0], 'already zero', ) @@ -5646,7 +5592,7 @@ async function main(): Promise { 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), timestamp: Date.now() }, + { type: 'dao_burn_reward', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, committee[0], { expectedBalanceDelta: r => -asBigInt(r.transactionFee ?? 0n) }, ) From 5583bf5cd889efadcd38dde5a9ecacfd6da93fd4 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 15:11:42 +0800 Subject: [PATCH 25/27] test(dao-e2e): build Scenario 21 transactions through one helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add sc21Tx for the fields every project transaction repeats - add sc21EndorseMilestoneTime for the propose-then-endorse sequence - collapse 34 transaction literals and 5 endorsement loops onto them Each literal restated the network id, the proposal id and the sender's address around the one or two fields the step was actually about. The endorsement sequence appeared six times in two shapes, half of them keying the proposer off an `i === 0` check written out at each call site — the rule that a proposer counts as endorsement #1 now lives in the helper that applies it. Two call sites keep their loops. Milestone 1's start interleaves write-once rejections between the proposal and the endorsements, and termination is a different mechanism: every submission carries a reason and none proposes a time. --- scripts/test-dao-e2e.ts | 142 +++++++++++++++++----------------------- 1 file changed, 59 insertions(+), 83 deletions(-) diff --git a/scripts/test-dao-e2e.ts b/scripts/test-dao-e2e.ts index e095c0a4..2874ba1c 100644 --- a/scripts/test-dao-e2e.ts +++ b/scripts/test-dao-e2e.ts @@ -5105,6 +5105,32 @@ async function main(): Promise { // 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. + */ + const sc21Tx = (type: string, from: TestAccount, extra: Record = {}): Record => ({ + type, + networkId: currentNetworkId, + from: from.address, + proposalId: daoProposalId(proposalN.sc21Project), + ...extra, + }) + + /** + * 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', @@ -5177,7 +5203,7 @@ async function main(): Promise { '21.3 Reject dao_project_start before the vote, then drive the proposal to accepted', async () => { await injectExpectReject( - { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_start', committee[0]), committee[0], 'not in accepted status', ) @@ -5197,12 +5223,12 @@ async function main(): Promise { await sleepUntilTimestamp(proposal.applyEligibleAt, 'applyEligibleAt', SLEEP_BUFFER_MS) // Minting is committee-only; the proposer has no special standing here. await injectExpectReject( - { type: 'dao_project_start', networkId: currentNetworkId, from: proposer3.address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_start', proposer3), proposer3, 'Only a committee member', ) const { receipt } = await injectAndAssert( - { type: 'dao_project_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_start', committee[0]), committee[0], ) assert(receipt.additionalInfo?.proposalStatus === 'executing', `Expected executing, got ${JSON.stringify(receipt.additionalInfo)}`) @@ -5239,7 +5265,7 @@ async function main(): Promise { // 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( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber }, + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber }), voter16, milestoneNumber === 0 ? 'positive integer' : 'outside the range', ) @@ -5251,12 +5277,12 @@ async function main(): Promise { async () => { const startTime = Date.now() await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime }, + sc21Tx('dao_project_milestone_start', voter16, { proposedTime: startTime }), voter16, ) // The contractor holds slot 0 and may not endorse their own proposal. await injectExpectReject( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_milestone_start', voter16), voter16, 'not endorse', ) @@ -5264,12 +5290,12 @@ async function main(): Promise { // 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( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000 }, + sc21Tx('dao_project_milestone_start', committee[2], { proposedTime: startTime - 5_000 }), committee[2], 'already been proposed', ) await injectExpectReject( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime - 5_000 }, + sc21Tx('dao_project_milestone_start', voter16, { proposedTime: startTime - 5_000 }), voter16, 'already been proposed', ) @@ -5279,7 +5305,7 @@ async function main(): Promise { `Rejected re-proposals must leave the original proposedTime ${startTime}, got ${stillPending.project.milestones[0].proposedTime}`, ) await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_milestone_start', committee[0]), committee[0], ) // Two of three so far — still pending. @@ -5287,7 +5313,7 @@ async function main(): Promise { assert(partway.project.milestones[0].status === 'pending', 'Milestone should not commit on two endorsements') await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: committee[1].address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_milestone_start', committee[1]), committee[1], ) const milestone = await waitForMilestoneStatus(proposalN.sc21Project, 1, 'executing') @@ -5301,12 +5327,11 @@ async function main(): Promise { async () => { // Deliberately never reaches three endorsements: committing would replace the contractor // mid-scenario and break every later claim. - const proposalId = daoProposalId(proposalN.sc21Project) const addressA = voter13.address const addressB = voter14.address await injectAndAssert( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[0].address, proposalId, proposedAddress: addressA }, + sc21Tx('dao_project_change_address', committee[0], { proposedAddress: addressA }), committee[0], ) let view = await getProject(proposalN.sc21Project) @@ -5314,7 +5339,7 @@ async function main(): Promise { // Policy line 352: called without an address, it endorses whatever is pending. await injectAndAssert( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[1].address, proposalId }, + sc21Tx('dao_project_change_address', committee[1]), committee[1], ) view = await getProject(proposalN.sc21Project) @@ -5322,7 +5347,7 @@ async function main(): Promise { // Policy line 353: called with an address, it re-proposes and resets the count to zero. await injectAndAssert( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId, proposedAddress: addressB }, + sc21Tx('dao_project_change_address', committee[2], { proposedAddress: addressB }), committee[2], ) view = await getProject(proposalN.sc21Project) @@ -5331,13 +5356,13 @@ async function main(): Promise { // A member cannot endorse the same pending value twice. await injectExpectReject( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: committee[2].address, proposalId }, + 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( - { type: 'dao_project_change_address', networkId: currentNetworkId, from: voter16.address, proposalId }, + sc21Tx('dao_project_change_address', voter16), voter16, 'Only a committee member', ) @@ -5353,28 +5378,19 @@ async function main(): Promise { // Well inside the 20% early band for a 60s planned duration. const endTime = startedAt + 1_000 - await injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime }, - voter16, - ) - for (const member of [committee[0], committee[1]]) { - await injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project) }, - member, - ) - } + 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( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1 }, + sc21Tx('dao_project_milestone_claim', committee[0], { milestoneNumber: 1 }), committee[0], 'Only the contractor', ) const expectedPay = usdSumToLibWei(before.project.rateUsdStr, '100', '10') // cost + bonus const { receipt } = await injectAndAssert( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1 }, + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 1 }), voter16, { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, ) @@ -5386,7 +5402,7 @@ async function main(): Promise { // 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( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 1 }, + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 1 }), voter16, 'already been claimed', ) @@ -5398,24 +5414,24 @@ async function main(): Promise { const before = await getProject(proposalN.sc21Project) // Committee only, and a reason is required on every submission. await injectExpectReject( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'contractor asks' }, + 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( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped' }, + 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( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'again' }, + sc21Tx('dao_project_milestone_terminate', committee[0], { milestoneNumber: 2, reason: 'again' }), committee[0], 'already voted', ) await injectAndAssert( - { type: 'dao_project_milestone_terminate', networkId: currentNetworkId, from: committee[2].address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2, reason: 'scope dropped' }, + sc21Tx('dao_project_milestone_terminate', committee[2], { milestoneNumber: 2, reason: 'scope dropped' }), committee[2], ) await waitForMilestoneStatus(proposalN.sc21Project, 2, 'terminated') @@ -5428,7 +5444,7 @@ async function main(): Promise { // And the contractor cannot be paid for it. await injectExpectReject( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 2 }, + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 2 }), voter16, 'not in completed status', ) @@ -5438,38 +5454,20 @@ async function main(): Promise { '21.9 Run milestone 3 late and claim cost minus penalty', async () => { const startTime = Date.now() - await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: startTime }, - voter16, - ) - for (const member of [committee[0], committee[1]]) { - await injectAndAssert( - { type: 'dao_project_milestone_start', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project) }, - member, - ) - } + 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 injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), proposedTime: endTime }, - voter16, - ) - for (const member of [committee[0], committee[1]]) { - await injectAndAssert( - { type: 'dao_project_milestone_end', networkId: currentNetworkId, from: member.address, proposalId: daoProposalId(proposalN.sc21Project) }, - member, - ) - } + 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( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3 }, + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 3 }), voter16, { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, ) @@ -5489,7 +5487,7 @@ async function main(): Promise { ) assert(asBigInt(after.project.milestones[2].paid) === expectedLatePayout, 'paid records the amount and settles the milestone') await injectExpectReject( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 3 }, + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 3 }), voter16, 'already been claimed', ) @@ -5499,36 +5497,14 @@ async function main(): Promise { '21.9b Complete milestone 4 but leave it unclaimed', async () => { const startTime = Date.now() - for (const [i, member] of [voter16, committee[0], committee[1]].entries()) { - await injectAndAssert( - { - type: 'dao_project_milestone_start', - networkId: currentNetworkId, - from: member.address, - proposalId: daoProposalId(proposalN.sc21Project), - ...(i === 0 ? { proposedTime: startTime } : {}), - }, - member, - ) - } + 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 - for (const [i, member] of [voter16, committee[0], committee[1]].entries()) { - await injectAndAssert( - { - type: 'dao_project_milestone_end', - networkId: currentNetworkId, - from: member.address, - proposalId: daoProposalId(proposalN.sc21Project), - ...(i === 0 ? { proposedTime: endTime } : {}), - }, - member, - ) - } + 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') @@ -5538,7 +5514,7 @@ async function main(): Promise { '21.10 End the project with milestone 4 still owed', async () => { const { receipt } = await injectAndAssert( - { type: 'dao_project_end', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_end', committee[0]), committee[0], ) // Milestone 4 was last and completed, so the project reads completed (D4) even though @@ -5564,7 +5540,7 @@ async function main(): Promise { const before = await getProject(proposalN.sc21Project) const expectedPay = usdSumToLibWei(before.project.rateUsdStr, '80', '8') const { receipt } = await injectAndAssert( - { type: 'dao_project_milestone_claim', networkId: currentNetworkId, from: voter16.address, proposalId: daoProposalId(proposalN.sc21Project), milestoneNumber: 4 }, + sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 4 }), voter16, { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, ) @@ -5574,7 +5550,7 @@ async function main(): Promise { 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( - { type: 'dao_project_reclaim_balance', networkId: currentNetworkId, from: committee[0].address, proposalId: daoProposalId(proposalN.sc21Project) }, + sc21Tx('dao_project_reclaim_balance', committee[0]), committee[0], 'already zero', ) From 644e62391d8968510e250f54865dd2f25ad0e185 Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 16:24:13 +0800 Subject: [PATCH 26/27] test(dao-e2e): derive Scenario 21 payouts from the milestone fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add sc21EarlyPayout and sc21LatePayout, reading the fixture amounts - replace five hardcoded expectations with them The mint expectation already derived from sc21Milestones while every per-milestone assertion restated the same amounts as literals. Changing a fixture amount then meant hand-updating the assertion, which is how the late payout came to convert a pre-subtracted total and drift by a wei. A stale hardcoded expectation also fails misleadingly: it reads as a product bug, and the tempting fix is to edit the number, which quietly changes what the step covers. Each amount is still converted separately, because the handler converts separately — summing or subtracting in USD first truncates once rather than once per amount. --- scripts/test-dao-e2e.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/test-dao-e2e.ts b/scripts/test-dao-e2e.ts index 2874ba1c..77396ad8 100644 --- a/scripts/test-dao-e2e.ts +++ b/scripts/test-dao-e2e.ts @@ -5119,6 +5119,22 @@ async function main(): Promise { ...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. * @@ -5388,7 +5404,7 @@ async function main(): Promise { 'Only the contractor', ) - const expectedPay = usdSumToLibWei(before.project.rateUsdStr, '100', '10') // cost + bonus + const expectedPay = sc21EarlyPayout(1, before.project.rateUsdStr) const { receipt } = await injectAndAssert( sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 1 }), voter16, @@ -5439,7 +5455,7 @@ async function main(): Promise { // 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 = usdSumToLibWei(before.project.rateUsdStr, '200', '20') + 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. @@ -5472,9 +5488,7 @@ async function main(): Promise { { expectedBalanceDelta: r => asBigInt(r.additionalInfo.paidWei) - asBigInt(r.transactionFee ?? 0n) }, ) assert(receipt.additionalInfo?.deliverySpeed === 'late', `Expected late delivery, got ${receipt.additionalInfo?.deliverySpeed}`) - // Late payout mirrors the handler: convert cost and penalty separately, then subtract. - // That preserves the same per-term truncation used for minting and claiming. - const expectedLatePayout = usdStrToLibWei('50', before.project.rateUsdStr) - usdStrToLibWei('10', before.project.rateUsdStr) + const expectedLatePayout = sc21LatePayout(3, before.project.rateUsdStr) assert( asBigInt(receipt.additionalInfo.paidWei) === expectedLatePayout, `Expected a late payout of ${expectedLatePayout}, got ${receipt.additionalInfo.paidWei}`, @@ -5524,7 +5538,7 @@ async function main(): Promise { // 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 = usdSumToLibWei(view.project.rateUsdStr, '80', '8') + 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') }, @@ -5538,7 +5552,7 @@ async function main(): Promise { assert(proposal.status === 'completed', `Expected a completed project, got ${proposal.status}`) const before = await getProject(proposalN.sc21Project) - const expectedPay = usdSumToLibWei(before.project.rateUsdStr, '80', '8') + const expectedPay = sc21EarlyPayout(4, before.project.rateUsdStr) const { receipt } = await injectAndAssert( sc21Tx('dao_project_milestone_claim', voter16, { milestoneNumber: 4 }), voter16, From 1770689b52752a96a8cba89bd5a9b032f9ff80aa Mon Sep 17 00:00:00 2001 From: jairajdev Date: Thu, 3 Sep 2026 17:05:01 +0800 Subject: [PATCH 27/27] test(dao-e2e): stop sc21Tx callers overriding the fields it owns - type the extra fields so type, networkId, from, proposalId and timestamp cannot be passed extra was spread after the invariant fields, so a caller could retarget a transaction at a different proposal or sender without anything saying so. Spreading it first would have made the override silently ignored instead, which is no better; typing those keys as never makes the attempt fail to compile. Verified both directions: the 34 existing call sites still typecheck, and adding proposalId to one of them errors with "Type 'string' is not assignable to type 'never'". --- scripts/test-dao-e2e.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/test-dao-e2e.ts b/scripts/test-dao-e2e.ts index 77396ad8..c1f5e336 100644 --- a/scripts/test-dao-e2e.ts +++ b/scripts/test-dao-e2e.ts @@ -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 @@ -5110,8 +5118,12 @@ async function main(): Promise { * * 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: Record = {}): Record => ({ + const sc21Tx = (type: string, from: TestAccount, extra: Sc21TxExtra = {}): Record => ({ type, networkId: currentNetworkId, from: from.address,