From 35881a45985569ce16dd9eda6d24d25129909f2d Mon Sep 17 00:00:00 2001 From: greenflame089 Date: Tue, 5 Aug 2025 02:59:02 -0400 Subject: [PATCH 1/3] Fix back button --- src/App.tsx | 2 ++ src/hooks/useIframe.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/hooks/useIframe.tsx diff --git a/src/App.tsx b/src/App.tsx index 9cba8e7..11cdcf3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( diff --git a/src/hooks/useIframe.tsx b/src/hooks/useIframe.tsx new file mode 100644 index 0000000..09d68c5 --- /dev/null +++ b/src/hooks/useIframe.tsx @@ -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 }; +}; -- 2.43.0 From 55945deec98f7293fcd27cfc1f1666c55350dd9f Mon Sep 17 00:00:00 2001 From: greenflame089 Date: Tue, 5 Aug 2025 03:09:20 -0400 Subject: [PATCH 2/3] Add multiple name support --- src/components/layout/Navbar/Navbar.tsx | 86 ++++++++++++++++++++----- src/pages/Home/Home.tsx | 47 ++++++-------- src/state/features/authSlice.ts | 1 + src/utils/qortalRequestFunctions.ts | 29 +++++++++ src/wrappers/GlobalWrapper.tsx | 27 ++++---- 5 files changed, 137 insertions(+), 53 deletions(-) create mode 100644 src/utils/qortalRequestFunctions.ts diff --git a/src/components/layout/Navbar/Navbar.tsx b/src/components/layout/Navbar/Navbar.tsx index 6793aca..6db227e 100644 --- a/src/components/layout/Navbar/Navbar.tsx +++ b/src/components/layout/Navbar/Navbar.tsx @@ -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 = ({ @@ -29,17 +37,32 @@ const NavBar: React.FC = ({ 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); + const open = Boolean(anchorEl); + const handleClick = (e: React.MouseEvent) => + setAnchorEl(e.currentTarget); + const handleClose = (name?: string) => { + setAnchorEl(null); + if (name && name !== username && setActiveName) setActiveName(name); + }; + return ( = ({ )} {isAuthenticated && username && ( <> - - - {username} - - {!userAvatarHash[username] ? ( + setAnchorEl(e.currentTarget)}> + {isAuthenticated && username && ( + + {username} + + + )} + {!userAvatar ? ( = ({ /> ) : ( User Avatar )} + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + > + {accountNames.map((n) => ( + { + setAnchorEl(null); + if (n !== username && setActiveName) setActiveName(n); + }} + > + {n === username ? "✔︎ " : ""}{n || "(nameless)"} + + ))} + + { + setIsOpenBlockedNamesModal(true); + setAnchorEl(null); + }} + > + + Blocked Names + + + handleClose()}> + {accountNames?.map((n) => ( + handleClose(n)}> + {n || ""} + + ))} + )} diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 0f18139..e950687 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -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 = ({ 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 = ({ 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={''} /> diff --git a/src/state/features/authSlice.ts b/src/state/features/authSlice.ts index 8f5ee20..3304675 100644 --- a/src/state/features/authSlice.ts +++ b/src/state/features/authSlice.ts @@ -6,6 +6,7 @@ interface AuthState { address: string; publicKey: string; name?: string; + names?: string[]; } | null; } const initialState: AuthState = { diff --git a/src/utils/qortalRequestFunctions.ts b/src/utils/qortalRequestFunctions.ts new file mode 100644 index 0000000..d4dc232 --- /dev/null +++ b/src/utils/qortalRequestFunctions.ts @@ -0,0 +1,29 @@ +export interface NameRecord { + name: string; + owner: string; +} + +export async function getAccountNames(address: string): Promise { + 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 { + try { + const res = await qortalRequest({ + action: 'GET_PRIMARY_NAME', + address, + }); + return typeof res === 'string' ? res : ''; + } catch { + return ''; + } +} diff --git a/src/wrappers/GlobalWrapper.tsx b/src/wrappers/GlobalWrapper.tsx index 5d083ba..8413788 100644 --- a/src/wrappers/GlobalWrapper.tsx +++ b/src/wrappers/GlobalWrapper.tsx @@ -47,18 +47,16 @@ const GlobalWrapper: React.FC = ({ 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 = ({ 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 = ({ children, setTheme }) => { 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} /> -- 2.43.0 From dd2323ae1c60569f81f7e405fd0f8539c8e271ca Mon Sep 17 00:00:00 2001 From: greenflame089 Date: Sat, 23 Aug 2025 19:47:40 -0400 Subject: [PATCH 3/3] Add ATv1 support --- docs/Qrowdfund-v2.java | 191 +++++++++++++ docs/Qrowdfund.java | 273 +++++++++++++++++++ docs/RELEASE_NOTES_v1.0.0.md | 64 +++++ docs/USER_ANNOUNCEMENT_v1.0.0.md | 17 ++ docs/qfund_dual_at_implementation_guide.md | 301 +++++++++++++++++++++ docs/qfund_v1_v2_spec.md | 152 +++++++++++ package-lock.json | 4 +- package.json | 2 +- src/components/Crowdfund/NewCrowdfund.tsx | 141 ++++++---- src/lib/at/crowdfund.ts | 82 ++++++ src/pages/Crowdfund/Crowdfund.tsx | 54 ++++ 11 files changed, 1229 insertions(+), 52 deletions(-) create mode 100644 docs/Qrowdfund-v2.java create mode 100644 docs/Qrowdfund.java create mode 100644 docs/RELEASE_NOTES_v1.0.0.md create mode 100644 docs/USER_ANNOUNCEMENT_v1.0.0.md create mode 100644 docs/qfund_dual_at_implementation_guide.md create mode 100644 docs/qfund_v1_v2_spec.md create mode 100644 src/lib/at/crowdfund.ts diff --git a/docs/Qrowdfund-v2.java b/docs/Qrowdfund-v2.java new file mode 100644 index 0000000..88360e5 --- /dev/null +++ b/docs/Qrowdfund-v2.java @@ -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 "); + } + + 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)); + } +} diff --git a/docs/Qrowdfund.java b/docs/Qrowdfund.java new file mode 100644 index 0000000..ecbbf7a --- /dev/null +++ b/docs/Qrowdfund.java @@ -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 "); + 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)); + } +} diff --git a/docs/RELEASE_NOTES_v1.0.0.md b/docs/RELEASE_NOTES_v1.0.0.md new file mode 100644 index 0000000..ac8c99a --- /dev/null +++ b/docs/RELEASE_NOTES_v1.0.0.md @@ -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()`. + diff --git a/docs/USER_ANNOUNCEMENT_v1.0.0.md b/docs/USER_ANNOUNCEMENT_v1.0.0.md new file mode 100644 index 0000000..b49930a --- /dev/null +++ b/docs/USER_ANNOUNCEMENT_v1.0.0.md @@ -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. + diff --git a/docs/qfund_dual_at_implementation_guide.md b/docs/qfund_dual_at_implementation_guide.md new file mode 100644 index 0000000..9d7f727 --- /dev/null +++ b/docs/qfund_dual_at_implementation_guide.md @@ -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); + + +``` + +> 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= +``` diff --git a/docs/qfund_v1_v2_spec.md b/docs/qfund_v1_v2_spec.md new file mode 100644 index 0000000..29d3717 --- /dev/null +++ b/docs/qfund_v1_v2_spec.md @@ -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. diff --git a/package-lock.json b/package-lock.json index ffa0bb3..d7c5859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ade52a4..01d8eac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "q-fund", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/Crowdfund/NewCrowdfund.tsx b/src/components/Crowdfund/NewCrowdfund.tsx index 21efa9a..880b083 100644 --- a/src/components/Crowdfund/NewCrowdfund.tsx +++ b/src/components/Crowdfund/NewCrowdfund.tsx @@ -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([]); const [coverImage, setCoverImage] = useState(null); + const [awardeeAddress, setAwardeeAddress] = useState(""); 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 { + 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 { + 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(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)} + setAwardeeAddress(e.target.value)} + inputProps={{ maxLength: 64 }} + /> + Add necessary files - optional = 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}`) +} diff --git a/src/pages/Crowdfund/Crowdfund.tsx b/src/pages/Crowdfund/Crowdfund.tsx index b3f1962..b7a86f3 100644 --- a/src/pages/Crowdfund/Crowdfund.tsx +++ b/src/pages/Crowdfund/Crowdfund.tsx @@ -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(""); + 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 ( + + Awardee: {label} + + ); +}; + export const Crowdfund = () => { const theme = useTheme(); const dispatch = useDispatch(); @@ -563,6 +590,33 @@ export const Crowdfund = () => { }} >{`Status: ${ATStatus}`} )} + {/* Awardee and payout info directly after status */} + {(() => { + const awardeeAddr = + crowdfundData?.deployedAT?.awardeeAddress || + crowdfundData?.deployedAT?.userAddress || + ''; + return 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 ( + + {label} + + ); + })()} {crowdfundData?.description} -- 2.43.0