Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cfcd7ca
feat(dao): add project proposal type and milestone data model
jairajdev Sep 1, 2026
efab99a
feat(dao): accept project proposals in dao_proposal_create
jairajdev Sep 1, 2026
8853852
feat(dao): add the project mint ceiling flag
jairajdev Sep 1, 2026
b22567b
feat(dao): add dao_project_start
jairajdev Sep 1, 2026
11b6ff0
feat(dao): add milestone lifecycle transactions
jairajdev Sep 1, 2026
772fd8f
feat(dao): add dao_project_milestone_claim
jairajdev Sep 1, 2026
c844591
feat(dao): add project administration transactions
jairajdev Sep 1, 2026
b944fbd
feat(dao): expose project data via API and client
jairajdev Sep 1, 2026
7640fce
fix(dao): correct project proposal review findings
jairajdev Sep 1, 2026
92740f3
fix(dao): use the receipt API on both paths in project transactions
jairajdev Sep 2, 2026
d86468c
feat(dao): require a milestone penalty below its cost
jairajdev Sep 2, 2026
49efce8
feat(dao): reject degenerate milestones at project start
jairajdev Sep 2, 2026
c8bad1a
test(dao): cover the rules that make paid a settled marker
jairajdev Sep 2, 2026
bd0daf8
fix(dao): stop a milestone endorsement counting toward an unseen value
jairajdev Sep 2, 2026
9694ccc
refactor(dao): state each project transaction's status rule inline
jairajdev Sep 2, 2026
cad6961
feat(dao): derive the milestone for a start or end transaction
jairajdev Sep 2, 2026
4611397
refactor(dao): give project log entries structured parameters
jairajdev Sep 2, 2026
1b42fb9
fix(dao): record the endorsed address, and fail closed on ambiguity
jairajdev Sep 2, 2026
893eefa
refactor(dao): compute a milestone payout from the project
jairajdev Sep 3, 2026
5f31912
refactor(dao): decide an endorsement once, then apply what it returns
jairajdev Sep 3, 2026
0439895
fix(dao): refuse a payout for a milestone that cannot state its duration
jairajdev Sep 3, 2026
26c984c
docs(dao): tighten the project util comments
jairajdev Sep 3, 2026
0df9789
test(dao): end-to-end project proposal lifecycle
jairajdev Sep 1, 2026
bf8c209
test(dao-e2e): stop setting a timestamp the harness overwrites
jairajdev Sep 3, 2026
5583bf5
test(dao-e2e): build Scenario 21 transactions through one helper
jairajdev Sep 3, 2026
644e623
test(dao-e2e): derive Scenario 21 payouts from the milestone fixture
jairajdev Sep 3, 2026
1770689
test(dao-e2e): stop sc21Tx callers overriding the fields it owns
jairajdev Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 147 additions & 5 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -2698,13 +2698,29 @@ 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',
message:
'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',
Expand All @@ -2721,15 +2737,18 @@ 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
? maxGraceMs
: 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 }

Expand Down Expand Up @@ -3090,7 +3109,7 @@ vorpal.command('dao burn reward', "burn the unclaimed voter reward for a proposa
// from it would silently omit proposals that exist but have not been backfilled yet — wrong for
// the command whose job is the complete list. `dao summary` is the fast recent-activity path;
// this one stays exhaustive.
const VALID_DAO_STATUSES = ['review', 'withheld', 'voting', 'rejected', 'accepted', 'applied', 'canceled']
const VALID_DAO_STATUSES = ['review', 'withheld', 'voting', 'rejected', 'accepted', 'applied', 'canceled', 'executing', 'completed', 'terminated']

vorpal.command('dao proposals [status]', `list DAO proposals, optionally filtered by status (${VALID_DAO_STATUSES.join('/')})`).action(async function (args, callback) {
if (args.status && !VALID_DAO_STATUSES.includes(args.status)) {
Expand Down Expand Up @@ -3140,6 +3159,129 @@ vorpal.command('dao proposals [status]', `list DAO proposals, optionally filtere
callback()
})

// ---------------------------------------------------------------------------
// dao project ... (project proposal lifecycle)
// ---------------------------------------------------------------------------
// Every project tx takes a proposal number and, where relevant, a 1-based milestone number —
// milestones are numbered from 1 in transactions but stored 0-based, and the server converts.
function projectTx(type, proposalNumber, extra = {}) {
return { type, from: USER.address, proposalId: daoProposalId(proposalNumber), ...extra, timestamp: Date.now() }
}

async function submitProjectTx(ctx, tx) {
try {
signTransaction(tx)
ctx.log(await injectTx(tx))
} catch (err) {
ctx.log('Error:', err.message)
}
}

vorpal.command('dao project <number>', '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 <number>', 'show a project audit trail').action(async function (args, callback) {
try {
const res = await axios.get(`${PROTOCOL}://${HOST}/dao/projects/${args.number}/logs`)
const logs = parseDaoApiBody(res.data)?.logs ?? []
if (logs.length === 0) this.log('No log entries.')
for (const l of logs) {
// params is a structured object; render it as key=value pairs rather than stringifying it.
const params = Object.entries(l.params ?? {})
.map(([k, v]) => `${k}=${v}`)
.join(' ')
this.log(`${new Date(l.timestamp).toISOString()} ${l.txType} by ${l.caller}${params ? ` | ${params}` : ''}`)
}
} catch (err) {
this.log('Error:', err.message)
}
callback()
})

vorpal.command('dao project start <number>', '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 <number>', '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 <number>', '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 <number>', 'propose or endorse a new contractor address (committee only)')
.action(async function (args, callback) {
// Blank endorses whatever is pending; a value proposes a replacement and resets endorsements.
const answers = await this.prompt([{ type: 'input', name: 'proposedAddress', message: 'New contractor address (blank to endorse the pending one):' }])
const extra = answers.proposedAddress?.trim() ? { proposedAddress: answers.proposedAddress.trim() } : {}
await submitProjectTx(this, projectTx('dao_project_change_address', args.number, extra))
callback()
})

// No milestone argument: the server acts on the next pending milestone for a start and the
// executing one for an end, as the policy specifies.
for (const [command, type, verb] of [
['dao milestone start <number>', 'dao_project_milestone_start', 'start'],
['dao milestone end <number>', 'dao_project_milestone_end', 'end'],
]) {
vorpal.command(command, `propose or endorse a milestone ${verb} time (contractor or committee)`).action(async function (args, callback) {
// Blank endorses the pending time; the first value proposes one. A time can only be proposed
// once per milestone, and may not be in the future — the server rejects both rather than
// resetting the endorsements or crediting unserved duration.
const answers = await this.prompt([{ type: 'input', name: 'proposedTime', message: `Proposed ${verb} time in ms since epoch (blank to endorse):` }])
const extra = answers.proposedTime?.trim() ? { proposedTime: Number(answers.proposedTime.trim()) } : {}
await submitProjectTx(this, projectTx(type, args.number, extra))
callback()
})
}

vorpal
.command('dao milestone terminate <number> <milestone>', '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 <number> <milestone>', '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)
// ---------------------------------------------------------------------------
Expand Down
Loading