update #1

Merged
crowetic merged 3 commits from :update into master 2025-09-11 22:35:53 +00:00
18 changed files with 1395 additions and 105 deletions
+191
View File
@@ -0,0 +1,191 @@
package org.qortal.at.qrowdfundv2;
import org.ciyam.at.*;
import org.qortal.crypto.Crypto;
import org.qortal.utils.Base58;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import static org.ciyam.at.OpCode.calcOffset;
/**
* Q-Fund v2 (non-refund) — pays awardee even if goal is not met.
*
* Differences vs v1 (Qrowdfund):
* - No donor refund loop; both branches pay awardee
* - Minimal program after timeout
*
* Data (ABI v2, big-endian u64 unless noted):
* [0] u64 sleepMinutes (offset 0)
* [1] u64 goalAmountAtoms (offset 8) // informational; not used to decide payout
* [2] u64 sleepUntilTimestamp (offset 16) // computed at runtime
* [3] u64 sleepUntilHeight (offset 24) // computed at runtime
* [4] u64 finalAmount (offset 32) // computed at runtime
* [5] u64 creationTimestamp (offset 40) // filled at runtime
* [6..9] 32B awardee address (offset 48)
*
* Code outline (canonical chain variant):
* GET_CREATION_TIMESTAMP -> [5]
* GET_BLOCK_TIMESTAMP -> [2]
* ADD_MINUTES_TO_TIMESTAMP [2] + [0] -> [2]
* SET_DAT [3] = [2]
* SHR_VAL [3], 32 // timestamp->height
* SLP_DAT [3]
* GET_CURRENT_BALANCE -> [4]
* BLT_DAT [4] < [1] ? goto (second payout) : fallthrough
* (first payout) SET_B_DAT [awardee]; PAY_ALL_TO_ADDRESS_IN_B; FIN_IMD
* (second payout) SET_PCS; SET_B_DAT [awardee]; PAY_ALL_TO_ADDRESS_IN_B; FIN_IMD
*/
public class QrowdfundV2 {
/**
* Returns Qortal AT creation bytes for Qrowdfund v2 AT.
*
* @param sleepMinutes Time period for allowing donations (roughly 1 block per minute)
* @param goalAmount Goal in QORT (QORT×1e8). Informational in v2.
* @param awardee Qortal address of awardee
*/
public static byte[] buildQortalAT(int sleepMinutes, long goalAmount, String awardee) {
if (sleepMinutes < 10 || sleepMinutes > 30 * 24 * 60)
throw new IllegalArgumentException("Sleep period should be between 10 minutes and 1 month");
if (goalAmount < 100_0000L || goalAmount > 1_000_000_00000000L)
throw new IllegalArgumentException("Goal amount should be between 0.01 QORT and 1,000,000 QORT");
if (!Crypto.isValidAddress(awardee))
throw new IllegalArgumentException("Awardee address should be a valid Qortal address");
// --- Data layout indices (u64 slots; index*8 = byte offset)
int idx = 0;
final int addrSleepMinutes = idx++; // 0 -> offset 0
final int addrGoalAmount = idx++; // 1 -> offset 8
final int addrCutTs = idx++; // 2 -> offset 16
final int addrCutHeight = idx++; // 3 -> offset 24
final int addrFinalAmount = idx++; // 4 -> offset 32
final int addrCreationTs = idx++; // 5 -> offset 40
final int addrAwardee = idx; idx += 4; // 6..9 -> offset 48
// --- Build data bytes
ByteBuffer data = ByteBuffer.allocate(idx * MachineState.VALUE_SIZE);
data.position(addrSleepMinutes * MachineState.VALUE_SIZE);
data.putLong(sleepMinutes);
data.position(addrGoalAmount * MachineState.VALUE_SIZE);
data.putLong(goalAmount);
data.position(addrAwardee * MachineState.VALUE_SIZE);
data.put(Base58.decode(awardee));
// --- Build code bytes (exact canonical opcode sequence)
ByteBuffer code = ByteBuffer.allocate(512);
// We'll use a two-pass approach to resolve relative jump offsets
Integer labelSecondPayout = null;
for (int pass = 0; pass < 2; ++pass) {
code.clear();
try {
// [00] creation timestamp -> [5]
code.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrCreationTs));
// [01] current block timestamp -> [2]
code.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrCutTs));
// [02] cutoff timestamp = [2] + minutes([0])
code.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrCutTs, addrCutTs, addrSleepMinutes));
// [03] height = cutoff ts >> 32
code.put(OpCode.SET_DAT.compile(addrCutHeight, addrCutTs));
code.put(OpCode.SHR_VAL.compile(addrCutHeight, 32L));
// [05] sleep until height
code.put(OpCode.SLP_DAT.compile(addrCutHeight));
// [06] final amount
code.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CURRENT_BALANCE, addrFinalAmount));
// [07] if final < goal -> jump to second payout
int jmpTarget = labelSecondPayout;
int offset = calcOffset(code, jmpTarget);
code.put(OpCode.BLT_DAT.compile(addrFinalAmount, addrGoalAmount, offset));
// [08-10] first payout path (fallthrough)
code.put(OpCode.EXT_FUN_VAL.compile(FunctionCode.SET_B_DAT, addrAwardee));
code.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B));
code.put(OpCode.FIN_IMD.compile());
// [11] label: second payout path
labelSecondPayout = code.position();
// Canonical sequence includes SET_PCS then repeats payout+finish
code.put(OpCode.SET_PCS.compile());
code.put(OpCode.EXT_FUN_VAL.compile(FunctionCode.SET_B_DAT, addrAwardee));
code.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B));
code.put(OpCode.FIN_IMD.compile());
} catch (CompilationException e) {
throw new IllegalStateException("Unable to compile AT", e);
}
}
code.flip();
byte[] codeBytes = new byte[code.limit()];
code.get(codeBytes);
// Build creation bytes
final short ciyamAtVersion = 2;
final short numCallStackPages = 0;
final short numUserStackPages = 0;
final long minActivationAmount = 0L;
return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, data.array(), numCallStackPages, numUserStackPages, minActivationAmount);
}
private static void usage() {
System.err.println("usage: qrowdfundv2 <timeout-minutes> <goal> <awardee-address>");
}
public static void main(String[] args) {
if (args.length != 3) {
usage();
System.exit(2);
}
int sleepMinutes;
try {
sleepMinutes = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
usage();
System.err.println();
System.err.printf("Entry window minutes '%s' invalid - should be integer larger than 10", args[0]);
System.exit(1);
// not reached
throw e;
}
long goal;
try {
goal = new BigDecimal(args[1]).setScale(8).unscaledValue().longValueExact();
} catch (Exception e) {
usage();
System.err.println();
System.err.printf("Goal '%s' invalid", args[1]);
System.exit(1);
// not reached
throw e;
}
String awardee = args[2];
if (!Crypto.isValidAddress(awardee)) {
usage();
System.err.println();
System.err.printf("Awardee address '%s' not a Qortal address", awardee);
System.exit(1);
}
byte[] creationBytes = buildQortalAT(sleepMinutes, goal, awardee);
System.out.printf("Creation bytes:\n%s\n", Base58.encode(creationBytes));
}
}
+273
View File
@@ -0,0 +1,273 @@
package org.qortal.at.qrowdfund;
import org.ciyam.at.*;
import org.qortal.account.Account;
import org.qortal.account.PublicKeyAccount;
import org.qortal.crypto.Crypto;
import org.qortal.utils.Base58;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import static org.ciyam.at.OpCode.calcOffset;
/**
* Design goals:
* 1. Sleep for set period to avoid extra DB state records
* 2. Check whether goal is reached
* 3. If yes: send balance to awardee
* 4. If no: refund all donors
*
* Data:
* [start timestamp / most recent transaction timestamp]
* [cutoff timestamp]
* [number of valid entries: set to 0]
* [best distance (unsigned): set to max]
* [best winner]
*
* Code:
* record start time
* sleep
* record cutoff time
* check balance
*
* Goal reached:
* send balance to 'awardee'
*
* Goal not reached:
* fetch next transaction
* if none, end
* update most recent transaction timestamp
* extract transaction's sender (address / public key?)
* send transaction amount back to sender
* continue loop
*/
public class Qrowdfund {
private static byte[] CODE_BYTES;
/** SHA256 of AT code bytes */
private static byte[] CODE_BYTES_HASH;
/**
* Returns Qortal AT creation bytes for Qrowdfund AT.
*
* @param sleepMinutes Time period for allowing donations (roughly 1 block per minute)
* @param goalAmount Minimum goal, in QORT, to trigger award after timeout
* @param awardee Qortal address of awardee
*/
public static byte[] buildQortalAT(int sleepMinutes, long goalAmount, String awardee) {
if (sleepMinutes < 10 || sleepMinutes > 30 * 24 * 60)
throw new IllegalArgumentException("Sleep period should be between 10 minutes and 1 month");
if (goalAmount < 100_0000L || goalAmount > 1_000_000_00000000L)
throw new IllegalArgumentException("Minimum amount should be between 0.01 QORT and 1,000,000 QORT");
if (!Crypto.isValidAddress(awardee))
throw new IllegalArgumentException("Awardee address should be a valid Qortal address");
// Labels for data segment addresses
int addrCounter = 0;
final int addrSleepMinutes = addrCounter++;
final int addrGoalAmount = addrCounter++;
final int addrSleepUntilTimestamp = addrCounter++;
final int addrSleepUntilHeight = addrCounter++;
final int addrFinalAmount = addrCounter++;
final int addrLastTxnTimestamp = addrCounter++;
final int addrResult = addrCounter++;
final int addrTxnType = addrCounter++;
final int addrPaymentTxnType = addrCounter++;
final int addrPaymentAmount = addrCounter++;
final int addrAwardeeAddress = addrCounter; addrCounter += 4;
// Data segment
ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE);
// Sleep period (minutes)
dataByteBuffer.position(addrSleepMinutes * MachineState.VALUE_SIZE);
dataByteBuffer.putLong(sleepMinutes);
// Minimum accepted amount
dataByteBuffer.position(addrGoalAmount * MachineState.VALUE_SIZE);
dataByteBuffer.putLong(goalAmount);
// PAYMENT transaction type
dataByteBuffer.position(addrPaymentTxnType * MachineState.VALUE_SIZE);
dataByteBuffer.putLong(API.ATTransactionType.PAYMENT.value);
// Awardee address
dataByteBuffer.position(addrAwardeeAddress * MachineState.VALUE_SIZE);
dataByteBuffer.put(Base58.decode(awardee));
// Code labels
Integer labelRefundDonors = null;
Integer labelTxnLoop = null;
Integer labelRefundTxn = null;
Integer labelCheckTxn2 = null;
ByteBuffer codeByteBuffer = ByteBuffer.allocate(768);
// Two-pass version
for (int pass = 0; pass < 2; ++pass) {
codeByteBuffer.clear();
try {
/* Initialization */
// Use AT creation 'timestamp' as starting point for finding transactions sent to AT
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp));
/*
* We want to sleep for a while.
*
* We could use SLP_VAL but different sleep periods would produce different code hashes,
* which would make identifying similar qrowdfund ATs more difficult.
*
* Instead we add sleepMinutes (as block count) to current block height,
* which is in the upper 32 bits of current block 'timestamp',
* so we perform a shift-right to extract.
*/
// Save current block 'timestamp' into addrSleepUntilHeight
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrSleepUntilTimestamp));
// Add number of minutes to sleep (assuming roughly 1 block per minute)
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrSleepUntilTimestamp, addrSleepUntilTimestamp, addrSleepMinutes));
// Copy then shift-right to convert 'timestamp' to block height
codeByteBuffer.put(OpCode.SET_DAT.compile(addrSleepUntilHeight, addrSleepUntilTimestamp));
codeByteBuffer.put(OpCode.SHR_VAL.compile(addrSleepUntilHeight, 32L));
/* Sleep */
codeByteBuffer.put(OpCode.SLP_DAT.compile(addrSleepUntilHeight));
/* Done sleeping */
// Goal reached?
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CURRENT_BALANCE, addrFinalAmount));
codeByteBuffer.put(OpCode.BLT_DAT.compile(addrFinalAmount, addrGoalAmount, calcOffset(codeByteBuffer, labelRefundDonors)));
// Goal reached - send balance to awardee
// Load B register with awardee's address
codeByteBuffer.put(OpCode.EXT_FUN_VAL.compile(FunctionCode.SET_B_DAT, addrAwardeeAddress));
// Pay AT's balance to receiving address
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B));
// We're finished forever
codeByteBuffer.put(OpCode.FIN_IMD.compile());
labelRefundDonors = codeByteBuffer.position();
// Restart after this opcode (probably not needed, but just in case)
codeByteBuffer.put(OpCode.SET_PCS.compile());
/* Transaction processing loop */
labelTxnLoop = codeByteBuffer.position();
// Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp));
// If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0.
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult));
// If addrResult is zero (i.e. A is non-zero, transaction was found) then go refund transaction
codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelRefundTxn)));
// No (more) transactions found - we're finished forever
codeByteBuffer.put(OpCode.FIN_IMD.compile());
/* Check transaction */
labelRefundTxn = codeByteBuffer.position();
// Update our 'last found transaction's timestamp' using 'timestamp' from transaction
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp));
// If transaction is before cut-off timestamp then perform more checks
codeByteBuffer.put(OpCode.BLT_DAT.compile(addrLastTxnTimestamp, addrSleepUntilTimestamp, calcOffset(codeByteBuffer, labelCheckTxn2)));
// Past cut-off - we're finished forever
codeByteBuffer.put(OpCode.FIN_IMD.compile());
/* Check transaction - part 2 */
labelCheckTxn2 = codeByteBuffer.position();
// Extract transaction type (message/payment) from transaction and save type in addrTxnType
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType));
// If transaction type is not PAYMENT type then go look for another transaction
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrPaymentTxnType, calcOffset(codeByteBuffer, labelTxnLoop)));
// Get payment amount
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_AMOUNT_FROM_TX_IN_A, addrPaymentAmount));
// Extract sender address from transaction into B register
codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B));
// Refund amount to donor address (in B)
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrPaymentAmount));
// Check for more donations to refund
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTxnLoop));
} catch (CompilationException e) {
throw new IllegalStateException("Unable to compile AT?", e);
}
}
codeByteBuffer.flip();
byte[] codeBytes = new byte[codeByteBuffer.limit()];
codeByteBuffer.get(codeBytes);
final short ciyamAtVersion = 2;
final short numCallStackPages = 0;
final short numUserStackPages = 0;
final long minActivationAmount = 0L;
return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount);
}
private static void usage() {
System.err.println("usage: qrowdfund <timeout-minutes> <minimum-goal> <awardee-address>");
System.err.println("example: qrowdfund 1440 10.4 QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG");
System.err.println(" deadline in 1440 mins (1 day), minimum goal 10.4 QORT");
}
public static void main(String[] args) {
if (args.length != 3) {
usage();
System.exit(2);
}
int sleepMinutes;
try {
sleepMinutes = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
usage();
System.err.println();
System.err.printf("Entry window minutes '%s' invalid - should be integer larger than 10", args[0]);
System.exit(1);
// not reached
throw e;
}
long minimumGoal;
try {
minimumGoal = new BigDecimal(args[1]).setScale(8).unscaledValue().longValue();
} catch (NumberFormatException e) {
usage();
System.err.println();
System.err.printf("Minimum goal '%s' invalid - should be larger than 0.1 QORT", args[1]);
System.exit(1);
// not reached
throw e;
}
String awardee = args[2];
if (!Crypto.isValidAddress(awardee)) {
usage();
System.err.println();
System.err.printf("Awardee address '%s' not a Qortal address", awardee);
System.exit(1);
}
byte[] creationBytes = buildQortalAT(sleepMinutes, minimumGoal, awardee);
System.out.printf("Creation bytes:\n%s\n", Base58.encode(creationBytes));
}
}
+64
View File
@@ -0,0 +1,64 @@
# QFund v1.0.0 — Release Notes
Release date: 20250823
## Highlights
- Dual AT support (v1 refund / v2 nonrefund) with creatorselectable payout policy.
- Awardee address field with validation and optional name resolution.
- Clear inapp labeling of Status, Awardee, and Payout behavior.
- Robust dataBytes builder with canonical v1/v2 code bytes and ABI parity.
## Changes
### 1) Dual Crowdfund AT Variants
- Added a variant toggle during creation: “Refund Donors (if goal not reached)”.
- Off (default) → v2 nonrefund: pays the awardee even if goal not met.
- On → v1 refund: refunds donors if the goal is not met.
- Implementation details:
- Pinned canonical codeBytes Base64 and codeHash:
- v1 (refund): codeHash `9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F`, length 167.
- v2 (nonrefund): codeHash `HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr`, length 104.
- ABI parity respected:
- Common: `[0..7]` sleepMinutes, `[8..15]` goalAtoms (u64, bigendian).
- v1: write `PAYMENT` (2) at `[64..71]`; awardee at `[80..111]`.
- v2: awardee at `[48..79]`.
- Creation parity: codeBytes are immutable; only dataBytes vary per deployment.
### 2) Awardee Address Input + Validation
- New optional Awardee field on the creation form.
- Defaults to creators address if left blank.
- Validates using `/addresses/validate/{address}`.
- If the input appears to be a Qortal name, resolves via `GET_NAME_DATA` to the owner address, then validates.
- Address encoding: Base58 decode is written into a 32byte slot; if the decoded length < 32 (typical 25 bytes), the remainder is zerofilled for ABI compatibility.
### 3) InApp Labels for Donors
- Crowdfund page now displays:
- Status (existing),
- Awardee (with optional name lookup via `GET_PRIMARY_NAME`),
- Payout behavior (v1 refund vs v2 nonrefund).
### 4) Technical: Centralized AT Builder
- Added `src/lib/at/crowdfund.ts` with:
- Pinned code bytes / codeHash constants,
- Variantaware `buildCrowdfundDataBytes()` using bigendian u64 writes,
- `qortToAtoms()` utility,
- Optional `assertCreationParity()` to verify codeBytes hash from `creationBytes`.
## Also included since last prerelease
- Fix back button
- Ensures consistent navigation back to the homepage and avoids stale state when returning from crowdfund details.
- Add multiple name support
- Accounts with multiple registered names are now supported. We load and show the primary name while retaining the full list for selection.
## Developer Notes
- When adding new variants in future, keep codeBytes immutable and adjust only dataBytes offsets.
- Unit tests recommended for:
- BE u64 writes for sleep/goal,
- v1 payment type constant at byte 64,
- awardee placement offsets per variant,
- codeHash parity via `assertCreationParity()`.
+17
View File
@@ -0,0 +1,17 @@
# QFund v1.0.0 — Whats New
Weve made QFund more flexible and clear for both creators and donors.
Highlights
- Choose your payout policy: when creating a QFund, you can now toggle “Refund Donors (if goal not reached)”.
- Off (default): funds go to the awardee even if the goal isnt met.
- On: donors are refunded if the goal isnt met.
- Pick who receives funds: optionally enter an Awardee address (defaults to your own). We validate the address and, if you enter a Qortal name, well use its owners address.
- Clear info for donors: each QFund clearly shows Status, Awardee (with name when available), and Payout behavior.
Other improvements
- Back button works more reliably when navigating around QFund pages.
- Accounts with multiple names are supported; we show your primary name while keeping your list available.
Thanks for building with QFund! If you spot anything off, please share feedback so we can keep polishing the experience.
+301
View File
@@ -0,0 +1,301 @@
# 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.
```ts
// 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.”
```tsx
// 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.
```ts
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):**
```ts
// 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)
```ts
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=
```
+152
View File
@@ -0,0 +1,152 @@
# Q-Fund Crowdfund ATs — Technical Specification (v1 refund, v2 non-refund)
**Scope:** This document specifies two Qortal Automated Transactions (ATs) that implement a crowdfund with two settlement behaviors: **v1 (refund-if-goal-missed)** and **v2 (pay-awardee-even-if-missed)**. It describes their immutable **code bytes**, **codeHash** calculation, **data bytes ABI**, **state machine**, **creation & deploy** flow, and verification rules so future developers can reproduce identical ATs (same codeHash) and build compatible apps.
---
## 1) Invariants & Golden Rules
- **Code identity = `codeHash` = `Base58(SHA-256(codeBytes))`.** Never include user parameters in `codeBytes`.
- **ABI uses bigendian u64** unless noted. Address bytes are 32 bytes.
- **Creation bytes = header + codeBytes + dataBytes**. Only `dataBytes` vary per deployment.
- **Determinism**: avoid compiling time-dependent constants into `codeBytes`; compute cutoffs from `GET_BLOCK_TIMESTAMP` at runtime.
---
## 2) Code Hashes (Pinned)
- **v1 (refund)**
- `codeHash` = `9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F`
- `codeBytes.length` = 167 bytes
- **v2 (pay-anyway)**
- `codeHash` = `HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr`
- `codeBytes.length` = 104 bytes
> These hashes were recomputed by `Base58(SHA-256(codeBytes))` from deployed creation bytes and from the apps embedded Base64 (v2).
---
## 2a) Premade codeBytes (reference)
These are the canonical, premade **codeBytes** for each variant. They are immutable; apps should embed these exact bytes (Base64) to reproduce the same `codeHash`.
### v1 — refund-if-goal-missed
- codeHash: `9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F`
- codeBytes.length: `167`
- SHA-256(codeBytes) hex: `80f770c2b6e6475208da0e9daa6b632b1b18293a606c88a213142929e2e7e9f0`
**Base64 (exact):**
```
NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAKMgQDKDAzAwQAAAAFNQElAAAABhsAAAAGByg1AwcAAAAFIAAAAAUAAAACCyg1AwUAAAAHJAAAAAcAAAAI0jUDBgAAAAkyAwozBAIAAAAJGgAAAFk=
```
### v2 — pay-awardee-even-if-missed
- codeHash: `HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr`
- codeBytes.length: `104`
- SHA-256(codeBytes) hex: `f665c427e2a59187aa4ee1c6325c226cd57814b02d007071439fb103f0badf3d`
**Base64 (exact):**
```
NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg=
```
---
## 3) Data Bytes ABI (public interface)
### Common fields
- **[0..7] (u64)** — *sleepMinutes/blocks window*. Number of minutes/blocks until the campaign decision point. Stored as u64 (big-endian). Used to compute a **timestamp cutoff** and a **sleep-until-height**.
- **[8..15] (u64)** — *goalAmountAtoms*. Goal amount in QORT atoms (QORT×1e8).
### Variantspecific fields
- **v1 (refund)** — extended ABI to support refund scanning
- **[16..23] (u64)** — *lastTxnTimestamp* (internal; initialized 0). Tracks scan cursor for inbound tx iteration.
- **[24..31] (u64)** — *cutoffTimestamp* (internal; computed).
- **[32..39] (u64)** — *finalAmount* (internal; computed at decision time).
- **[40..47] (u64)** — *tmpAmount* (scratch).
- **[48..55] (u64)** — *tmpType* (scratch; holds tx type).
- **[56..63] (u64)** — *tmpHeight* (scratch; holds height from timestamp).
- **[64..71] (u64)** — *paymentTypeId* (= `PAYMENT` = 2). Constant used to filter only payment transactions.
- **[72..79] (u64)** — *reserved*.
- **[80..111] (32 bytes)** — *awardeeAddress*.
- **v2 (pay-anyway)** — minimal ABI
- **[16..47] (4×u64)** — reserved/scratch (implementation-specific).
- **[48..79] (32 bytes)** — *awardeeAddress*.
> **Note:** All unspecified fields are zero-initialized. The layouts above reflect the offsets used by the app (v2) and by the v1 Java program. Keep exact sizes to preserve codeHash parity across builds.
---
## 4) State Machine (high level)
### v1 — Refund if goal missed
1. **Init**: Read `sleepMinutes` & `goalAtoms`; awardee set in `dataBytes`.
2. **Compute cutoff**: `GET_BLOCK_TIMESTAMP``ADD_MINUTES_TO_TIMESTAMP(sleepMinutes)` → store `cutoffTimestamp`.
3. **Sleep**: Convert cutoff timestamp to height; `SLP_DAT(height)` to pause until decision block.
4. **Decision**:
- `GET_CURRENT_BALANCE` → store `finalAmount`.
- If `finalAmount >= goalAtoms`: `SET_B_DAT(awardee)``PAY_ALL_TO_ADDRESS_IN_B``FIN_IMD`.
- Else: enter **Refund Loop**.
5. **Refund Loop**:
- `PUT_TX_AFTER_TIMESTAMP_INTO_A(lastTxnTimestamp)`; if A is zero → `FIN_IMD`.
- `GET_TIMESTAMP_FROM_TX_IN_A` → update `lastTxnTimestamp`; if `>= cutoffTimestamp``FIN_IMD`.
- `GET_TYPE_FROM_TX_IN_A` → if `!= paymentTypeId` → loop (skip non-payments).
- `GET_AMOUNT_FROM_TX_IN_A` & `PUT_ADDRESS_FROM_TX_IN_A_INTO_B``PAY_TO_ADDRESS_IN_B(amount)` → loop.
### v2 — Pay awardee even if goal missed
1. **Init**: Read `sleepMinutes` & `goalAtoms`; awardee set in `dataBytes`.
2. **Compute cutoff** as in v1; **Sleep** until decision block.
3. **Decision**: Regardless of whether `GET_CURRENT_BALANCE >= goalAtoms`, **pay all**: `SET_B_DAT(awardee)``PAY_ALL_TO_ADDRESS_IN_B``FIN_IMD`.
---
## 5) Build → Create → Deploy
1. **Build `dataBytes`** (big-endian):
- Write u64 at offset 0: `sleepMinutes` (or block window).
- Write u64 at offset 8: `goalAtoms`.
- Write 32byte awardee at offset **v1: 80** / **v2: 48**.
- (v1 only) Write u64 at offset 64: `paymentTypeId = 2`.
2. **Use fixed `codeBytesBase64`** per variant (see §2).
3. **POST `/at/create`** with:
```json
{
"ciyamAtVersion": 2,
"codeBytesBase64": "...",
"dataBytesBase64": "...",
"numCallStackPages": 0,
"numUserStackPages": 0,
"minActivationAmount": 0
}
```
4. **Verify** the response by decoding `creationBytes` and re-hashing `codeBytes`.
5. **Deploy** via Hub:
`qortalRequest({ action: 'DEPLOY_AT', creationBytes, name, description, tags:'q-fund', amount:0.2, assetId:0, type:'crowdfund' })`.
---
## 6) Creation Bytes Decoder (sanity tool)
Given `creationBytes` (Base58):
- Decode → parse header → extract `codeLen` → slice `codeBytes` → slice `dataBytes`.
- Compute `codeHash = Base58(SHA-256(codeBytes))`.
- Assert expected lengths and non-absurd values (e.g., data ≥ 112 for v1; ≥ 80 for v2).
---
## 7) Parity Tests (Definition of Done)
- **Code parity:** The provided `codeBytesBase64` must hash to the pinned `codeHash` for each variant.
- **Creation parity:** Changing only `dataBytes` must not alter `codeHash`.
- **Behavior parity:**
- v1: when goal is missed, donors are refunded; when met, awardee receives all.
- v2: awardee receives all regardless of goal.
- **ABI parity:** Offsets and sizes match §3 (BE u64, 32byte address).
---
## 8) Known Offsets & Helpers (app reference)
- **v2 (repo)** uses
- `setLongValue(data, 0, blocks)`
- `setLongValue(data, 8, goalAtoms)`
- `replaceArraySlice(data, 48, bs58.decode(awardee))`
- **v1** must use
- `setLongValue(data, 0, blocks)`
- `setLongValue(data, 8, goalAtoms)`
- `setLongValue(data, 64, 2)` // PAYMENT constant
- `replaceArraySlice(data, 80, bs58.decode(awardee))`
---
## 9) Hash Recipe (exact)
```text
codeHash = Base58( SHA-256( codeBytes ) )
```
---
## 10) Appendix — Validation Artifacts
- v1 `codeBytes.length` = 167; v2 `codeBytes.length` = 104.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "q-fund",
"version": "0.0.0",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "q-fund",
"version": "0.0.0",
"version": "1.0.0",
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "q-fund",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Route, Routes } from 'react-router-dom';
import { useIframe } from './hooks/useIframe'
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { darkTheme, lightTheme } from './styles/theme';
@@ -15,6 +16,7 @@ function App() {
// const themeColor = window._qdnTheme
const [theme, setTheme] = useState('dark');
useIframe()
return (
<Provider store={store}>
+92 -49
View File
@@ -33,7 +33,14 @@ import isBetween from "dayjs/plugin/isBetween"; // Import the plugin
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import duration from "dayjs/plugin/duration";
import bs58 from "bs58";
import {
Variant,
qortToAtoms,
buildCrowdfundDataBytes,
selectCodeBytesBase64,
expectedCodeHash,
assertCreationParity,
} from "../../lib/at/crowdfund";
import {
addCrowdfundToBeginning,
addToHashMap,
@@ -98,6 +105,7 @@ export const NewCrowdfund = ({ editId, editContent }: NewCrowdfundProps) => {
const [inlineContent, setInlineContent] = useState("");
const [attachments, setAttachments] = useState<any[]>([]);
const [coverImage, setCoverImage] = useState<string | null>(null);
const [awardeeAddress, setAwardeeAddress] = useState<string>("");
const minGoal = 1;
const maxGoal = 1_000_000;
@@ -158,50 +166,49 @@ export const NewCrowdfund = ({ editId, editContent }: NewCrowdfundProps) => {
}
}
const dataBytePlaceholder = [0, 0, 0, 0, 0, 0, 0, 60, 0, 0, 0, 0, 61, -3, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 58, -72, -68, -80, 127, 99, 68, -76, 42, -80, 66, 80, -56, 106, 110, -117, 117, -45, -3, -69, -58, 86, -107, -110, 93, 0, 0, 0, 0, 0, 0, 0]
function adjustByteValue(byteValue) {
return (byteValue + 256) % 256;
}
function setLongValue(array, position, value) {
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setUint32(0, Math.floor(value / 0x100000000));
view.setUint32(4, value >>> 0);
for (let i = 0; i < 8; i++) {
array[position + i] = view.getInt8(i) & 0xff; // Correctly handle the byte value
}
}
// Function to replace a value at a given position in the original array with an array
function replaceArraySlice(originalArray, position, newArray) {
for (let i = 0; i < newArray.length; i++) {
originalArray[position + i] = newArray[i];
}
}
const codeBytes =
"NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg=";
const createBytes = (goalAmount: number, blocks: number, address: string) => {
// Validate Qortal address via core endpoint
async function validateQortalAddress(address: string): Promise<boolean> {
const addr = address?.trim();
if (!addr) return false;
try {
const newArray = [...dataBytePlaceholder];
setLongValue(newArray, 0, blocks);
const adjustedInput = goalAmount * 1e8;
setLongValue(newArray, 8, adjustedInput);
const decodedAwardeeAddress = bs58.decode(address).map(adjustByteValue);
replaceArraySlice(newArray, 48, decodedAwardeeAddress);
const byteArray: Uint8Array = new Uint8Array(newArray);
const encodedString: string = uint8ArrayToBase64(byteArray);
return encodedString;
} catch (error) {
console.error(error);
const res = await fetch(`/addresses/validate/${addr}`, { method: 'GET' });
if (!res.ok) return false;
try {
const json = await res.json();
if (typeof json === 'boolean') return json;
if (typeof json?.valid === 'boolean') return json.valid;
} catch (_) {
const text = await res.text();
const t = (text || '').toLowerCase();
if (t === 'true') return true;
if (t === 'false') return false;
}
return false;
} catch {
return false;
}
};
}
// Attempt to resolve a name to an address if needed
async function resolveAwardeeAddress(input: string, fallbackAddress: string): Promise<string> {
const base58re = /[1-9A-HJ-NP-Za-km-z]/g;
const clean = (s: string) => (s || '').match(base58re)?.join('') || '';
const candidate = clean(input?.trim() || '');
const fallback = clean(fallbackAddress || '');
if (!candidate) return fallback;
// If already a valid address, use it
if (await validateQortalAddress(candidate)) return candidate;
// Try resolving as a registered name
try {
const data = await qortalRequest({ action: 'GET_NAME_DATA', name: candidate });
const owner = data?.owner || '';
if (owner && (await validateQortalAddress(owner))) return owner;
} catch {}
return candidate; // return original; validation will catch invalid
}
// Variant selection (default = v2 non-refund)
const [refundIfMissed, setRefundIfMissed] = useState<boolean>(false);
@@ -256,14 +263,31 @@ export const NewCrowdfund = ({ editId, editContent }: NewCrowdfundProps) => {
if (blocksToGoal < 29 || blocksToGoal > 43200)
throw new Error("end of crowdfund needs to be between 2880 and 43200");
if (!goalValue) throw new Error("Goal amount must be one or greater!");
requestBody.dataBytesBase64 = createBytes(
+goalValue,
blocksToGoal,
userAddress
);
const variant: Variant = refundIfMissed ? 'v1_refund' : 'v2_nonrefund';
const goalAtoms = qortToAtoms(String(goalValue));
// Determine awardee (optional field; defaults to creator address)
const effectiveAwardee = await resolveAwardeeAddress(awardeeAddress, userAddress || '');
if (!effectiveAwardee) throw new Error('Unable to determine awardee address');
const isValid = await validateQortalAddress(effectiveAwardee);
if (!isValid) throw new Error('Invalid awardee address');
const dataBytes = buildCrowdfundDataBytes({
variant,
sleepMinutes: blocksToGoal,
goalAtoms,
awardeeBase58: effectiveAwardee,
});
requestBody.dataBytesBase64 = uint8ArrayToBase64(dataBytes);
requestBody.codeBytesBase64 = selectCodeBytesBase64(variant);
requestBody.codeBytesBase64 = codeBytes;
const creationBytes = await fetchPostRequest("/at/create", requestBody);
try {
await assertCreationParity(creationBytes, expectedCodeHash(variant));
} catch (e) {
console.warn('AT creation parity check failed:', e);
}
const response = await qortalRequest({
action: "DEPLOY_AT",
creationBytes,
@@ -288,6 +312,9 @@ export const NewCrowdfund = ({ editId, editContent }: NewCrowdfundProps) => {
blocksToGoal,
goalValue: +goalValue,
userAddress,
awardeeAddress: effectiveAwardee,
payoutVariant: variant,
codeHash: expectedCodeHash(variant),
},
};
@@ -530,6 +557,22 @@ export const NewCrowdfund = ({ editId, editContent }: NewCrowdfundProps) => {
Length of crowdfund: {diffInMins} blocks ~{" "}
{formatDuration(diffInMins)}
</NewCrowdfundTimeDescription>
<CustomInputField
name="awardee"
label="Awardee address (optional; defaults to your address)"
variant="filled"
value={awardeeAddress}
onChange={e => setAwardeeAddress(e.target.value)}
inputProps={{ maxLength: 64 }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' }}>
<input
type="checkbox"
checked={refundIfMissed}
onChange={e => setRefundIfMissed(e.target.checked)}
/>
<span>Refund Donors (if goal not reached)</span>
</label>
<NewCrowdFundFont>Add necessary files - optional</NewCrowdFundFont>
<FileAttachment
+71 -15
View File
@@ -1,5 +1,7 @@
import React from "react";
import { Box, useTheme } from "@mui/material";
import { Box, Menu, MenuItem, Popover, useTheme } from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import PersonOffIcon from "@mui/icons-material/PersonOff";
import {
CustomAppBar,
ThemeSelectRow,
@@ -9,6 +11,8 @@ import {
AuthenticateButton,
NavbarName,
AvatarContainer,
DropdownContainer,
DropdownText,
} from "./Navbar-styles";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
@@ -22,6 +26,10 @@ interface Props {
authenticate: () => void;
setTheme: (val: string) => void;
fixed?: boolean;
userName: string | null;
accountNames?: string[];
setActiveName?: (name: string) => void;
userAvatar?: string;
}
const NavBar: React.FC<Props> = ({
@@ -29,17 +37,32 @@ const NavBar: React.FC<Props> = ({
authenticate,
setTheme,
fixed,
userName,
accountNames = [],
setActiveName,
userAvatar = "",
}) => {
const theme = useTheme();
const [isOpenBlockedNamesModal, setIsOpenBlockedNamesModal] =
React.useState(false);
const dispatch = useDispatch();
const navigate = useNavigate();
const username = useSelector((state: RootState) => state.auth.user?.name);
const username = userName;
const userAvatarHash = useSelector(
(state: RootState) => state.global.userAvatarHash
);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) =>
setAnchorEl(e.currentTarget);
const handleClose = (name?: string) => {
setAnchorEl(null);
if (name && name !== username && setActiveName) setActiveName(name);
};
return (
<CustomAppBar
position={fixed ? "sticky" : "relative"}
@@ -94,13 +117,14 @@ const NavBar: React.FC<Props> = ({
)}
{isAuthenticated && username && (
<>
<AvatarContainer>
<NavbarName
style={{ color: !fixed ? "white" : theme.palette.text.primary }}
>
{username}
</NavbarName>
{!userAvatarHash[username] ? (
<AvatarContainer onClick={(e) => setAnchorEl(e.currentTarget)}>
{isAuthenticated && username && (
<NavbarName style={{ color: !fixed ? "white" : theme.palette.text.primary }}>
{username}
<ExpandMoreIcon sx={{ fontSize: 20, ml: 0.5 }} />
</NavbarName>
)}
{!userAvatar ? (
<AccountCircleSVG
color={!fixed ? "white" : theme.palette.text.primary}
width="32"
@@ -108,17 +132,49 @@ const NavBar: React.FC<Props> = ({
/>
) : (
<img
src={userAvatarHash[username]}
alt="User Avatar"
src={userAvatar}
alt="avatar"
width="32"
height="32"
style={{
borderRadius: "50%",
color: !fixed ? "white" : theme.palette.text.primary,
}}
style={{ borderRadius: "50%" }}
/>
)}
</AvatarContainer>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
>
{accountNames.map((n) => (
<DropdownContainer
key={n}
onClick={() => {
setAnchorEl(null);
if (n !== username && setActiveName) setActiveName(n);
}}
>
<DropdownText>{n === username ? "✔︎ " : ""}{n || "(nameless)"}</DropdownText>
</DropdownContainer>
))}
<DropdownContainer
onClick={() => {
setIsOpenBlockedNamesModal(true);
setAnchorEl(null);
}}
>
<PersonOffIcon sx={{ color: "#e35050" }} />
<DropdownText>Blocked Names</DropdownText>
</DropdownContainer>
</Popover>
<Menu anchorEl={anchorEl} open={open} onClose={() => handleClose()}>
{accountNames?.map((n) => (
<MenuItem key={n} selected={n === username} onClick={() => handleClose(n)}>
{n || "<nameless>"}
</MenuItem>
))}
</Menu>
</>
)}
</Box>
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
export const useIframe = () => {
const navigate = useNavigate();
useEffect(() => {
function handleNavigation(event: MessageEvent) {
if (event.data?.action === "NAVIGATE_TO_PATH" && event.data.path) {
console.log("Navigating to path within React app:", event.data.path);
navigate(event.data.path); // Navigate directly to the specified path
// Send a response back to the parent window after navigation is handled
window.parent.postMessage(
{ action: "NAVIGATION_SUCCESS", path: event.data.path },
"*"
);
}
}
window.addEventListener("message", handleNavigation);
return () => {
window.removeEventListener("message", handleNavigation);
};
}, [navigate]);
return { navigate };
};
+82
View File
@@ -0,0 +1,82 @@
import bs58 from 'bs58'
// Canonical code bytes and hashes (do not modify)
export const CROWDFUND_V1_CODEHASH = '9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F'
export const CROWDFUND_V2_CODEHASH = 'HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr'
export const CROWDFUND_V1_CODEBYTES_BASE64 =
'NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAKMgQDKDAzAwQAAAAFNQElAAAABhsAAAAGByg1AwcAAAAFIAAAAAUAAAACCyg1AwUAAAAHJAAAAAcAAAAI0jUDBgAAAAkyAwozBAIAAAAJGgAAAFk='
export const CROWDFUND_V2_CODEBYTES_BASE64 =
'NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg='
export type Variant = 'v1_refund' | 'v2_nonrefund'
// v1 (refund) ABI specifics
export const V1_PAYMENT_TYPE_U64 = 2n
const V1_AWARDEE_OFFSET = 80
const V2_AWARDEE_OFFSET = 48
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 [i, fRaw = ''] = s.split('.')
const f = (fRaw + '00000000').slice(0, 8)
return BigInt(i || '0') * 100000000n + BigInt(f || '0')
}
export function buildCrowdfundDataBytes(args: {
variant: Variant
sleepMinutes: number
goalAtoms: bigint
awardeeBase58: string
}): Uint8Array {
const { variant, sleepMinutes, goalAtoms, awardeeBase58 } = args
if (sleepMinutes < 10) throw new Error('sleepMinutes must be >= 10')
const awardee = bs58.decode(awardeeBase58)
// Accept standard Qortal address length (often 25 bytes in Base58 decode).
// Zero-pad up to 32 bytes to fit ABI field.
if (awardee.length > 32) {
throw new Error(`awardee decode too long: ${awardee.length} bytes`)
}
const totalBytes = variant === 'v1_refund' ? 112 : 80
const awardeeOffset = variant === 'v1_refund' ? V1_AWARDEE_OFFSET : V2_AWARDEE_OFFSET
const data = new Uint8Array(totalBytes)
beWriteU64(data, 0, BigInt(sleepMinutes))
beWriteU64(data, 8, goalAtoms)
if (variant === 'v1_refund') beWriteU64(data, 64, V1_PAYMENT_TYPE_U64)
data.set(awardee, awardeeOffset)
// remaining bytes in the 32-byte slot stay zero-initialized if address < 32 bytes
return data
}
export function selectCodeBytesBase64(variant: Variant): string {
return variant === 'v1_refund' ? CROWDFUND_V1_CODEBYTES_BASE64 : CROWDFUND_V2_CODEBYTES_BASE64
}
export function expectedCodeHash(variant: Variant): string {
return variant === 'v1_refund' ? CROWDFUND_V1_CODEHASH : CROWDFUND_V2_CODEHASH
}
// Optional sanity helper to re-hash codeBytes from creationBytes and confirm codeHash
export async function assertCreationParity(
creationBytesBase58: string,
expectedHash: string
) {
const bs58 = await import('bs58')
const raw = bs58.default.decode(creationBytesBase58)
let ptr = 0
ptr += 2 // ciyamAtVersion
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 hash58 = bs58.default.encode(new Uint8Array(sha))
if (hash58 !== expectedHash) throw new Error(`codeHash mismatch: expected ${expectedHash}, got ${hash58}`)
}
+54
View File
@@ -57,6 +57,8 @@ import CoverImageDefault from "../../assets/images/CoverImageDefault.webp";
import { setNotification } from "../../state/features/notificationsSlice";
import { useFetchCrowdfundStatus } from "../../hooks/useFetchCrowdfundStatus";
import { CrowdfundLoader } from "./CrowdfundLoader";
import { CROWDFUND_V1_CODEHASH, CROWDFUND_V2_CODEHASH } from "../../lib/at/crowdfund";
import { getPrimaryAccountName } from "../../utils/qortalRequestFunctions";
import { ReusableModalStyled } from "../../components/common/Reviews/QFundOwnerReviews-styles";
import { QFundOwnerReviews } from "../../components/common/Reviews/QFundOwnerReviews";
import DonorInfo from "../../components/common/Donate/DonorInfo";
@@ -65,6 +67,31 @@ import {
searchTransactions,
} from "qortal-app-utils";
// Small component to display awardee info with optional name lookup
const AwardeeInfo: React.FC<{ address: string }> = ({ address }) => {
const [name, setName] = useState<string>("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const n = await getPrimaryAccountName(address);
if (!cancelled) setName(n || "");
} catch (_) {
if (!cancelled) setName("");
}
})();
return () => {
cancelled = true;
};
}, [address]);
const label = name ? `${name} (${address})` : address;
return (
<CrowdfundStatusRow style={{ border: '1px solid #7a7a7a', color: '#c8c8c8' }}>
Awardee: {label}
</CrowdfundStatusRow>
);
};
export const Crowdfund = () => {
const theme = useTheme();
const dispatch = useDispatch();
@@ -563,6 +590,33 @@ export const Crowdfund = () => {
}}
>{`Status: ${ATStatus}`}</CrowdfundStatusRow>
)}
{/* Awardee and payout info directly after status */}
{(() => {
const awardeeAddr =
crowdfundData?.deployedAT?.awardeeAddress ||
crowdfundData?.deployedAT?.userAddress ||
'';
return awardeeAddr ? (
<AwardeeInfo address={awardeeAddr} />
) : null;
})()}
{(() => {
const variant = crowdfundData?.deployedAT?.payoutVariant;
const codeHash = crowdfundData?.deployedAT?.codeHash;
const v = variant
? variant
: codeHash === CROWDFUND_V1_CODEHASH
? 'v1_refund'
: 'v2_nonrefund';
const label = v === 'v1_refund'
? 'Payout: Refund donors if goal is not met'
: 'Payout: Always pays awardee (non-refund)';
return (
<CrowdfundStatusRow style={{ border: '1px solid #7a7a7a', color: '#c8c8c8' }}>
{label}
</CrowdfundStatusRow>
);
})()}
<CrowdfundDescriptionRow>
{crowdfundData?.description}
</CrowdfundDescriptionRow>
+21 -26
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { NewCrowdfund } from '../../components/Crowdfund/NewCrowdfund';
import { CrowdfundList } from './CrowdfundList';
import {
@@ -17,6 +17,10 @@ import {
import NavBar from '../../components/layout/Navbar/Navbar';
import { useDispatch, useSelector } from 'react-redux';
import { addUser } from '../../state/features/authSlice';
import {
getAccountNames,
getPrimaryAccountName,
} from '../../utils/qortalRequestFunctions';
import { RootState } from '../../state/store';
import { ExploreSVG } from '../../assets/svgs/ExploreSVG';
import { DonateSVG } from '../../assets/svgs/DonateSVG';
@@ -30,32 +34,17 @@ export const Home: React.FC<Props> = ({ setTheme }) => {
const dispatch = useDispatch();
const user = useSelector((state: RootState) => state.auth.user);
async function getNameInfo(address: string) {
const response = await qortalRequest({
action: 'GET_ACCOUNT_NAMES',
address: address,
});
const nameData = response;
if (nameData?.length > 0) {
return nameData[0].name;
} else {
return '';
}
const askForAccountInformation = useCallback(async () => {
try {
const account = await qortalRequest({ action: 'GET_USER_ACCOUNT' });
const nameObjs = await getAccountNames(account.address);
const names = (nameObjs || []).map(n => n.name);
const primary = names[0] || '';
dispatch(addUser({ ...account, name: primary, names }));
} catch (error) {
console.error(error);
}
const askForAccountInformation = React.useCallback(async () => {
try {
const account = await qortalRequest({
action: 'GET_USER_ACCOUNT',
});
const name = await getNameInfo(account.address);
dispatch(addUser({ ...account, name }));
} catch (error) {
console.error(error);
}
}, []);
}, [dispatch]);
useEffect(() => {
if (user?.name) return;
@@ -70,6 +59,12 @@ export const Home: React.FC<Props> = ({ setTheme }) => {
setTheme={(val: string) => setTheme(val)}
authenticate={askForAccountInformation}
isAuthenticated={!!user?.name}
userName={user?.name || ''}
accountNames={user?.names || []}
setActiveName={(name: string) =>
dispatch(addUser({ ...user, name, names: user?.names || [] }))
}
userAvatar={''}
/>
<HomePageSubContainer>
<HomepageTitleRow>
+1
View File
@@ -6,6 +6,7 @@ interface AuthState {
address: string;
publicKey: string;
name?: string;
names?: string[];
} | null;
}
const initialState: AuthState = {
+29
View File
@@ -0,0 +1,29 @@
export interface NameRecord {
name: string;
owner: string;
}
export async function getAccountNames(address: string): Promise<NameRecord[]> {
try {
const list = await qortalRequest({
action: 'GET_ACCOUNT_NAMES',
address,
});
if (Array.isArray(list) && list.length) return list;
return [{ name: '', owner: address }];
} catch {
return [{ name: '', owner: address }];
}
}
export async function getPrimaryAccountName(address: string): Promise<string> {
try {
const res = await qortalRequest({
action: 'GET_PRIMARY_NAME',
address,
});
return typeof res === 'string' ? res : '';
} catch {
return '';
}
}
+15 -12
View File
@@ -47,18 +47,16 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
const { isLoadingGlobal } = useSelector((state: RootState) => state.global);
async function getNameInfo(address: string) {
const response = await qortalRequest({
async function getNames(address: string) {
const names = await qortalRequest({
action: "GET_ACCOUNT_NAMES",
address: address,
address,
});
const nameData = response;
if (nameData?.length > 0) {
return nameData[0].name;
} else {
return "";
}
const list = (names || []).map((n: any) => n.name);
return {
primary: list[0] || "",
list,
};
}
const askForAccountInformation = React.useCallback(async () => {
@@ -67,8 +65,8 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
action: "GET_USER_ACCOUNT",
});
const name = await getNameInfo(account.address);
dispatch(addUser({ ...account, name }));
const { primary, list } = await getNames(account.address);
dispatch(addUser({ ...account, name: primary, names: list }));
} catch (error) {
console.error(error);
}
@@ -87,6 +85,11 @@ const GlobalWrapper: React.FC<Props> = ({ children, setTheme }) => {
<NavBar
setTheme={(val: string) => setTheme(val)}
isAuthenticated={!!user?.name}
userName={user?.name || ""}
accountNames={user?.names || []}
setActiveName={(name: string) =>
dispatch(addUser({ ...user, name, names: user?.names || [] }))
}
authenticate={askForAccountInformation}
fixed={true}
/>