11 KiB
Q‑Fund App: Dual‑AT Support Implementation Guide (v1 refund / v2 non‑refund)
Objective: Modify the current Q‑Fund app so creators can choose between two crowdfund AT variants at creation time:
- v2 (default) — Pays awardee even if goal is unmet (non‑refund).
codeHash = HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr,codeBytes.length = 104 - v1 (opt‑in) — Refunds donors if goal is unmet (refund).
codeHash = 9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F,codeBytes.length = 167
The UI adds a checkbox “Refund Donors (if goal not reached)” — off by default uses v2, on deploys v1.
0) Repo landmarks (assumed)
- Create flow (today):
src/components/Crowdfund/NewCrowdfund.tsx - Helpers (suggested new):
src/lib/at/crowdfund.ts - Server bridge (unchanged): POST
/{at}/createthenqortalRequest({ action:'DEPLOY_AT', ... })
If your paths differ, adapt filenames accordingly. Keep all logic behind a single buildCrowdfundCreationBytes(...) helper to minimize surface area.
1) Pin canonical constants (one source of truth)
Create src/lib/at/crowdfund.ts (or update an existing helper) and pin the canonical codeBytesBase64 and ABI layouts.
// src/lib/at/crowdfund.ts
import bs58 from 'bs58';
// ---------- Canonical code bytes (immutable) ----------
export const CROWDFUND_V1_CODEHASH = '9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F';
export const CROWDFUND_V2_CODEHASH = 'HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr';
export const CROWDFUND_V1_CODEBYTES_BASE64 = 'NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAKMgQDKDAzAwQAAAAFNQElAAAABhsAAAAGByg1AwcAAAAFIAAAAAUAAAACCyg1AwUAAAAHJAAAAAcAAAAI0jUDBgAAAAkyAwozBAIAAAAJGgAAAFk='; // len 167 bytes
export const CROWDFUND_V2_CODEBYTES_BASE64 = 'NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg='; // len 104 bytes
// ---------- ABI (big-endian u64 unless noted) ----------
export type Variant = 'v1_refund' | 'v2_nonrefund';
// Common fields
// [0..7] u64 sleepMinutes
// [8..15] u64 goalAtoms (QORT * 1e8)
// v1 (refund) extras + awardee offset:
export const V1_PAYMENT_TYPE_U64 = 2n; // must be written at offset 64
export const V1_AWARDEE_OFFSET_BYTES = 80; // awardee 32 bytes at [80..111]
// v2 (non-refund) awardee offset:
export const V2_AWARDEE_OFFSET_BYTES = 48; // awardee 32 bytes at [48..79]
// ---------- Helpers ----------
function beWriteU64(buf: Uint8Array, byteOffset: number, value: bigint) {
for (let i = 7; i >= 0; i--) {
buf[byteOffset + (7 - i)] = Number((value >> BigInt(i * 8)) & 0xffn);
}
}
export function qortToAtoms(qort: string | number): bigint {
const s = typeof qort === 'number' ? qort.toString() : String(qort);
const [intPart, fracPartRaw = ''] = s.split('.');
const fracPart = (fracPartRaw + '00000000').slice(0, 8);
return BigInt(intPart) * 100000000n + BigInt(fracPart);
}
export function buildCrowdfundDataBytes(params: {
variant: Variant;
sleepMinutes: number;
goalAtoms: bigint;
awardeeBase58: string;
}): Uint8Array {
if (params.sleepMinutes < 10) throw new Error('sleepMinutes must be >= 10');
const awardee = bs58.decode(params.awardeeBase58);
if (awardee.length !== 32) throw new Error('awardee must decode to 32 bytes');
let totalBytes: number;
let awardeeOffset: number;
if (params.variant === 'v1_refund') {
totalBytes = 112; // v1 uses fields through awardee[80..111]
awardeeOffset = V1_AWARDEE_OFFSET_BYTES;
} else {
totalBytes = 80; // v2 uses awardee[48..79]
awardeeOffset = V2_AWARDEE_OFFSET_BYTES;
}
const data = new Uint8Array(totalBytes);
beWriteU64(data, 0, BigInt(params.sleepMinutes)); // [0..7]
beWriteU64(data, 8, params.goalAtoms); // [8..15]
if (params.variant === 'v1_refund') beWriteU64(data, 64, V1_PAYMENT_TYPE_U64); // [64..71] = 2
data.set(awardee, awardeeOffset); // awardee 32 bytes
return data;
}
export function selectCodeBytesBase64(variant: Variant): string {
return variant === 'v1_refund'
? CROWDFUND_V1_CODEBYTES_BASE64
: CROWDFUND_V2_CODEBYTES_BASE64;
}
Why this matters
codeBytesBase64is immutable, determinescodeHash.dataBytescarries sleep, goal, and awardee (plus v1’s paymentType constant).- Offsets differ between v1 and v2; do not reuse one layout for both.
2) Add the UI control (checkbox)
In src/components/Crowdfund/NewCrowdfund.tsx:
- Form state: add a boolean
refundIfMissed(defaultfalse). - UI control: a checkbox labeled Refund Donors (if goal not reached).
- Help tip: “If enabled, the AT refunds each donor when the goal isn’t met before the deadline.”
// inside component
const [refundIfMissed, setRefundIfMissed] = useState(false);
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={refundIfMissed}
onChange={e => setRefundIfMissed(e.target.checked)}
/>
<span>Refund Donors (if goal not reached)</span>
</label>
Accessibility: Ensure the label is clickable and the checkbox is reachable via keyboard. Persist the state in your draft/session store if you have one.
3) Build creationBytes per selection
Still in NewCrowdfund.tsx, replace the fixed codeBytes path with the variant‑aware builder.
import bs58 from 'bs58';
import {
Variant,
qortToAtoms,
buildCrowdfundDataBytes,
selectCodeBytesBase64,
CROWDFUND_V1_CODEHASH,
CROWDFUND_V2_CODEHASH
} from '@/lib/at/crowdfund';
async function createCrowdfundAT({ sleepMinutes, goalQort, awardee }: {
sleepMinutes: number;
goalQort: string;
awardee: string; // Base58 Qortal address
}) {
const variant: Variant = refundIfMissed ? 'v1_refund' : 'v2_nonrefund';
const goalAtoms = qortToAtoms(goalQort);
const dataBytes = buildCrowdfundDataBytes({
variant,
sleepMinutes,
goalAtoms,
awardeeBase58: awardee,
});
const codeBytesBase64 = selectCodeBytesBase64(variant);
const dataBytesBase64 = Buffer.from(dataBytes).toString('base64');
const body = {
ciyamAtVersion: 2,
codeBytesBase64,
dataBytesBase64,
numCallStackPages: 0,
numUserStackPages: 0,
minActivationAmount: 0
};
const res = await fetch('/at/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`AT create failed: ${res.status}`);
const { creationBytes } = await res.json() as { creationBytes: string };
// Optional: re-hash codeBytes inside creationBytes and assert codeHash
const expected = variant === 'v1_refund' ? CROWDFUND_V1_CODEHASH : CROWDFUND_V2_CODEHASH;
await assertCreationParity(creationBytes, expected);
return creationBytes;
}
Parity check helper (recommended):
// Decode creationBytes (Base58) and verify codeHash = Base58(SHA256(codeBytes))
async function assertCreationParity(creationBytesBase58: string, expectedCodeHash: string) {
const raw = bs58.decode(creationBytesBase58);
let ptr = 0;
// ciyamAtVersion (2 bytes)
ptr += 2;
// codeLen (4 bytes BE)
const codeLen = (raw[ptr]<<24) | (raw[ptr+1]<<16) | (raw[ptr+2]<<8) | raw[ptr+3];
ptr += 4;
const code = raw.slice(ptr, ptr + codeLen);
const sha = await crypto.subtle.digest('SHA-256', code);
const shaBytes = new Uint8Array(sha);
const codeHash = bs58.encode(shaBytes);
if (codeHash !== expectedCodeHash) {
throw new Error(`codeHash mismatch: expected ${expectedCodeHash}, got ${codeHash}`);
}
}
4) Deploy via Hub (unchanged)
import { qortalRequest } from '@/lib/hub';
async function deployCrowdfund(creationBytes: string, name: string, description: string, tags: string[]) {
const res = await qortalRequest({
action: 'DEPLOY_AT',
creationBytes,
name,
description,
tags: tags.join(','),
amount: 0.2,
assetId: 0,
type: 'crowdfund'
});
return res;
}
Note: Names/descriptions/tags do not affect the codeHash. Variant identity comes from the codeBytes only.
5) UX polish
- Dynamic help text below the checkbox:
- Off (v2): “After the deadline, all funds go to the awardee even if the goal is unmet.”
- On (v1): “After the deadline, donations are refunded if the goal is unmet.”
- Preview card: show variant label — “Payout: Always to Awardee (v2)” or “Payout: Refund If Unmet (v1)”.
- Persist last choice in local storage; default resets to v2.
6) Tests (must‑have)
Unit (pure functions)
qortToAtoms()— decimal edge cases (0.00000001, trailing zeros, large ints).buildCrowdfundDataBytes()— for v1/v2: verify total length, offsets (sleep, goal), awardee placement, v1’spaymentTypeat byte 64..71 equals2.selectCodeBytesBase64()— returns pinned Base64; hashing these Base64 →codeHashequals pinned values.
Integration (creation parity)
- POST
/at/createwith both variants and assert:- re‑hashed
codeBytesmatches expectedcodeHash(9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8ForHaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr), - data segment length meets minimum (v1 ≥ 112, v2 ≥ 80),
- creation bytes round‑trip (Base58 decode/encode) preserves content.
- re‑hashed
UI
- Checkbox defaults OFF.
- Toggling modifies
variantpassed to builder (spy or mock). - Form validation unchanged (awardee length=32 bytes after bs58 decode; goal≥min; sleep≥10).
7) Migration & telemetry (optional)
- Feature flag:
crowdfund.dualAT = true; gate the checkbox for staged rollout. - Analytics: capture selection counts (v1 vs v2) + failures.
- Docs: add a short “Behavior” section to the app’s help modal.
8) Troubleshooting
- codeHash mismatch after
/at/create→ ensure you’re using the exact Base64 below. Some bundlers auto‑wrap long strings; prefer loading them from a.b64asset module to avoid line-wrap edits. - awardee invalid → confirm Base58 decoded length is 32 bytes.
- wrong offsets → v1 awardee at byte 80, v2 at byte 48. v1 must also write
PAYMENT(2) at byte 64.
Appendix A — Canonical codeBytes (Base64)
v1 (refund) — 9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F, len 167:
NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAKMgQDKDAzAwQAAAAFNQElAAAABhsAAAAGByg1AwcAAAAFIAAAAAUAAAACCyg1AwUAAAAHJAAAAAcAAAAI0jUDBgAAAAkyAwozBAIAAAAJGgAAAFk=
v2 (non‑refund) — HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr, len 104:
NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg=