Proposal script
The complete example for your first proposal.
Environment
Create one .env file beside propose.mjs with the contents below. Fill in every blank value; the populated defaults target Polygon. Obtain contract and request values from your trusted integration configuration. Keep your private key and this file out of version control.
# Obtain contract/request pins independently; do not copy them blindly from the API.
API_URL=https://api.risklabs.co
CHAIN_ID=137
RPC_URL=
PRIVATE_KEY=
QUESTION_ID=
OUTCOME=p2
PERMIT_AMOUNT=
# Canonical Polygon Permit2. Verify separately for any other environment.
PERMIT2_ADDRESS=0x000000000022D473030F116dDEE9F6B43aC78BA3
SIGNED_PROPOSER_ADDRESS=
ORACLE_ADDRESS=
TOKEN_ADDRESS=
REQUESTER_ADDRESS=
ORACLE_IDENTIFIER=
REQUEST_TIMESTAMP=
ANCILLARY_DATA_HASH=
# One file per candidate; keep this authorization out of source control.
PAYLOAD_FILE=proposal.private.json
Run
Your wallet must already have the bond balance and Permit2 allowance. Save the script, package manifest, and lockfile beside .env, then run with Node.js 22+ and Yarn Classic 1.22.22.
yarn install --frozen-lockfile
node --env-file=.env propose.mjs --submitThe script saves your authorization automatically before sending. After an interrupted run, use --resume --submit: it polls the saved candidate directly when an acknowledgement is available, or replays the original payload when the response was lost. Omit --submit to sign without submitting. Keep .env and saved authorizations out of version control, and use a separate PAYLOAD_FILE for each candidate.
Script
The main flow is first; validation helpers, signing types, and the Permit2 ABI are below it. This is the same file as the download, and the copy button copies everything.
import { randomBytes } from 'node:crypto';
import { link, readFile, unlink, writeFile } from 'node:fs/promises';
import { createPublicClient, erc20Abi, http, keccak256, parseAbi } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
// Start here: prepare, sign, submit, and follow one proposal.
async function main() {
const flags = new Set(process.argv.slice(2));
requireThat([...flags].every(f => ['--submit', '--resume'].includes(f)), 'Unknown flag.');
const base = new URL(process.env.API_URL || 'https://api.risklabs.co');
requireThat(base.protocol === 'https:' && base.pathname === '/' && !base.username && !base.password && !base.search && !base.hash, 'API_URL must be an HTTPS origin.');
const question = env('QUESTION_ID');
requireThat(/^0x[0-9a-f]{64}$/.test(question), 'QUESTION_ID must be lowercase bytes32.');
const route = `/v1/proposals/${question}`;
const file = process.env.PAYLOAD_FILE || 'proposal.private.json';
const api = createApi(base);
// 1. Prepare and sign, or reuse the exact authorization from an earlier run.
const saved = flags.has('--resume')
? await loadPayload(file, base.origin, question)
: await createPayload(file, base.origin, question, api, route);
// Without --submit, stop after signing locally.
if (!flags.has('--submit')) {
console.log('Authorization saved. Submit with --resume --submit.');
return;
}
// 2. Submit once, or continue tracking the candidate already acknowledged.
const ack = await submitOrResume(saved, file, api, route);
console.log('Candidate:', ack.candidate_id);
// 3. Poll until confirmed or unsuccessful.
await track(api, ack);
}
// Supporting helpers. The proposal flow above is the part to adapt for your bot.
async function loadPayload(file, origin, question) {
const saved = JSON.parse(await readFile(file, 'utf8'));
requireThat(saved.api === origin && saved.question_id === question, 'Saved payload targets a different API or question.');
requireThat(same(saved.payload.version, version), 'Saved payload version mismatch.');
// Replay exactly; an earlier admission may still be executing even after expiry.
return saved;
}
async function createPayload(file, origin, question, api, route) {
try { await readFile(file); throw new SafeError('Payload file exists. Use --resume, or a distinct file for a new question.'); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
const payload = await prepareAndSign(api, route, question);
const saved = { api: origin, question_id: question, payload };
await saveJson(file, saved);
return saved;
}
async function submitOrResume(saved, file, api, route) {
const ackFile = `${file}.ack.json`;
let record;
try { record = JSON.parse(await readFile(ackFile, 'utf8')); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
if (record) {
requireThat(record.api === saved.api && record.question_id === saved.question_id && same(record.payload, saved.payload), 'Saved acknowledgement belongs to a different authorization.');
}
// A missing acknowledgement means the POST response may have been lost.
const ack = record ? record.ack : await api(route, saved.payload);
requireThat(/^[0-9a-f-]{36}$/.test(ack.candidate_id) && ack.status_url === `${route}/status/${ack.candidate_id}`, 'Unexpected acknowledgement path.');
if (!record) {
await saveJson(ackFile, { ...saved, ack });
}
return ack;
}
async function saveJson(file, value) {
const temporary = `${file}.${randomBytes(8).toString('hex')}.tmp`;
// Publish a complete file atomically, without overwriting an existing record.
try {
await writeFile(temporary, JSON.stringify(value, null, 2), { flag: 'wx', mode: 0o600 });
await link(temporary, file);
} finally {
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
}
}
async function prepareAndSign(api, route, question) {
const chainIdText = uint(env('CHAIN_ID'));
const chainId = Number(chainIdText);
requireThat(Number.isSafeInteger(chainId) && chainId > 0, 'Invalid chain ID.');
const account = privateKeyToAccount(env('PRIVATE_KEY'));
const proposer = account.address.toLowerCase();
const permit2 = address('PERMIT2_ADDRESS'), spender = address('SIGNED_PROPOSER_ADDRESS');
const token = address('TOKEN_ADDRESS'), oracle = address('ORACLE_ADDRESS'), requester = address('REQUESTER_ADDRESS');
const outcome = env('OUTCOME');
const prices = { p1: '0', p2: '1000000000000000000', p3: '500000000000000000' };
requireThat(Object.hasOwn(prices, outcome), 'OUTCOME must be p1, p2, or p3.');
const amount = uint(env('PERMIT_AMOUNT'));
const rpc = createPublicClient({ transport: http(env('RPC_URL')) });
requireThat(await rpc.getChainId() === chainId, 'RPC chain differs from trusted CHAIN_ID.');
requireThat((await api(`/v1/proposals/eligibility/${proposer}`)).eligible === true, 'Proposer is not eligible.');
const nonce = BigInt(`0x${randomBytes(32).toString('hex')}`);
const permit = { amount, nonce: nonce.toString(), deadline: String(Math.floor(Date.now() / 1000) + 1800) };
const input = { version, proposer, outcome, max_payment: '0', permit };
const prepared = await api(`${route}/signing-data`, input);
requireThat(prepared.question_id === question && same(prepared.permit, permit), 'Question or permit changed.');
requireThat(prepared.proposal.proposer === proposer && prepared.proposal.outcome === outcome && prepared.proposal.proposed_price === prices[outcome] && prepared.proposal.max_payment === '0', 'Proposal changed.');
requireThat(prepared.outcomes.some(o => o.key === outcome && o.proposed_price === prices[outcome]), 'Outcome not allowed.');
requireThat(prepared.token.address === token && prepared.contracts.permit2 === permit2 && prepared.contracts.signed_proposer === spender, 'Contract pins differ.');
const request = prepared.request;
requireThat(request.oracle === oracle && request.requester === requester && request.identifier.raw === env('ORACLE_IDENTIFIER') && request.timestamp === uint(env('REQUEST_TIMESTAMP')) && keccak256(request.ancillary_data) === env('ANCILLARY_DATA_HASH'), 'Request identity differs from trusted pins.');
requireThat(BigInt(uint(prepared.economics.current_total_bond)) <= BigInt(amount), 'Bond exceeds authorization.');
const message = {
permitted: { token, amount }, spender, nonce: permit.nonce, deadline: permit.deadline,
witness: { oracle, requester, identifier: request.identifier.raw, timestamp: request.timestamp, ancillaryData: request.ancillary_data, proposedPrice: prices[outcome], maxPayment: '0' },
};
const domain = { name: 'Permit2', chainId: chainIdText, verifyingContract: permit2 };
requireThat(same(prepared.typed_data, { domain, types: { EIP712Domain: domainType, ...types }, primaryType: 'PermitWitnessTransferFrom', message }), 'Returned typed data differs from canonical authorization.');
const bitmap = await rpc.readContract({ address: permit2, abi: nonceAbi, functionName: 'nonceBitmap', args: [account.address, nonce >> 8n] });
requireThat((bitmap & (1n << (nonce & 255n))) === 0n, 'Nonce is already used.');
const balance = await rpc.readContract({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [account.address] });
requireThat(balance >= BigInt(prepared.economics.current_total_bond), 'Insufficient bond balance.');
const allowance = await rpc.readContract({ address: token, abi: erc20Abi, functionName: 'allowance', args: [account.address, permit2] });
requireThat(allowance >= BigInt(amount), 'Insufficient Permit2 allowance. This example assumes the bond token is already approved to Permit2.');
requireThat(BigInt(permit.deadline) > BigInt(Math.floor(Date.now() / 1000) + 300), 'Preparation is too old. Prepare again.');
const signature = await account.signTypedData({
domain: { ...domain, chainId }, types, primaryType: 'PermitWitnessTransferFrom',
message: { ...message, permitted: { token, amount: BigInt(amount) }, nonce, deadline: BigInt(permit.deadline), witness: { ...message.witness, timestamp: BigInt(request.timestamp), proposedPrice: BigInt(prices[outcome]), maxPayment: 0n } },
});
return { ...input, signature: signature.toLowerCase() };
}
async function track(api, ack) {
let failures = 0;
for (let poll = 0; poll < 120; poll++) {
await sleep(5000);
let state;
try { state = await api(ack.status_url); failures = 0; }
catch (error) {
if (error instanceof SafeError && error.status && error.status < 500 && error.status !== 429) throw error;
const delay = Math.max(error.retryMs || 0, Math.min(60000, 1000 * 2 ** Math.min(++failures, 6)) + Math.random() * 1000);
await sleep(delay); continue;
}
console.log('Status:', state.status, '/', state.submission?.status ?? 'not submitted');
if (['confirmed', 'indexed'].includes(state.submission?.status)) {
console.log('Proposal transaction:', state.submission.transaction_hash); return;
}
if (['not_selected', 'rejected', 'expired', 'stale'].includes(state.status) || state.submission?.status === 'failed') {
console.log('Terminal result:', state.failure?.code ?? state.status, 'Can resubmit:', state.failure?.can_resubmit ?? false);
process.exitCode = 1; return;
}
}
throw new SafeError('Polling stopped without a terminal result. Keep tracking the saved candidate; do not create a new authorization.');
}
function createApi(base) {
return async function api(path, body) {
requireThat(path.startsWith('/v1/proposals/') && !path.includes('..'), 'Unexpected API path.');
const response = await fetch(new URL(path, base), {
method: body ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(30000),
headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const error = new SafeError(`API HTTP ${response.status}. Reconcile saved authorization before replacing it.`);
error.status = response.status;
const retry = response.headers.get('retry-after');
error.retryMs = retry ? (/^\d+$/.test(retry) ? Number(retry) * 1000 : Math.max(0, Date.parse(retry) - Date.now())) : 0;
throw error;
}
requireThat(data && same(data.version, version), 'Unexpected API version.');
return data;
};
}
class SafeError extends Error {}
function requireThat(condition, message) { if (!condition) throw new SafeError(message); }
function env(name) { const value = process.env[name]; requireThat(value, `Set ${name}.`); return value; }
function address(name) { const value = env(name).toLowerCase(); requireThat(/^0x[0-9a-f]{40}$/.test(value) && BigInt(value) !== 0n, `Invalid ${name}.`); return value; }
function uint(value) { requireThat(/^(0|[1-9][0-9]*)$/.test(value) && BigInt(value) < 2n ** 256n, 'Expected a canonical uint256 string.'); return value; }
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
return value;
}
function same(a, b) { return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)); }
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Protocol definitions: EIP-712 types and the Permit2 nonce ABI.
const version = { api: 'v1', schema: 'signed-proposal-v1', witness: 'permit2-signed-proposer-v1' };
const types = {
PermitWitnessTransferFrom: [
{ name: 'permitted', type: 'TokenPermissions' },
{ name: 'spender', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
{ name: 'witness', type: 'Proposal' },
],
TokenPermissions: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }],
Proposal: [
{ name: 'oracle', type: 'address' }, { name: 'requester', type: 'address' },
{ name: 'identifier', type: 'bytes32' }, { name: 'timestamp', type: 'uint256' },
{ name: 'ancillaryData', type: 'bytes' }, { name: 'proposedPrice', type: 'int256' },
{ name: 'maxPayment', type: 'uint256' },
],
};
const domainType = [{ name: 'name', type: 'string' }, { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }];
const nonceAbi = parseAbi(['function nonceBitmap(address owner, uint256 wordPos) view returns (uint256)']);
// Run only after the protocol definitions above have initialized.
main().catch(error => {
console.error(error instanceof SafeError ? error.message : 'Operation failed. Do not log the raw error: it may contain signing or RPC data. If submission was attempted, reconcile with --resume --submit.');
process.exitCode = 1;
});