Risk LabsProposer API

Proposal script

The complete example for your first proposal.

← 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.

.env
# 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
Download .env template ↓

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 --submit

The 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.

propose.mjs
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;
});

On this page