forked from Qortal/q-fund-v2
update #1
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# Q‑Fund v1.0.0 — Release Notes
|
||||
|
||||
Release date: 2025‑08‑23
|
||||
|
||||
## Highlights
|
||||
|
||||
- Dual AT support (v1 refund / v2 non‑refund) with creator‑selectable payout policy.
|
||||
- Awardee address field with validation and optional name resolution.
|
||||
- Clear in‑app 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 non‑refund: 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 (non‑refund): codeHash `HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr`, length 104.
|
||||
- ABI parity respected:
|
||||
- Common: `[0..7]` sleepMinutes, `[8..15]` goalAtoms (u64, big‑endian).
|
||||
- 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 creator’s 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 32‑byte slot; if the decoded length < 32 (typical 25 bytes), the remainder is zero‑filled for ABI compatibility.
|
||||
|
||||
### 3) In‑App Labels for Donors
|
||||
- Crowdfund page now displays:
|
||||
- Status (existing),
|
||||
- Awardee (with optional name lookup via `GET_PRIMARY_NAME`),
|
||||
- Payout behavior (v1 refund vs v2 non‑refund).
|
||||
|
||||
### 4) Technical: Centralized AT Builder
|
||||
- Added `src/lib/at/crowdfund.ts` with:
|
||||
- Pinned code bytes / codeHash constants,
|
||||
- Variant‑aware `buildCrowdfundDataBytes()` using big‑endian u64 writes,
|
||||
- `qortToAtoms()` utility,
|
||||
- Optional `assertCreationParity()` to verify codeBytes hash from `creationBytes`.
|
||||
|
||||
## Also included since last pre‑release
|
||||
|
||||
- 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()`.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Q‑Fund v1.0.0 — What’s New
|
||||
|
||||
We’ve made Q‑Fund more flexible and clear for both creators and donors.
|
||||
|
||||
Highlights
|
||||
- Choose your payout policy: when creating a Q‑Fund, you can now toggle “Refund Donors (if goal not reached)”.
|
||||
- Off (default): funds go to the awardee even if the goal isn’t met.
|
||||
- On: donors are refunded if the goal isn’t 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, we’ll use its owner’s address.
|
||||
- Clear info for donors: each Q‑Fund clearly shows Status, Awardee (with name when available), and Payout behavior.
|
||||
|
||||
Other improvements
|
||||
- Back button works more reliably when navigating around Q‑Fund pages.
|
||||
- Accounts with multiple names are supported; we show your primary name while keeping your list available.
|
||||
|
||||
Thanks for building with Q‑Fund! If you spot anything off, please share feedback so we can keep polishing the experience.
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# 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}/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 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`**:
|
||||
|
||||
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 isn’t 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 variant‑aware 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 (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’s `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:
|
||||
- re‑hashed `codeBytes` matches expected `codeHash` (`9gS2L74FdaG3zuEeYv815xVyHkhvLguq7ZGD6pf24i8F` or `HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr`),
|
||||
- data segment length meets minimum (v1 ≥ 112, v2 ≥ 80),
|
||||
- creation bytes round‑trip (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 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 `.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 (non‑refund) — HaqJBVVr9gZqgARZ5UZd7EU9ybyvVK2fCo9sx3gMMFsr, len 104:**
|
||||
```
|
||||
NQMBAAAABTUDAAAAAAI3BAYAAAACAAAAAgAAAAACAAAAAwAAAAJLAAAAAwAAAAAAAAAgJQAAAAM1BAAAAAAEIAAAAAQAAAABGTgBHwAAAAAAAAAGMgQDKDA4AR8AAAAAAAAABjIEAyg=
|
||||
```
|
||||
@@ -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 big‑endian 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 app’s 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).
|
||||
|
||||
### Variant‑specific 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 32‑byte 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, 32‑byte 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.
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "q-fund",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ interface AuthState {
|
||||
address: string;
|
||||
publicKey: string;
|
||||
name?: string;
|
||||
names?: string[];
|
||||
} | null;
|
||||
}
|
||||
const initialState: AuthState = {
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user