Files
q-fund-v2/docs/qfund_dual_at_implementation_guide.md
2025-08-23 19:47:40 -04:00

11 KiB
Raw Permalink Blame History

QFund App: DualAT Support Implementation Guide (v1 refund / v2 nonrefund)

Objective: Modify the current QFund app so creators can choose between two crowdfund AT variants at creation time:

  • v2 (default)Pays awardee even if goal is unmet (nonrefund).
    codeHash = HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr, codeBytes.length = 104
  • v1 (optin)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}/create then qortalRequest({ 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

  • codeBytesBase64 is immutable, determines codeHash.
  • dataBytes carries sleep, goal, and awardee (plus v1s 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:

  1. Form state: add a boolean refundIfMissed (default false).
  2. UI control: a checkbox labeled Refund Donors (if goal not reached).
  3. Help tip: “If enabled, the AT refunds each donor when the goal isnt 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 variantaware 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 (musthave)

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, v1s paymentType at byte 64..71 equals 2.
  • selectCodeBytesBase64() — returns pinned Base64; hashing these Base64 → codeHash equals pinned values.

Integration (creation parity)

  • POST /at/create with both variants and assert:
    • rehashed codeBytes matches expected codeHash (9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F or HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr),
    • data segment length meets minimum (v1 ≥ 112, v2 ≥ 80),
    • creation bytes roundtrip (Base58 decode/encode) preserves content.

UI

  • Checkbox defaults OFF.
  • Toggling modifies variant passed 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 apps help modal.

8) Troubleshooting

  • codeHash mismatch after /at/create → ensure youre using the exact Base64 below. Some bundlers autowrap long strings; prefer loading them from a .b64 asset 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 (nonrefund) — HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr, len 104:

NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg=