Enable real blockchain transactions for milestone-based grant payments using child bounty payouts on Polkadot networks.
- Database Schema:
milestone_approvalsandmultisig_signaturestables created - Server Actions: All actions implemented in
src/app/(dashboard)/dashboard/submissions/multisig-actions.ts - UI Components:
MilestoneVotingPanel,MultisigConfigForm,PolkadotWalletSelector,SignatoryVoteList - Polkadot Client:
dedotLegacyClient with@luno-kit/reacthooks - Wallet Integration:
@luno-kit/uifor wallet connection UI - Type Safety: Types from
dedotand@dedot/chaintypes - Provider Context:
LunoKitProviderfor wallet connection - Seed Data: Infrastructure Committee configured with merged workflow
- Multisig Functions:
initiateMultisigApproval,approveOrExecuteMultisigCall,finalizeMultisigCall - Child Bounty Functions:
createPayoutCall,createChildBountyBundle,getParentBounty,getParentBountyCurator - Integration Tests: 59 tests passing
Current State: Lines 49-64, throws error Purpose: Create a simple balance transfer call for wrapping in multisig Implementation Requirements:
- Use
getPaseoTypedApi()from./client - Call
api.tx.Balances.transfer_keep_alive({ dest, value }) destshould use SS58 address format (Substrate address type)valueis amount in Planck (1 DOT = 10^10 Planck)- Return the call object (not submitted)
Example Pattern:
export function createTransferCall(beneficiaryAddress: string, amount: bigint) {
const api = getPaseoTypedApi()
return api.tx.Balances.transfer_keep_alive({
dest: beneficiaryAddress, // Or { type: 'Id', value: beneficiaryAddress }
value: amount,
})
}2. createBatchedPaymentCall(beneficiaryAddress: string, amount: bigint, milestoneId: number): unknown
Current State: Lines 72-87, throws error Purpose: Create atomic batch call combining transfer + on-chain remark for milestone tracking Implementation Requirements:
- Use
createTransferCall()to create the transfer - Create a
System.remark()call with milestone ID encoded (e.g.,milestone:${milestoneId}) - Use
Utility.batch_all()to combine both calls atomically - If any call fails, entire transaction reverts
Example Pattern:
export function createBatchedPaymentCall(
beneficiaryAddress: string,
amount: bigint,
milestoneId: number
) {
const api = getPaseoTypedApi()
const transferCall = createTransferCall(beneficiaryAddress, amount)
const remarkCall = api.tx.System.remark({
remark: Binary.fromText(`milestone:${milestoneId}`)
})
return api.tx.Utility.batch_all({
calls: [transferCall, remarkCall]
})
}Current State: Lines 124-204, throws error Purpose: First signatory publishes the multisig call and casts the first vote Implementation Requirements:
- Create the payment call using
createBatchedPaymentCall()orcreateTransferCall()based onuseBatchparam - Encode the call to get
callData(hex string) - Hash the call to get
callHash - Calculate
maxWeightfor the call execution - Build
Multisig.as_multi()extrinsic with:thresholdotherSignatories(sorted, excluding initiator)maybeTimepoint: null(first call)call: callDatamaxWeight
- Sign and submit using
signer.signSubmitAndWatch() - Listen for events:
Multisig.NewMultisigandMultisig.MultisigApproval - Extract
timepointfrom events (block height + extrinsic index) - Return
{ callHash, callData, timepoint, txHash, blockNumber }
Key Considerations:
- First call uses
as_multi()which publishes AND votes - Must use
getOtherSignatories()helper to exclude initiator and sort - Transaction hash and block number needed for database record
- Error handling for failed transactions
Current State: Lines 214-258, throws error Purpose: Intermediate signatories approve with call hash only (lighter transaction) Implementation Requirements:
- Use
Multisig.approve_as_multi()extrinsic with:thresholdotherSignatories(sorted, excluding current approver)maybeTimepoint: timepoint(from initial transaction)callHash(not full call data)maxWeight
- Sign and submit
- Listen for
Multisig.MultisigApprovalevent - Check if threshold is met (event indicates approval count)
- Return
{ txHash, blockNumber, thresholdMet }
Key Considerations:
- Requires valid
timepointfrom first transaction - Uses only call hash (cheaper than full call data)
- Must track if this is the final approval before execution threshold
Current State: Lines 268-299, throws error Purpose: Last signatory executes the multisig transaction with full call data Implementation Requirements:
- Similar to
initiateMultisigApproval()but with existing timepoint - Use
Multisig.as_multi()with:thresholdotherSignatoriesmaybeTimepoint: timepoint(from initial transaction)call: callData(MUST provide full call data again)maxWeight
- Sign and submit
- Listen for
Multisig.MultisigExecutedevent (indicates successful execution) - Parse event for execution result
- Return
{ txHash, blockNumber, executionSuccess, executionError? }
Key Considerations:
- Must provide full
callDataagain (not just hash) - This is when the actual payment executes on-chain
- Check
MultisigExecutedevent for success/failure - Handle execution failures gracefully
Current State: Lines 301-308, throws error Purpose: Query on-chain state to find pending multisig transactions Implementation Requirements:
- Query storage
Multisig.Multisigs(multisigAddress, callHash) - Iterate through known call hashes or query all
- Parse stored multisig state:
when: Timepointdeposit: bigintdepositor: stringapprovals: string[](addresses that have approved)
- Return array of pending multisigs with their details
Key Considerations:
- This allows UI to show pending approvals from blockchain
- Useful for recovery if database is out of sync
- May need to query multiple storage keys
Location: src/app/(dashboard)/dashboard/submissions/multisig-actions.ts
-
initiateMilestoneApprovalAction(lines ~50-150):- Fetches milestone, submission, committee data
- Validates user is committee member
- Calls
initiateMultisigApproval() - Stores result in
milestone_approvalsandmultisig_signaturestables - Revalidates UI paths
-
approveMilestoneApprovalAction(lines ~160-240):- Gets existing approval from DB
- Validates user hasn't voted
- Calls
approveMultisigCall() - Records signature in
multisig_signatures - Checks if threshold met
-
finalizeMilestoneApprovalAction(lines ~250-340):- Gets approval with all votes
- Validates threshold met
- Calls
finalizeMultisigCall() - Updates milestone to 'completed'
- Creates payout record in
payoutstable - Records final signature
Location: src/components/milestone/milestone-voting-panel.tsx
- Displays current approval status
- Shows list of signatories and their vote status
- Provides "Sign Transaction" button for committee members
- Handles wallet connection via
usePolkadot()hook - Calls appropriate server action based on approval state
Location: src/lib/db/schema/milestone-approvals.ts
export const milestoneApprovals = pgTable('milestone_approvals', {
id: serial('id').primaryKey(),
milestoneId: integer('milestone_id').references(() => milestones.id),
groupId: integer('group_id').references(() => groups.id),
initiatorId: integer('initiator_id').references(() => users.id),
multisigCallHash: varchar('multisig_call_hash', { length: 128 }),
multisigCallData: text('multisig_call_data'), // Hex-encoded
timepoint: jsonb('timepoint').$type<Timepoint>(), // { height, index }
status: approvalStatusEnum('status'), // pending, threshold_met, executed
executedAt: timestamp('executed_at'),
executionTxHash: varchar('execution_tx_hash', { length: 128 }),
executionBlockNumber: integer('execution_block_number'),
})
export const multisigSignatures = pgTable('multisig_signatures', {
id: serial('id').primaryKey(),
approvalId: integer('approval_id').references(() => milestoneApprovals.id),
reviewId: integer('review_id').references(() => reviews.id), // For merged workflow
userId: integer('user_id').references(() => users.id),
signatoryAddress: varchar('signatory_address', { length: 64 }),
signatureType: signatureEnum('signature_type'), // 'signed' | 'rejected'
txHash: varchar('tx_hash', { length: 128 }),
signedAt: timestamp('signed_at'),
isInitiator: boolean('is_initiator'),
isFinalApproval: boolean('is_final_approval'),
})import { getPaseoTypedApi } from './client'
const api = getPaseoTypedApi()
// Available pallets (examples):
api.tx.Balances.transfer_keep_alive(...)
api.tx.Balances.transfer_all(...)
api.tx.Multisig.as_multi(...)
api.tx.Multisig.approve_as_multi(...)
api.tx.Multisig.cancel_as_multi(...)
api.tx.System.remark(...)
api.tx.Utility.batch(...)
api.tx.Utility.batch_all(...)
// Query storage:
api.query.Multisig.Multisigs(address, callHash)
api.query.System.Account(address)
// Constants:
api.constants.Multisig.DepositBase()
api.constants.Multisig.DepositFactor()const finalized = await tx.signSubmitAndWatch(signer)
.pipe(/* filter/map operators */)
.toPromise()
// Or with subscription:
tx.signSubmitAndWatch(signer).subscribe({
next: (event) => {
if (event.type === 'finalized') {
// Extract events from block
const multisigEvents = event.events.filter(
e => e.type === 'Multisig' && e.value.type === 'MultisigExecuted'
)
}
},
error: (err) => console.error(err),
})- Test
sortSignatories()andgetOtherSignatories()helpers - Mock Polkadot API to test call construction
- Verify call data encoding/hashing
# Setup
1. Create 2 test accounts in Polkadot.js extension
2. Fund both with testnet tokens from faucet
3. Create multisig address: https://polkadot.js.org/apps/#/accounts
4. Fund the multisig address
5. Update .env with addresses:
MULTISIG_ADDRESS=5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty
SIGNATORY_1_ADDRESS=5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY
SIGNATORY_2_ADDRESS=5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y
# Test Flow
1. pnpm db:reset && pnpm db:seed
2. Login as reviewer1@test.com (password: reviewer123)
3. Navigate to Infrastructure Committee submissions
4. Approve a milestone to trigger multisig workflow
5. Connect Polkadot wallet (ensure it's on Paseo network)
6. Click "Sign Transaction" in MilestoneVotingPanel
7. Verify first signature recorded in UI
8. Login as reviewer2@test.com
9. Approve same milestone
10. Verify threshold met and transaction executed
11. Check Paseo block explorer for transaction
12. Verify payout recorded in database- Insufficient balance in multisig wallet
- Invalid call data or hash
- Timepoint mismatch
- Threshold not met
- Duplicate approvals
- Transaction timeout
- Network connection failures
- Wallet rejection
- Call Hash Verification: Always verify call hash matches expected payment
- Signatory Validation: Ensure signer is in configured signatories list
- Amount Validation: Verify payment amount matches milestone amount
- Beneficiary Validation: Confirm beneficiary address is submission owner's wallet
- Replay Protection: Timepoint ensures each multisig is unique
- Atomic Execution: batch_all ensures all-or-nothing execution
- Use TypeScript strict mode (already enabled)
- Follow existing error handling patterns with try/catch
- Add detailed console.log statements:
console.log('[functionName]: action', { params }) - Use logger.error for errors:
logger.error(error, 'context message') - No
anytypes - useunknownand type guards - Prefer async/await over promises
- Add JSDoc comments for public functions
- Implement
initiateMultisigApproval()with event parsing - Implement
approveOrExecuteMultisigCall()for intermediate and final signatories - Implement
finalizeMultisigCall()with execution checking - Implement child bounty functions (
createPayoutCall,createChildBountyBundle) - Implement parent bounty queries (
getParentBounty,getParentBountyCurator) - Handle all error cases gracefully
- Add comprehensive logging
- Run
pnpm typecheckandpnpm lint - Create integration tests (59 tests passing)
- Test on Paseo testnet with real multisig wallet
- Test merged workflow (decision + execution combined)
- Test separated workflow (approval then payment)
- Verify database records match on-chain state
- dedot Docs: https://docs.dedot.dev/
- Multisig Pallet Reference: https://wiki.polkadot.network/docs/learn-account-multisig
- Child Bounties Pallet: https://wiki.polkadot.network/docs/learn-bounties
- Paseo Testnet Explorer: https://paseo.subscan.io
- Paseo Faucet: https://faucet.polkadot.io/paseo
- Polkadot.js Apps (Paseo): https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Frpc.paseo.io#/explorer
- Start Simple: Implement
createTransferCall()first and test it in isolation - Use Block Explorer: Monitor Paseo.subscan.io to see actual transactions
- Check Events: Multisig events contain crucial information (timepoint, approvals)
- Test Incrementally: Test each function before moving to the next
- Wallet Setup: Ensure wallet is connected to Paseo network, not mainnet
- Gas Estimation: First multisig transaction requires deposit (~20 PAS tokens)
- Timepoint Precision: Exact block height and index are critical for subsequent calls
- Error Messages: Polkadot errors can be cryptic - check both JS console and block explorer
β Wrong Network: Ensure wallet and API both use Paseo testnet β Unsorted Signatories: Must sort addresses before passing to multisig calls β Missing Call Data: Final execution requires full call data, not just hash β Wrong Timepoint: Must use exact timepoint from first transaction β Insufficient Deposit: First signer needs extra tokens for multisig deposit β Threshold Confusion: Threshold is number of approvals needed (e.g., 2 out of 3) β Type Mismatches: Use proper types from generated descriptors, avoid casting
Implementation status:
- β All multisig and child bounty functions implemented
- β
TypeScript compiles without errors (
pnpm typecheck) - β
ESLint passes without errors (
pnpm lint) - β Integration tests passing (59 tests)
- β³ Can initiate multisig approval on Paseo testnet
- β³ Can approve with intermediate signatories
- β³ Can finalize and execute payment transaction
- β³ Transaction appears on Paseo block explorer
- β³ Database records match on-chain state
- β³ Both merged and separated workflows function correctly