From b818436d18592b6d4684181b849e70106f04f57b Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Tue, 7 Apr 2026 17:50:32 +0600 Subject: [PATCH 01/86] chore: update event log fetching documentation and improve code consistency --- packages/contracts/README.md | 10 ++++--- .../src/contracts/all-or-nothing/events.ts | 2 +- .../contracts/campaign-info-factory/events.ts | 2 +- .../src/contracts/campaign-info/events.ts | 2 +- .../src/contracts/global-params/events.ts | 2 +- .../src/contracts/item-registry/events.ts | 2 +- .../src/contracts/keep-whats-raised/events.ts | 2 +- .../src/contracts/payment-treasury/events.ts | 2 +- .../src/contracts/treasury-factory/events.ts | 2 +- packages/contracts/src/types/events.ts | 30 +++++++++++++++++-- 10 files changed, 41 insertions(+), 15 deletions(-) diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 51ee1fc7..ae2e1f2b 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -530,12 +530,12 @@ Every contract entity exposes an `events` property with three capabilities: ### Fetching historical logs -Each event has a `get*Logs()` method that returns all matching logs from the entire chain history. You can optionally pass `{ fromBlock, toBlock }` to narrow the search range. +Each event has a `get*Logs()` method. Without options, it queries only the **latest block**. Pass `{ fromBlock, toBlock }` to search a specific range. ```typescript const gp = oak.globalParams("0x..."); -// All PlatformEnlisted events ever emitted by this contract +// Latest block only (safe default) const logs = await gp.events.getPlatformEnlistedLogs(); for (const log of logs) { @@ -545,11 +545,13 @@ for (const log of logs) { // Filter by block range const recentLogs = await gp.events.getPlatformEnlistedLogs({ - fromBlock: 1_000_000n, - toBlock: 2_000_000n, + fromBlock: 48_792_800n, + toBlock: 48_802_800n, }); ``` +> **RPC block range limits:** Public RPCs (e.g. Celo Forno, Infura free tier) typically restrict `eth_getLogs` to a maximum of 2,000–10,000 blocks per request. Requesting a larger range will result in an HTTP error or timeout. For historical scans across large ranges, either split into smaller batches or use a dedicated archive/indexing node with higher limits. + ### Decoding raw logs Use `decodeLog()` to decode a raw log from a transaction receipt. This is useful when you have a receipt and want to decode its logs without knowing which event they belong to. diff --git a/packages/contracts/src/contracts/all-or-nothing/events.ts b/packages/contracts/src/contracts/all-or-nothing/events.ts index 87f690d5..d082c35e 100644 --- a/packages/contracts/src/contracts/all-or-nothing/events.ts +++ b/packages/contracts/src/contracts/all-or-nothing/events.ts @@ -37,7 +37,7 @@ async function fetchEventLogs( address, abi: ALL_OR_NOTHING_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => diff --git a/packages/contracts/src/contracts/campaign-info-factory/events.ts b/packages/contracts/src/contracts/campaign-info-factory/events.ts index 3b906f9e..371b9bfb 100644 --- a/packages/contracts/src/contracts/campaign-info-factory/events.ts +++ b/packages/contracts/src/contracts/campaign-info-factory/events.ts @@ -19,7 +19,7 @@ async function fetchEventLogs( ): Promise { const logs = await publicClient.getContractEvents({ address, abi: CAMPAIGN_INFO_FACTORY_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, toBlock: options?.toBlock, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => decode({ topics: [...log.topics] as Hex[], data: log.data })); } diff --git a/packages/contracts/src/contracts/campaign-info/events.ts b/packages/contracts/src/contracts/campaign-info/events.ts index 8620bbb9..d1d9e1fd 100644 --- a/packages/contracts/src/contracts/campaign-info/events.ts +++ b/packages/contracts/src/contracts/campaign-info/events.ts @@ -19,7 +19,7 @@ async function fetchEventLogs( ): Promise { const logs = await publicClient.getContractEvents({ address, abi: CAMPAIGN_INFO_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, toBlock: options?.toBlock, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => decode({ topics: [...log.topics] as Hex[], data: log.data })); } diff --git a/packages/contracts/src/contracts/global-params/events.ts b/packages/contracts/src/contracts/global-params/events.ts index c6280311..b8071956 100644 --- a/packages/contracts/src/contracts/global-params/events.ts +++ b/packages/contracts/src/contracts/global-params/events.ts @@ -19,7 +19,7 @@ async function fetchEventLogs( ): Promise { const logs = await publicClient.getContractEvents({ address, abi: GLOBAL_PARAMS_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, toBlock: options?.toBlock, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => decode({ topics: [...log.topics] as Hex[], data: log.data })); } diff --git a/packages/contracts/src/contracts/item-registry/events.ts b/packages/contracts/src/contracts/item-registry/events.ts index 2a30c1b6..27a97e21 100644 --- a/packages/contracts/src/contracts/item-registry/events.ts +++ b/packages/contracts/src/contracts/item-registry/events.ts @@ -19,7 +19,7 @@ async function fetchEventLogs( ): Promise { const logs = await publicClient.getContractEvents({ address, abi: ITEM_REGISTRY_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, toBlock: options?.toBlock, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => decode({ topics: [...log.topics] as Hex[], data: log.data })); } diff --git a/packages/contracts/src/contracts/keep-whats-raised/events.ts b/packages/contracts/src/contracts/keep-whats-raised/events.ts index 5b65a491..4d2e17fd 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/events.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/events.ts @@ -19,7 +19,7 @@ async function fetchEventLogs( ): Promise { const logs = await publicClient.getContractEvents({ address, abi: KEEP_WHATS_RAISED_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, toBlock: options?.toBlock, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => decode({ topics: [...log.topics] as Hex[], data: log.data })); } diff --git a/packages/contracts/src/contracts/payment-treasury/events.ts b/packages/contracts/src/contracts/payment-treasury/events.ts index 7d438cf0..b038a007 100644 --- a/packages/contracts/src/contracts/payment-treasury/events.ts +++ b/packages/contracts/src/contracts/payment-treasury/events.ts @@ -19,7 +19,7 @@ async function fetchEventLogs( ): Promise { const logs = await publicClient.getContractEvents({ address, abi: PAYMENT_TREASURY_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, toBlock: options?.toBlock, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => decode({ topics: [...log.topics] as Hex[], data: log.data })); } diff --git a/packages/contracts/src/contracts/treasury-factory/events.ts b/packages/contracts/src/contracts/treasury-factory/events.ts index 0c83d7e2..131735cc 100644 --- a/packages/contracts/src/contracts/treasury-factory/events.ts +++ b/packages/contracts/src/contracts/treasury-factory/events.ts @@ -19,7 +19,7 @@ async function fetchEventLogs( ): Promise { const logs = await publicClient.getContractEvents({ address, abi: TREASURY_FACTORY_ABI, eventName, - fromBlock: options?.fromBlock ?? 0n, toBlock: options?.toBlock, + fromBlock: options?.fromBlock, toBlock: options?.toBlock, }); return logs.map((log) => decode({ topics: [...log.topics] as Hex[], data: log.data })); } diff --git a/packages/contracts/src/types/events.ts b/packages/contracts/src/types/events.ts index a42527bf..d217de32 100644 --- a/packages/contracts/src/types/events.ts +++ b/packages/contracts/src/types/events.ts @@ -1,10 +1,34 @@ import type { Hex } from "../lib"; -/** Options for filtering historical contract event logs. */ +/** + * Options for filtering historical contract event logs. + * + * **RPC block range limits:** Public RPCs (e.g. Celo Forno, Infura free tier) + * typically restrict `eth_getLogs` to a maximum block range per request + * (commonly 2,000–10,000 blocks). Requesting a range that exceeds this limit + * will result in an HTTP error or timeout. + * + * - If neither `fromBlock` nor `toBlock` is provided, only the **latest block** + * is queried (safe for any RPC). + * - For historical scans across large ranges, split into smaller batches + * (e.g. 10,000 blocks per request) or use a dedicated archive/indexing node. + * + * @example + * ```typescript + * // Latest block only (safe default) + * const logs = await gp.events.getPlatformEnlistedLogs(); + * + * // Narrow range (safe for public RPCs) + * const logs = await gp.events.getPlatformEnlistedLogs({ + * fromBlock: 48_792_800n, + * toBlock: 48_802_800n, + * }); + * ``` + */ export interface EventFilterOptions { - /** Block number to start searching from. */ + /** Block number to start searching from. Omit to query only the latest block. */ fromBlock?: bigint; - /** Block number to stop searching at. */ + /** Block number to stop searching at. Omit for the latest block. */ toBlock?: bigint; } From 356fc496c3fdbd8cb9385d7c749d10713c4d143d Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:39:16 +0600 Subject: [PATCH 02/86] feat: add getReceipt method to OakContractsClient for fetching transaction receipts --- packages/contracts/src/client/create.ts | 17 +++++++++++++++++ packages/contracts/src/client/types.ts | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/contracts/src/client/create.ts b/packages/contracts/src/client/create.ts index 2ac3232b..1b34ee58 100644 --- a/packages/contracts/src/client/create.ts +++ b/packages/contracts/src/client/create.ts @@ -58,12 +58,29 @@ export function createOakContractsClient( }; } + async function getReceipt(txHash: Hex): Promise { + try { + const receipt = await publicClient.getTransactionReceipt({ hash: txHash }); + return { + blockNumber: receipt.blockNumber, + gasUsed: receipt.gasUsed, + logs: receipt.logs.map((log) => ({ + topics: log.topics as readonly Hex[], + data: log.data, + })), + }; + } catch { + return null; + } + } + return { config: publicConfig, options, publicClient, walletClient, waitForReceipt, + getReceipt, multicall Promise)[]>( calls: [...T], diff --git a/packages/contracts/src/client/types.ts b/packages/contracts/src/client/types.ts index 6065b759..94fedc6d 100644 --- a/packages/contracts/src/client/types.ts +++ b/packages/contracts/src/client/types.ts @@ -129,6 +129,15 @@ export interface OakContractsClient { * @returns TransactionReceipt with blockNumber, gasUsed, and logs */ waitForReceipt(txHash: Hex): Promise; + /** + * Fetches the receipt for an already-mined transaction without waiting. + * Use this when you already have a tx hash (e.g. from a webhook, indexer, + * or previous session) and don't need to block until mining completes. + * + * @param txHash - Transaction hash to look up + * @returns TransactionReceipt, or null if the transaction is not yet mined + */ + getReceipt(txHash: Hex): Promise; /** * Batches multiple entity read calls into a single RPC round-trip via the * on-chain Multicall3 contract. Accepts an array of lazy read closures — From a35bbf86abcb08f7f9d6dd6a64bcbf5f212aa13f Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:43:22 +0600 Subject: [PATCH 03/86] feat: add encoding and decoding utilities for function data and event logs --- packages/contracts/src/index.ts | 5 +++++ packages/contracts/src/lib/viem/index.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6860caf5..2604dbff 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -8,8 +8,10 @@ export type { Address, Chain, Hex, + Log, PublicClient, WalletClient, + EIP1193Provider, } from "./lib"; export type { Wallet } from "./lib"; @@ -20,6 +22,9 @@ export { custom, stringToHex, toHex, + encodeFunctionData, + decodeFunctionResult, + decodeEventLog, parseEther, formatEther, parseUnits, diff --git a/packages/contracts/src/lib/viem/index.ts b/packages/contracts/src/lib/viem/index.ts index a0cf5bb7..1ba0c589 100644 --- a/packages/contracts/src/lib/viem/index.ts +++ b/packages/contracts/src/lib/viem/index.ts @@ -8,6 +8,8 @@ export { toHex, stringToHex, encodeAbiParameters, + encodeFunctionData, + decodeFunctionResult, decodeErrorResult, decodeEventLog, parseEther, From 8174d487ddd6b5b7e21671b18e7d6234467518a7 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:44:46 +0600 Subject: [PATCH 04/86] feat: add toSimulationResult function and enhance simulateWithErrorDecode for better simulation handling --- packages/contracts/src/errors/index.ts | 1 + .../src/errors/parse-contract-error.ts | 31 ++++++++++++++++--- packages/contracts/src/types/events.ts | 25 ++++++++++++++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/contracts/src/errors/index.ts b/packages/contracts/src/errors/index.ts index 440146a4..a9cdb7bc 100644 --- a/packages/contracts/src/errors/index.ts +++ b/packages/contracts/src/errors/index.ts @@ -3,6 +3,7 @@ export { parseContractError, getRevertData, simulateWithErrorDecode, + toSimulationResult, } from "./parse-contract-error"; export { diff --git a/packages/contracts/src/errors/parse-contract-error.ts b/packages/contracts/src/errors/parse-contract-error.ts index c0320da8..dc6d68a9 100644 --- a/packages/contracts/src/errors/parse-contract-error.ts +++ b/packages/contracts/src/errors/parse-contract-error.ts @@ -1,6 +1,7 @@ -import type { Hex } from "../lib"; +import type { Address, Hex } from "../lib"; import { isHex } from "../utils"; import type { ContractErrorBase } from "./base"; +import type { SimulationResult } from "../types/events"; import { parseGlobalParamsError } from "./parse/global-params"; import { parseCampaignInfoFactoryError } from "./parse/campaign-info-factory"; import { parseCampaignInfoError } from "./parse/campaign-info"; @@ -74,15 +75,16 @@ export function getRevertData(error: unknown): string | null { /** * Wraps a simulateContract call, catches reverts, decodes them via parseContractError, - * and re-throws as a typed SDK error. Consumers catch the same error class whether - * they are simulating or transacting. + * and re-throws as a typed SDK error. On success, returns the raw simulation response + * from viem (`{ result, request }`). * * @param operation - Async function that calls simulateContract + * @returns The raw viem simulation response * @throws Typed ContractErrorBase subclass on revert, or the original error if not decodable */ -export async function simulateWithErrorDecode(operation: () => Promise): Promise { +export async function simulateWithErrorDecode(operation: () => Promise): Promise { try { - await operation(); + return await operation(); } catch (error: unknown) { const revertData = getRevertData(error); const parsed = parseContractError(revertData ?? ""); @@ -92,3 +94,22 @@ export async function simulateWithErrorDecode(operation: () => Promise) throw error; } } + +/** + * Converts the raw viem simulateContract response into the SDK's SimulationResult shape. + * + * @param response - Raw response from publicClient.simulateContract + * @returns SimulationResult with the contract return value and prepared transaction params + */ +export function toSimulationResult(response: { result: T; request: Record }): SimulationResult { + const req = response.request; + return { + result: response.result, + request: { + to: req["to"] as Address, + data: req["data"] as Hex, + value: req["value"] as bigint | undefined, + gas: req["gas"] as bigint | undefined, + }, + }; +} diff --git a/packages/contracts/src/types/events.ts b/packages/contracts/src/types/events.ts index a42527bf..1f36f063 100644 --- a/packages/contracts/src/types/events.ts +++ b/packages/contracts/src/types/events.ts @@ -1,4 +1,4 @@ -import type { Hex } from "../lib"; +import type { Address, Hex } from "../lib"; /** Options for filtering historical contract event logs. */ export interface EventFilterOptions { @@ -26,3 +26,26 @@ export interface RawLog { /** Callback invoked when a watched event log is received. */ export type EventWatchHandler = (logs: readonly DecodedEventLog[]) => void; + +/** + * Result returned by entity simulate methods. Contains the return value + * predicted by the simulation and the prepared transaction request that + * can be used for gas estimation or account-abstraction flows. + * + * @typeParam T - Contract function return type (void for most write functions) + */ +export interface SimulationResult { + /** The value the contract function would return on-chain. */ + result: T; + /** Prepared transaction parameters from the simulation. */ + request: { + /** Target contract address. */ + to: Address; + /** ABI-encoded calldata. */ + data: Hex; + /** Native token value to send (wei). */ + value?: bigint; + /** Estimated gas limit. */ + gas?: bigint; + }; +} From 6c49d87295635eb80729e356cbcf71c8e483ca59 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:45:09 +0600 Subject: [PATCH 05/86] feat: add contract write preparation utilities with prepareContractWrite and toPreparedTransaction functions --- packages/contracts/src/utils/index.ts | 2 + packages/contracts/src/utils/prepare.ts | 111 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 packages/contracts/src/utils/prepare.ts diff --git a/packages/contracts/src/utils/index.ts b/packages/contracts/src/utils/index.ts index c6c69b1b..444c57ab 100644 --- a/packages/contracts/src/utils/index.ts +++ b/packages/contracts/src/utils/index.ts @@ -8,3 +8,5 @@ export { keccak256, id } from "./hash"; export { getCurrentTimestamp, addDays } from "./time"; export { getChainFromId } from "./chain"; export { multicall } from "./multicall"; +export { prepareContractWrite, toPreparedTransaction } from "./prepare"; +export type { PrepareWriteOptions, PreparedTransaction } from "./prepare"; diff --git a/packages/contracts/src/utils/prepare.ts b/packages/contracts/src/utils/prepare.ts new file mode 100644 index 00000000..cf3cf5ef --- /dev/null +++ b/packages/contracts/src/utils/prepare.ts @@ -0,0 +1,111 @@ +import type { Address, Hex, PublicClient, Chain } from "../lib"; +import { encodeFunctionData } from "../lib"; +import type { SimulationResult } from "../types/events"; + +/** + * Options for preparing a contract write transaction. + * + * @typeParam TAbi - ABI type (inferred from the contract ABI constant) + */ +export interface PrepareWriteOptions { + /** Target contract address. */ + address: Address; + /** Contract ABI (use an exported ABI constant, e.g. GLOBAL_PARAMS_ABI). */ + abi: TAbi; + /** The contract function name to call. */ + functionName: string; + /** Arguments to pass to the contract function. */ + args?: readonly unknown[]; + /** Native token value to send (wei). Defaults to 0. */ + value?: bigint; + /** Account address that would send the transaction — required for gas estimation. */ + account: Address; + /** Chain for gas estimation. */ + chain: Chain; +} + +/** + * Raw transaction parameters returned by prepareContractWrite. + * Suitable for account-abstraction UserOps, Safe multisig batching, + * or any flow that needs calldata without actually sending a transaction. + */ +export interface PreparedTransaction { + /** Target contract address. */ + to: Address; + /** ABI-encoded calldata. */ + data: Hex; + /** Native token value to send (wei). */ + value: bigint; + /** Estimated gas limit. */ + gas: bigint; +} + +/** + * Encodes calldata and estimates gas for a contract write without sending it. + * Combines `encodeFunctionData` and `estimateContractGas` into a single call + * that returns the raw transaction parameters needed for account-abstraction + * wallets, Safe multisig batching, or any custom signing flow. + * + * @param publicClient - Viem PublicClient for gas estimation + * @param options - Contract call parameters (address, abi, functionName, args, account, chain) + * @returns PreparedTransaction with to, data, value, and gas + * + * @example + * ```typescript + * import { prepareContractWrite, GLOBAL_PARAMS_ABI } from "@oaknetwork/contracts"; + * + * const tx = await prepareContractWrite(oak.publicClient, { + * address: "0x...", + * abi: GLOBAL_PARAMS_ABI, + * functionName: "enlistPlatform", + * args: [platformHash, adminAddress, feePercent, adapterAddress], + * account: "0xMyWallet...", + * chain: oak.config.chain, + * }); + * // tx = { to, data, value, gas } + * ``` + */ +export async function prepareContractWrite( + publicClient: PublicClient, + options: PrepareWriteOptions, +): Promise { + const data = encodeFunctionData({ + abi: options.abi, + functionName: options.functionName, + args: options.args as unknown[], + }); + + const gas = await publicClient.estimateContractGas({ + address: options.address, + abi: options.abi, + functionName: options.functionName, + args: options.args as unknown[], + account: options.account, + chain: options.chain, + value: options.value, + } as Parameters[0]); + + return { + to: options.address, + data, + value: options.value ?? 0n, + gas, + }; +} + +/** + * Extracts a PreparedTransaction from a SimulationResult. + * Convenient when you already have a simulation result and want the raw + * transaction params for account-abstraction or multisig flows. + * + * @param result - SimulationResult returned from an entity simulate method + * @returns PreparedTransaction with to, data, value, and gas + */ +export function toPreparedTransaction(result: SimulationResult): PreparedTransaction { + return { + to: result.request.to, + data: result.request.data, + value: result.request.value ?? 0n, + gas: result.request.gas ?? 0n, + }; +} From 2efe5d47f718c1ecf30d1161e18f033a973f9f09 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:45:33 +0600 Subject: [PATCH 06/86] feat: export additional ABI contracts in index.ts for improved contract integration --- packages/contracts/src/contracts/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/contracts/src/contracts/index.ts b/packages/contracts/src/contracts/index.ts index 44a1f557..84096c2d 100644 --- a/packages/contracts/src/contracts/index.ts +++ b/packages/contracts/src/contracts/index.ts @@ -6,3 +6,12 @@ export { createPaymentTreasuryEntity } from "./payment-treasury"; export { createAllOrNothingEntity } from "./all-or-nothing"; export { createKeepWhatsRaisedEntity } from "./keep-whats-raised"; export { createItemRegistryEntity } from "./item-registry"; + +export { GLOBAL_PARAMS_ABI } from "./global-params/abi"; +export { CAMPAIGN_INFO_FACTORY_ABI } from "./campaign-info-factory/abi"; +export { CAMPAIGN_INFO_ABI } from "./campaign-info/abi"; +export { TREASURY_FACTORY_ABI } from "./treasury-factory/abi"; +export { PAYMENT_TREASURY_ABI } from "./payment-treasury/abi"; +export { ALL_OR_NOTHING_ABI } from "./all-or-nothing/abi"; +export { KEEP_WHATS_RAISED_ABI } from "./keep-whats-raised/abi"; +export { ITEM_REGISTRY_ABI } from "./item-registry/abi"; From 5f2d63f489ad8fe95732d5299039b409a7026bda Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:46:27 +0600 Subject: [PATCH 07/86] feat: enhance simulation methods to return SimulationResult for better error handling and response management --- .../src/contracts/all-or-nothing/simulate.ts | 77 +++++++++------- .../src/contracts/all-or-nothing/types.ts | 62 ++++++------- .../campaign-info-factory/simulate.ts | 22 +++-- .../contracts/campaign-info-factory/types.ts | 18 ++-- .../src/contracts/campaign-info/simulate.ts | 72 +++++++++------ .../src/contracts/campaign-info/types.ts | 58 ++++++------ .../src/contracts/global-params/simulate.ts | 54 +++++++---- .../src/contracts/global-params/types.ts | 66 +++++++------- .../src/contracts/item-registry/simulate.ts | 12 +-- .../src/contracts/item-registry/types.ts | 10 +-- .../contracts/keep-whats-raised/simulate.ts | 68 +++++++++----- .../src/contracts/keep-whats-raised/types.ts | 90 +++++++++---------- .../contracts/payment-treasury/simulate.ts | 47 ++++++---- .../src/contracts/payment-treasury/types.ts | 62 ++++++------- .../contracts/treasury-factory/simulate.ts | 27 +++--- .../src/contracts/treasury-factory/types.ts | 22 ++--- 16 files changed, 430 insertions(+), 337 deletions(-) diff --git a/packages/contracts/src/contracts/all-or-nothing/simulate.ts b/packages/contracts/src/contracts/all-or-nothing/simulate.ts index 762f1286..617b90f0 100644 --- a/packages/contracts/src/contracts/all-or-nothing/simulate.ts +++ b/packages/contracts/src/contracts/all-or-nothing/simulate.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { ALL_OR_NOTHING_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { AllOrNothingSimulate } from "./types"; import type { TieredReward } from "../../types/structs"; import type { CallSignerOptions } from "../../client/types"; @@ -25,9 +25,9 @@ export function createAllOrNothingSimulate( const contract = { address, abi: ALL_OR_NOTHING_ABI } as const; return { - async pauseTreasury(message: Hex, options?: CallSignerOptions): Promise { + async pauseTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -36,10 +36,11 @@ export function createAllOrNothingSimulate( args: [message], }), ); + return toSimulationResult(response); }, - async unpauseTreasury(message: Hex, options?: CallSignerOptions): Promise { + async unpauseTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -48,10 +49,11 @@ export function createAllOrNothingSimulate( args: [message], }), ); + return toSimulationResult(response); }, - async cancelTreasury(message: Hex, options?: CallSignerOptions): Promise { + async cancelTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -60,10 +62,11 @@ export function createAllOrNothingSimulate( args: [message], }), ); + return toSimulationResult(response); }, - async addRewards(rewardNames: readonly Hex[], rewards: readonly TieredReward[], options?: CallSignerOptions): Promise { + async addRewards(rewardNames: readonly Hex[], rewards: readonly TieredReward[], options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -72,10 +75,11 @@ export function createAllOrNothingSimulate( args: [[...rewardNames], [...rewards]], }), ); + return toSimulationResult(response); }, - async removeReward(rewardName: Hex, options?: CallSignerOptions): Promise { + async removeReward(rewardName: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -84,10 +88,11 @@ export function createAllOrNothingSimulate( args: [rewardName], }), ); + return toSimulationResult(response); }, - async pledgeForAReward(backer: Address, pledgeToken: Address, shippingFee: bigint, rewardNames: readonly Hex[], options?: CallSignerOptions): Promise { + async pledgeForAReward(backer: Address, pledgeToken: Address, shippingFee: bigint, rewardNames: readonly Hex[], options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -96,10 +101,11 @@ export function createAllOrNothingSimulate( args: [backer, pledgeToken, shippingFee, [...rewardNames]], }), ); + return toSimulationResult(response); }, - async pledgeWithoutAReward(backer: Address, pledgeToken: Address, pledgeAmount: bigint, options?: CallSignerOptions): Promise { + async pledgeWithoutAReward(backer: Address, pledgeToken: Address, pledgeAmount: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -108,10 +114,11 @@ export function createAllOrNothingSimulate( args: [backer, pledgeToken, pledgeAmount], }), ); + return toSimulationResult(response); }, - async claimRefund(tokenId: bigint, options?: CallSignerOptions): Promise { + async claimRefund(tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -120,10 +127,11 @@ export function createAllOrNothingSimulate( args: [tokenId], }), ); + return toSimulationResult(response); }, - async disburseFees(options?: CallSignerOptions): Promise { + async disburseFees(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -132,10 +140,11 @@ export function createAllOrNothingSimulate( args: [], }), ); + return toSimulationResult(response); }, - async withdraw(options?: CallSignerOptions): Promise { + async withdraw(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -144,10 +153,11 @@ export function createAllOrNothingSimulate( args: [], }), ); + return toSimulationResult(response); }, - async burn(tokenId: bigint, options?: CallSignerOptions): Promise { + async burn(tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -156,10 +166,11 @@ export function createAllOrNothingSimulate( args: [tokenId], }), ); + return toSimulationResult(response); }, - async approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise { + async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -168,10 +179,11 @@ export function createAllOrNothingSimulate( args: [to, tokenId], }), ); + return toSimulationResult(response); }, - async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise { + async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -180,10 +192,11 @@ export function createAllOrNothingSimulate( args: [operator, approved], }), ); + return toSimulationResult(response); }, - async safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise { + async safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -192,10 +205,11 @@ export function createAllOrNothingSimulate( args: [from, to, tokenId], }), ); + return toSimulationResult(response); }, - async transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise { + async transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -204,6 +218,7 @@ export function createAllOrNothingSimulate( args: [from, to, tokenId], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/all-or-nothing/types.ts b/packages/contracts/src/contracts/all-or-nothing/types.ts index 9e63cf66..d30fbdc0 100644 --- a/packages/contracts/src/contracts/all-or-nothing/types.ts +++ b/packages/contracts/src/contracts/all-or-nothing/types.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from "../../lib"; import type { TieredReward } from "../../types/structs"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for an AllOrNothing treasury contract instance. */ @@ -75,36 +75,36 @@ export interface AllOrNothingWrites { /** Simulate counterparts for AllOrNothing write methods. */ export interface AllOrNothingSimulate { - /** Simulates pauseTreasury; throws a typed error on revert. */ - pauseTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates unpauseTreasury; throws a typed error on revert. */ - unpauseTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates cancelTreasury; throws a typed error on revert. */ - cancelTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates addRewards; throws a typed error on revert. */ - addRewards(rewardNames: readonly Hex[], rewards: readonly TieredReward[], options?: CallSignerOptions): Promise; - /** Simulates removeReward; throws a typed error on revert. */ - removeReward(rewardName: Hex, options?: CallSignerOptions): Promise; - /** Simulates pledgeForAReward; throws a typed error on revert. */ - pledgeForAReward(backer: Address, pledgeToken: Address, shippingFee: bigint, rewardNames: readonly Hex[], options?: CallSignerOptions): Promise; - /** Simulates pledgeWithoutAReward; throws a typed error on revert. */ - pledgeWithoutAReward(backer: Address, pledgeToken: Address, pledgeAmount: bigint, options?: CallSignerOptions): Promise; - /** Simulates claimRefund; throws a typed error on revert. */ - claimRefund(tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates disburseFees; throws a typed error on revert. */ - disburseFees(options?: CallSignerOptions): Promise; - /** Simulates withdraw; throws a typed error on revert. */ - withdraw(options?: CallSignerOptions): Promise; - /** Simulates burn; throws a typed error on revert. */ - burn(tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates approve; throws a typed error on revert. */ - approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates setApprovalForAll; throws a typed error on revert. */ - setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; - /** Simulates safeTransferFrom; throws a typed error on revert. */ - safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates transferFrom; throws a typed error on revert. */ - transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates pauseTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + pauseTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates unpauseTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + unpauseTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates cancelTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + cancelTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates addRewards; returns a SimulationResult on success, throws a typed error on revert. */ + addRewards(rewardNames: readonly Hex[], rewards: readonly TieredReward[], options?: CallSignerOptions): Promise; + /** Simulates removeReward; returns a SimulationResult on success, throws a typed error on revert. */ + removeReward(rewardName: Hex, options?: CallSignerOptions): Promise; + /** Simulates pledgeForAReward; returns a SimulationResult on success, throws a typed error on revert. */ + pledgeForAReward(backer: Address, pledgeToken: Address, shippingFee: bigint, rewardNames: readonly Hex[], options?: CallSignerOptions): Promise; + /** Simulates pledgeWithoutAReward; returns a SimulationResult on success, throws a typed error on revert. */ + pledgeWithoutAReward(backer: Address, pledgeToken: Address, pledgeAmount: bigint, options?: CallSignerOptions): Promise; + /** Simulates claimRefund; returns a SimulationResult on success, throws a typed error on revert. */ + claimRefund(tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates disburseFees; returns a SimulationResult on success, throws a typed error on revert. */ + disburseFees(options?: CallSignerOptions): Promise; + /** Simulates withdraw; returns a SimulationResult on success, throws a typed error on revert. */ + withdraw(options?: CallSignerOptions): Promise; + /** Simulates burn; returns a SimulationResult on success, throws a typed error on revert. */ + burn(tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates approve; returns a SimulationResult on success, throws a typed error on revert. */ + approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates setApprovalForAll; returns a SimulationResult on success, throws a typed error on revert. */ + setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; + /** Simulates safeTransferFrom; returns a SimulationResult on success, throws a typed error on revert. */ + safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates transferFrom; returns a SimulationResult on success, throws a typed error on revert. */ + transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; } /** Event helpers for an AllOrNothing treasury contract instance. */ diff --git a/packages/contracts/src/contracts/campaign-info-factory/simulate.ts b/packages/contracts/src/contracts/campaign-info-factory/simulate.ts index 81fee8e7..78703e6f 100644 --- a/packages/contracts/src/contracts/campaign-info-factory/simulate.ts +++ b/packages/contracts/src/contracts/campaign-info-factory/simulate.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { CAMPAIGN_INFO_FACTORY_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { CampaignInfoFactorySimulate } from "./types"; import type { CreateCampaignParams } from "../../types/params"; import type { CallSignerOptions } from "../../client/types"; @@ -25,9 +25,9 @@ export function createCampaignInfoFactorySimulate( const contract = { address, abi: CAMPAIGN_INFO_FACTORY_ABI } as const; return { - async createCampaign(params: CreateCampaignParams, options?: CallSignerOptions): Promise { + async createCampaign(params: CreateCampaignParams, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -52,10 +52,11 @@ export function createCampaignInfoFactorySimulate( ], }), ); + return toSimulationResult(response); }, - async updateImplementation(newImplementation: Address, options?: CallSignerOptions): Promise { + async updateImplementation(newImplementation: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -64,10 +65,11 @@ export function createCampaignInfoFactorySimulate( args: [newImplementation], }), ); + return toSimulationResult(response); }, - async transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise { + async transferOwnership(newOwner: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -76,10 +78,11 @@ export function createCampaignInfoFactorySimulate( args: [newOwner], }), ); + return toSimulationResult(response); }, - async renounceOwnership(options?: CallSignerOptions): Promise { + async renounceOwnership(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -88,6 +91,7 @@ export function createCampaignInfoFactorySimulate( args: [], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/campaign-info-factory/types.ts b/packages/contracts/src/contracts/campaign-info-factory/types.ts index bb4faac2..d2970f91 100644 --- a/packages/contracts/src/contracts/campaign-info-factory/types.ts +++ b/packages/contracts/src/contracts/campaign-info-factory/types.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from "../../lib"; import type { CreateCampaignParams } from "../../types/params"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for a CampaignInfoFactory contract instance. */ @@ -31,14 +31,14 @@ export interface CampaignInfoFactoryWrites { /** Simulate counterparts for CampaignInfoFactory write methods. */ export interface CampaignInfoFactorySimulate { - /** Simulates createCampaign; throws a typed error on revert. */ - createCampaign(params: CreateCampaignParams, options?: CallSignerOptions): Promise; - /** Simulates updateImplementation; throws a typed error on revert. */ - updateImplementation(newImplementation: Address, options?: CallSignerOptions): Promise; - /** Simulates transferOwnership; throws a typed error on revert. */ - transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; - /** Simulates renounceOwnership; throws a typed error on revert. */ - renounceOwnership(options?: CallSignerOptions): Promise; + /** Simulates createCampaign; returns a SimulationResult on success, throws a typed error on revert. */ + createCampaign(params: CreateCampaignParams, options?: CallSignerOptions): Promise; + /** Simulates updateImplementation; returns a SimulationResult on success, throws a typed error on revert. */ + updateImplementation(newImplementation: Address, options?: CallSignerOptions): Promise; + /** Simulates transferOwnership; returns a SimulationResult on success, throws a typed error on revert. */ + transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; + /** Simulates renounceOwnership; returns a SimulationResult on success, throws a typed error on revert. */ + renounceOwnership(options?: CallSignerOptions): Promise; } /** Event helpers for a CampaignInfoFactory contract instance. */ diff --git a/packages/contracts/src/contracts/campaign-info/simulate.ts b/packages/contracts/src/contracts/campaign-info/simulate.ts index 55ae20f9..7eda92a4 100644 --- a/packages/contracts/src/contracts/campaign-info/simulate.ts +++ b/packages/contracts/src/contracts/campaign-info/simulate.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { CAMPAIGN_INFO_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { CampaignInfoSimulate } from "./types"; import type { CallSignerOptions } from "../../client/types"; @@ -24,9 +24,9 @@ export function createCampaignInfoSimulate( const contract = { address, abi: CAMPAIGN_INFO_ABI } as const; return { - async updateDeadline(deadline: bigint, options?: CallSignerOptions): Promise { + async updateDeadline(deadline: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -35,10 +35,11 @@ export function createCampaignInfoSimulate( args: [deadline], }), ); + return toSimulationResult(response); }, - async updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions): Promise { + async updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -47,10 +48,11 @@ export function createCampaignInfoSimulate( args: [goalAmount], }), ); + return toSimulationResult(response); }, - async updateLaunchTime(launchTime: bigint, options?: CallSignerOptions): Promise { + async updateLaunchTime(launchTime: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -59,10 +61,11 @@ export function createCampaignInfoSimulate( args: [launchTime], }), ); + return toSimulationResult(response); }, - async updateSelectedPlatform(platformHash: Hex, selection: boolean, platformDataKey: readonly Hex[], platformDataValue: readonly Hex[], options?: CallSignerOptions): Promise { + async updateSelectedPlatform(platformHash: Hex, selection: boolean, platformDataKey: readonly Hex[], platformDataValue: readonly Hex[], options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -71,10 +74,11 @@ export function createCampaignInfoSimulate( args: [platformHash, selection, [...platformDataKey], [...platformDataValue]], }), ); + return toSimulationResult(response); }, - async setImageURI(newImageURI: string, options?: CallSignerOptions): Promise { + async setImageURI(newImageURI: string, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -83,10 +87,11 @@ export function createCampaignInfoSimulate( args: [newImageURI], }), ); + return toSimulationResult(response); }, - async updateContractURI(newContractURI: string, options?: CallSignerOptions): Promise { + async updateContractURI(newContractURI: string, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -95,10 +100,11 @@ export function createCampaignInfoSimulate( args: [newContractURI], }), ); + return toSimulationResult(response); }, - async mintNFTForPledge(backer: Address, reward: Hex, tokenAddress: Address, amount: bigint, shippingFee: bigint, tipAmount: bigint, options?: CallSignerOptions): Promise { + async mintNFTForPledge(backer: Address, reward: Hex, tokenAddress: Address, amount: bigint, shippingFee: bigint, tipAmount: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -107,10 +113,11 @@ export function createCampaignInfoSimulate( args: [backer, reward, tokenAddress, amount, shippingFee, tipAmount], }), ); + return toSimulationResult(response); }, - async burn(tokenId: bigint, options?: CallSignerOptions): Promise { + async burn(tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -119,10 +126,11 @@ export function createCampaignInfoSimulate( args: [tokenId], }), ); + return toSimulationResult(response); }, - async pauseCampaign(message: Hex, options?: CallSignerOptions): Promise { + async pauseCampaign(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -131,10 +139,11 @@ export function createCampaignInfoSimulate( args: [message], }), ); + return toSimulationResult(response); }, - async unpauseCampaign(message: Hex, options?: CallSignerOptions): Promise { + async unpauseCampaign(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -143,10 +152,11 @@ export function createCampaignInfoSimulate( args: [message], }), ); + return toSimulationResult(response); }, - async cancelCampaign(message: Hex, options?: CallSignerOptions): Promise { + async cancelCampaign(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -155,10 +165,11 @@ export function createCampaignInfoSimulate( args: [message], }), ); + return toSimulationResult(response); }, - async setPlatformInfo(platformBytes: Hex, platformTreasuryAddress: Address, options?: CallSignerOptions): Promise { + async setPlatformInfo(platformBytes: Hex, platformTreasuryAddress: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -167,10 +178,11 @@ export function createCampaignInfoSimulate( args: [platformBytes, platformTreasuryAddress], }), ); + return toSimulationResult(response); }, - async transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise { + async transferOwnership(newOwner: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -179,10 +191,11 @@ export function createCampaignInfoSimulate( args: [newOwner], }), ); + return toSimulationResult(response); }, - async renounceOwnership(options?: CallSignerOptions): Promise { + async renounceOwnership(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -191,6 +204,7 @@ export function createCampaignInfoSimulate( args: [], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/campaign-info/types.ts b/packages/contracts/src/contracts/campaign-info/types.ts index 32c0bd8a..ee2da5e6 100644 --- a/packages/contracts/src/contracts/campaign-info/types.ts +++ b/packages/contracts/src/contracts/campaign-info/types.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from "../../lib"; import type { LineItemTypeInfo, CampaignConfig } from "../../types/structs"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for a CampaignInfo contract instance. */ @@ -101,34 +101,34 @@ export interface CampaignInfoWrites { /** Simulate counterparts for CampaignInfo write methods. */ export interface CampaignInfoSimulate { - /** Simulates updateDeadline; throws a typed error on revert. */ - updateDeadline(deadline: bigint, options?: CallSignerOptions): Promise; - /** Simulates updateGoalAmount; throws a typed error on revert. */ - updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions): Promise; - /** Simulates updateLaunchTime; throws a typed error on revert. */ - updateLaunchTime(launchTime: bigint, options?: CallSignerOptions): Promise; - /** Simulates updateSelectedPlatform; throws a typed error on revert. */ - updateSelectedPlatform(platformHash: Hex, selection: boolean, platformDataKey: readonly Hex[], platformDataValue: readonly Hex[], options?: CallSignerOptions): Promise; - /** Simulates setImageURI; throws a typed error on revert. */ - setImageURI(newImageURI: string, options?: CallSignerOptions): Promise; - /** Simulates updateContractURI; throws a typed error on revert. */ - updateContractURI(newContractURI: string, options?: CallSignerOptions): Promise; - /** Simulates mintNFTForPledge; throws a typed error on revert. */ - mintNFTForPledge(backer: Address, reward: Hex, tokenAddress: Address, amount: bigint, shippingFee: bigint, tipAmount: bigint, options?: CallSignerOptions): Promise; - /** Simulates burn; throws a typed error on revert. */ - burn(tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates pauseCampaign; throws a typed error on revert. */ - pauseCampaign(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates unpauseCampaign; throws a typed error on revert. */ - unpauseCampaign(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates cancelCampaign; throws a typed error on revert. */ - cancelCampaign(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates setPlatformInfo; throws a typed error on revert. */ - setPlatformInfo(platformBytes: Hex, platformTreasuryAddress: Address, options?: CallSignerOptions): Promise; - /** Simulates transferOwnership; throws a typed error on revert. */ - transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; - /** Simulates renounceOwnership; throws a typed error on revert. */ - renounceOwnership(options?: CallSignerOptions): Promise; + /** Simulates updateDeadline; returns a SimulationResult on success, throws a typed error on revert. */ + updateDeadline(deadline: bigint, options?: CallSignerOptions): Promise; + /** Simulates updateGoalAmount; returns a SimulationResult on success, throws a typed error on revert. */ + updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions): Promise; + /** Simulates updateLaunchTime; returns a SimulationResult on success, throws a typed error on revert. */ + updateLaunchTime(launchTime: bigint, options?: CallSignerOptions): Promise; + /** Simulates updateSelectedPlatform; returns a SimulationResult on success, throws a typed error on revert. */ + updateSelectedPlatform(platformHash: Hex, selection: boolean, platformDataKey: readonly Hex[], platformDataValue: readonly Hex[], options?: CallSignerOptions): Promise; + /** Simulates setImageURI; returns a SimulationResult on success, throws a typed error on revert. */ + setImageURI(newImageURI: string, options?: CallSignerOptions): Promise; + /** Simulates updateContractURI; returns a SimulationResult on success, throws a typed error on revert. */ + updateContractURI(newContractURI: string, options?: CallSignerOptions): Promise; + /** Simulates mintNFTForPledge; returns a SimulationResult on success, throws a typed error on revert. */ + mintNFTForPledge(backer: Address, reward: Hex, tokenAddress: Address, amount: bigint, shippingFee: bigint, tipAmount: bigint, options?: CallSignerOptions): Promise; + /** Simulates burn; returns a SimulationResult on success, throws a typed error on revert. */ + burn(tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates pauseCampaign; returns a SimulationResult on success, throws a typed error on revert. */ + pauseCampaign(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates unpauseCampaign; returns a SimulationResult on success, throws a typed error on revert. */ + unpauseCampaign(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates cancelCampaign; returns a SimulationResult on success, throws a typed error on revert. */ + cancelCampaign(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates setPlatformInfo; returns a SimulationResult on success, throws a typed error on revert. */ + setPlatformInfo(platformBytes: Hex, platformTreasuryAddress: Address, options?: CallSignerOptions): Promise; + /** Simulates transferOwnership; returns a SimulationResult on success, throws a typed error on revert. */ + transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; + /** Simulates renounceOwnership; returns a SimulationResult on success, throws a typed error on revert. */ + renounceOwnership(options?: CallSignerOptions): Promise; } /** Event helpers for a CampaignInfo contract instance. */ diff --git a/packages/contracts/src/contracts/global-params/simulate.ts b/packages/contracts/src/contracts/global-params/simulate.ts index 2fc0e7d3..e5f21950 100644 --- a/packages/contracts/src/contracts/global-params/simulate.ts +++ b/packages/contracts/src/contracts/global-params/simulate.ts @@ -1,14 +1,14 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { GLOBAL_PARAMS_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { GlobalParamsSimulate } from "./types"; import type { CallSignerOptions } from "../../client/types"; /** * Builds simulate methods for GlobalParams write calls. - * Each method calls simulateContract against the current chain state and throws a typed - * SDK error on revert, decoded via simulateWithErrorDecode. + * Each method calls simulateContract against the current chain state and returns a + * SimulationResult, or throws a typed SDK error on revert (decoded via simulateWithErrorDecode). * @param address - Deployed GlobalParams contract address * @param publicClient - Viem PublicClient used to call simulateContract * @param walletClient - Viem WalletClient used to resolve the account for simulation @@ -26,7 +26,7 @@ export function createGlobalParamsSimulate( return { async enlistPlatform(platformHash: Hex, platformAdminAddress: Address, platformFeePercent: bigint, platformAdapter: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -35,10 +35,11 @@ export function createGlobalParamsSimulate( args: [platformHash, platformAdminAddress, platformFeePercent, platformAdapter], }), ); + return toSimulationResult(response); }, async delistPlatform(platformBytes: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -47,10 +48,11 @@ export function createGlobalParamsSimulate( args: [platformBytes], }), ); + return toSimulationResult(response); }, async updatePlatformAdminAddress(platformBytes: Hex, platformAdminAddress: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -59,10 +61,11 @@ export function createGlobalParamsSimulate( args: [platformBytes, platformAdminAddress], }), ); + return toSimulationResult(response); }, async updatePlatformClaimDelay(platformBytes: Hex, claimDelay: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -71,10 +74,11 @@ export function createGlobalParamsSimulate( args: [platformBytes, claimDelay], }), ); + return toSimulationResult(response); }, async updateProtocolAdminAddress(protocolAdminAddress: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -83,10 +87,11 @@ export function createGlobalParamsSimulate( args: [protocolAdminAddress], }), ); + return toSimulationResult(response); }, async updateProtocolFeePercent(protocolFeePercent: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -95,10 +100,11 @@ export function createGlobalParamsSimulate( args: [protocolFeePercent], }), ); + return toSimulationResult(response); }, async setPlatformAdapter(platformBytes: Hex, platformAdapter: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -107,10 +113,11 @@ export function createGlobalParamsSimulate( args: [platformBytes, platformAdapter], }), ); + return toSimulationResult(response); }, async setPlatformLineItemType(platformHash: Hex, typeId: Hex, label: string, countsTowardGoal: boolean, applyProtocolFee: boolean, canRefund: boolean, instantTransfer: boolean, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -119,10 +126,11 @@ export function createGlobalParamsSimulate( args: [platformHash, typeId, label, countsTowardGoal, applyProtocolFee, canRefund, instantTransfer], }), ); + return toSimulationResult(response); }, async removePlatformLineItemType(platformHash: Hex, typeId: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -131,10 +139,11 @@ export function createGlobalParamsSimulate( args: [platformHash, typeId], }), ); + return toSimulationResult(response); }, async addTokenToCurrency(currency: Hex, token: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -143,10 +152,11 @@ export function createGlobalParamsSimulate( args: [currency, token], }), ); + return toSimulationResult(response); }, async removeTokenFromCurrency(currency: Hex, token: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -155,10 +165,11 @@ export function createGlobalParamsSimulate( args: [currency, token], }), ); + return toSimulationResult(response); }, async addPlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -167,10 +178,11 @@ export function createGlobalParamsSimulate( args: [platformBytes, platformDataKey], }), ); + return toSimulationResult(response); }, async removePlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -179,10 +191,11 @@ export function createGlobalParamsSimulate( args: [platformBytes, platformDataKey], }), ); + return toSimulationResult(response); }, async addToRegistry(key: Hex, value: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -191,10 +204,11 @@ export function createGlobalParamsSimulate( args: [key, value], }), ); + return toSimulationResult(response); }, async transferOwnership(newOwner: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -203,10 +217,11 @@ export function createGlobalParamsSimulate( args: [newOwner], }), ); + return toSimulationResult(response); }, async renounceOwnership(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -215,6 +230,7 @@ export function createGlobalParamsSimulate( args: [], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/global-params/types.ts b/packages/contracts/src/contracts/global-params/types.ts index c23d9ac2..4cde2327 100644 --- a/packages/contracts/src/contracts/global-params/types.ts +++ b/packages/contracts/src/contracts/global-params/types.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from "../../lib"; import type { LineItemTypeInfo } from "../../types/structs"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for a GlobalParams contract instance. */ @@ -73,38 +73,38 @@ export interface GlobalParamsWrites { /** Simulate counterparts for GlobalParams write methods. */ export interface GlobalParamsSimulate { - /** Simulates enlistPlatform; throws a typed error on revert. */ - enlistPlatform(platformHash: Hex, platformAdminAddress: Address, platformFeePercent: bigint, platformAdapter: Address, options?: CallSignerOptions): Promise; - /** Simulates delistPlatform; throws a typed error on revert. */ - delistPlatform(platformBytes: Hex, options?: CallSignerOptions): Promise; - /** Simulates updatePlatformAdminAddress; throws a typed error on revert. */ - updatePlatformAdminAddress(platformBytes: Hex, platformAdminAddress: Address, options?: CallSignerOptions): Promise; - /** Simulates updatePlatformClaimDelay; throws a typed error on revert. */ - updatePlatformClaimDelay(platformBytes: Hex, claimDelay: bigint, options?: CallSignerOptions): Promise; - /** Simulates updateProtocolAdminAddress; throws a typed error on revert. */ - updateProtocolAdminAddress(protocolAdminAddress: Address, options?: CallSignerOptions): Promise; - /** Simulates updateProtocolFeePercent; throws a typed error on revert. */ - updateProtocolFeePercent(protocolFeePercent: bigint, options?: CallSignerOptions): Promise; - /** Simulates setPlatformAdapter; throws a typed error on revert. */ - setPlatformAdapter(platformBytes: Hex, platformAdapter: Address, options?: CallSignerOptions): Promise; - /** Simulates setPlatformLineItemType; throws a typed error on revert. */ - setPlatformLineItemType(platformHash: Hex, typeId: Hex, label: string, countsTowardGoal: boolean, applyProtocolFee: boolean, canRefund: boolean, instantTransfer: boolean, options?: CallSignerOptions): Promise; - /** Simulates removePlatformLineItemType; throws a typed error on revert. */ - removePlatformLineItemType(platformHash: Hex, typeId: Hex, options?: CallSignerOptions): Promise; - /** Simulates addTokenToCurrency; throws a typed error on revert. */ - addTokenToCurrency(currency: Hex, token: Address, options?: CallSignerOptions): Promise; - /** Simulates removeTokenFromCurrency; throws a typed error on revert. */ - removeTokenFromCurrency(currency: Hex, token: Address, options?: CallSignerOptions): Promise; - /** Simulates addPlatformData; throws a typed error on revert. */ - addPlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions): Promise; - /** Simulates removePlatformData; throws a typed error on revert. */ - removePlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions): Promise; - /** Simulates addToRegistry; throws a typed error on revert. */ - addToRegistry(key: Hex, value: Hex, options?: CallSignerOptions): Promise; - /** Simulates transferOwnership; throws a typed error on revert. */ - transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; - /** Simulates renounceOwnership; throws a typed error on revert. */ - renounceOwnership(options?: CallSignerOptions): Promise; + /** Simulates enlistPlatform; returns a SimulationResult. */ + enlistPlatform(platformHash: Hex, platformAdminAddress: Address, platformFeePercent: bigint, platformAdapter: Address, options?: CallSignerOptions): Promise; + /** Simulates delistPlatform; returns a SimulationResult. */ + delistPlatform(platformBytes: Hex, options?: CallSignerOptions): Promise; + /** Simulates updatePlatformAdminAddress; returns a SimulationResult. */ + updatePlatformAdminAddress(platformBytes: Hex, platformAdminAddress: Address, options?: CallSignerOptions): Promise; + /** Simulates updatePlatformClaimDelay; returns a SimulationResult. */ + updatePlatformClaimDelay(platformBytes: Hex, claimDelay: bigint, options?: CallSignerOptions): Promise; + /** Simulates updateProtocolAdminAddress; returns a SimulationResult. */ + updateProtocolAdminAddress(protocolAdminAddress: Address, options?: CallSignerOptions): Promise; + /** Simulates updateProtocolFeePercent; returns a SimulationResult. */ + updateProtocolFeePercent(protocolFeePercent: bigint, options?: CallSignerOptions): Promise; + /** Simulates setPlatformAdapter; returns a SimulationResult. */ + setPlatformAdapter(platformBytes: Hex, platformAdapter: Address, options?: CallSignerOptions): Promise; + /** Simulates setPlatformLineItemType; returns a SimulationResult. */ + setPlatformLineItemType(platformHash: Hex, typeId: Hex, label: string, countsTowardGoal: boolean, applyProtocolFee: boolean, canRefund: boolean, instantTransfer: boolean, options?: CallSignerOptions): Promise; + /** Simulates removePlatformLineItemType; returns a SimulationResult. */ + removePlatformLineItemType(platformHash: Hex, typeId: Hex, options?: CallSignerOptions): Promise; + /** Simulates addTokenToCurrency; returns a SimulationResult. */ + addTokenToCurrency(currency: Hex, token: Address, options?: CallSignerOptions): Promise; + /** Simulates removeTokenFromCurrency; returns a SimulationResult. */ + removeTokenFromCurrency(currency: Hex, token: Address, options?: CallSignerOptions): Promise; + /** Simulates addPlatformData; returns a SimulationResult. */ + addPlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions): Promise; + /** Simulates removePlatformData; returns a SimulationResult. */ + removePlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions): Promise; + /** Simulates addToRegistry; returns a SimulationResult. */ + addToRegistry(key: Hex, value: Hex, options?: CallSignerOptions): Promise; + /** Simulates transferOwnership; returns a SimulationResult. */ + transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; + /** Simulates renounceOwnership; returns a SimulationResult. */ + renounceOwnership(options?: CallSignerOptions): Promise; } /** Event helpers for a GlobalParams contract instance. */ diff --git a/packages/contracts/src/contracts/item-registry/simulate.ts b/packages/contracts/src/contracts/item-registry/simulate.ts index df16d38c..d68cf261 100644 --- a/packages/contracts/src/contracts/item-registry/simulate.ts +++ b/packages/contracts/src/contracts/item-registry/simulate.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { ITEM_REGISTRY_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { ItemRegistrySimulate } from "./types"; import type { Item } from "../../types/structs"; import type { CallSignerOptions } from "../../client/types"; @@ -25,9 +25,9 @@ export function createItemRegistrySimulate( const contract = { address, abi: ITEM_REGISTRY_ABI } as const; return { - async addItem(itemId: Hex, item: Item, options?: CallSignerOptions): Promise { + async addItem(itemId: Hex, item: Item, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -46,10 +46,11 @@ export function createItemRegistrySimulate( ], }), ); + return toSimulationResult(response); }, - async addItemsBatch(itemIds: readonly Hex[], items: readonly Item[], options?: CallSignerOptions): Promise { + async addItemsBatch(itemIds: readonly Hex[], items: readonly Item[], options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -68,6 +69,7 @@ export function createItemRegistrySimulate( ], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/item-registry/types.ts b/packages/contracts/src/contracts/item-registry/types.ts index 8bf79d2f..b8b8747c 100644 --- a/packages/contracts/src/contracts/item-registry/types.ts +++ b/packages/contracts/src/contracts/item-registry/types.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from "../../lib"; import type { Item } from "../../types/structs"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for ItemRegistry. */ @@ -19,10 +19,10 @@ export interface ItemRegistryWrites { /** Simulate counterparts for ItemRegistry write methods. */ export interface ItemRegistrySimulate { - /** Simulates addItem; throws a typed error on revert. */ - addItem(itemId: Hex, item: Item, options?: CallSignerOptions): Promise; - /** Simulates addItemsBatch; throws a typed error on revert. */ - addItemsBatch(itemIds: readonly Hex[], items: readonly Item[], options?: CallSignerOptions): Promise; + /** Simulates addItem; returns a SimulationResult on success, throws a typed error on revert. */ + addItem(itemId: Hex, item: Item, options?: CallSignerOptions): Promise; + /** Simulates addItemsBatch; returns a SimulationResult on success, throws a typed error on revert. */ + addItemsBatch(itemIds: readonly Hex[], items: readonly Item[], options?: CallSignerOptions): Promise; } /** Event helpers for ItemRegistry. */ diff --git a/packages/contracts/src/contracts/keep-whats-raised/simulate.ts b/packages/contracts/src/contracts/keep-whats-raised/simulate.ts index 47696f77..1afd4e63 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/simulate.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/simulate.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { KEEP_WHATS_RAISED_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { KeepWhatsRaisedSimulate } from "./types"; import type { CallSignerOptions } from "../../client/types"; import type { TieredReward, CampaignData } from "../../types/structs"; @@ -32,7 +32,7 @@ export function createKeepWhatsRaisedSimulate( return { async pauseTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -41,10 +41,11 @@ export function createKeepWhatsRaisedSimulate( args: [message], }), ); + return toSimulationResult(response); }, async unpauseTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -53,10 +54,11 @@ export function createKeepWhatsRaisedSimulate( args: [message], }), ); + return toSimulationResult(response); }, async cancelTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -65,6 +67,7 @@ export function createKeepWhatsRaisedSimulate( args: [message], }), ); + return toSimulationResult(response); }, async configureTreasury( config: KeepWhatsRaisedConfig, @@ -74,7 +77,7 @@ export function createKeepWhatsRaisedSimulate( options?: CallSignerOptions, ) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -107,10 +110,11 @@ export function createKeepWhatsRaisedSimulate( ], }), ); + return toSimulationResult(response); }, async addRewards(rewardNames: readonly Hex[], rewards: readonly TieredReward[], options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -128,10 +132,11 @@ export function createKeepWhatsRaisedSimulate( ], }), ); + return toSimulationResult(response); }, async removeReward(rewardName: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -140,10 +145,11 @@ export function createKeepWhatsRaisedSimulate( args: [rewardName], }), ); + return toSimulationResult(response); }, async approveWithdrawal(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -152,10 +158,11 @@ export function createKeepWhatsRaisedSimulate( args: [], }), ); + return toSimulationResult(response); }, async setPaymentGatewayFee(pledgeId: Hex, fee: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -164,6 +171,7 @@ export function createKeepWhatsRaisedSimulate( args: [pledgeId, fee], }), ); + return toSimulationResult(response); }, async setFeeAndPledge( pledgeId: Hex, @@ -177,7 +185,7 @@ export function createKeepWhatsRaisedSimulate( options?: CallSignerOptions, ) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -186,6 +194,7 @@ export function createKeepWhatsRaisedSimulate( args: [pledgeId, backer, pledgeToken, pledgeAmount, tip, fee, [...reward], isPledgeForAReward], }), ); + return toSimulationResult(response); }, async pledgeForAReward( pledgeId: Hex, @@ -196,7 +205,7 @@ export function createKeepWhatsRaisedSimulate( options?: CallSignerOptions, ) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -205,6 +214,7 @@ export function createKeepWhatsRaisedSimulate( args: [pledgeId, backer, pledgeToken, tip, [...rewardNames]], }), ); + return toSimulationResult(response); }, async pledgeWithoutAReward( pledgeId: Hex, @@ -215,7 +225,7 @@ export function createKeepWhatsRaisedSimulate( options?: CallSignerOptions, ) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -224,10 +234,11 @@ export function createKeepWhatsRaisedSimulate( args: [pledgeId, backer, pledgeToken, pledgeAmount, tip], }), ); + return toSimulationResult(response); }, async claimRefund(tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -236,10 +247,11 @@ export function createKeepWhatsRaisedSimulate( args: [tokenId], }), ); + return toSimulationResult(response); }, async claimTip(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -248,10 +260,11 @@ export function createKeepWhatsRaisedSimulate( args: [], }), ); + return toSimulationResult(response); }, async claimFund(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -260,10 +273,11 @@ export function createKeepWhatsRaisedSimulate( args: [], }), ); + return toSimulationResult(response); }, async disburseFees(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -272,10 +286,11 @@ export function createKeepWhatsRaisedSimulate( args: [], }), ); + return toSimulationResult(response); }, async withdraw(token: Address, amount: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -284,10 +299,11 @@ export function createKeepWhatsRaisedSimulate( args: [token, amount], }), ); + return toSimulationResult(response); }, async updateDeadline(deadline: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -296,10 +312,11 @@ export function createKeepWhatsRaisedSimulate( args: [deadline], }), ); + return toSimulationResult(response); }, async updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -308,10 +325,11 @@ export function createKeepWhatsRaisedSimulate( args: [goalAmount], }), ); + return toSimulationResult(response); }, async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -320,10 +338,11 @@ export function createKeepWhatsRaisedSimulate( args: [to, tokenId], }), ); + return toSimulationResult(response); }, async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -332,10 +351,11 @@ export function createKeepWhatsRaisedSimulate( args: [operator, approved], }), ); + return toSimulationResult(response); }, async safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -344,10 +364,11 @@ export function createKeepWhatsRaisedSimulate( args: [from, to, tokenId], }), ); + return toSimulationResult(response); }, async transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -356,6 +377,7 @@ export function createKeepWhatsRaisedSimulate( args: [from, to, tokenId], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/keep-whats-raised/types.ts b/packages/contracts/src/contracts/keep-whats-raised/types.ts index cf395ac2..d0a557b6 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/types.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/types.ts @@ -1,7 +1,7 @@ import type { Address, Hex } from "../../lib"; import type { TieredReward, CampaignData } from "../../types/structs"; import type { KeepWhatsRaisedConfig, KeepWhatsRaisedFeeKeys, KeepWhatsRaisedFeeValues } from "../../types/params"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for KeepWhatsRaised treasury. */ @@ -134,29 +134,29 @@ export interface KeepWhatsRaisedWrites { /** Simulate counterparts for KeepWhatsRaised write methods. */ export interface KeepWhatsRaisedSimulate { - /** Simulates pauseTreasury; throws a typed error on revert. */ - pauseTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates unpauseTreasury; throws a typed error on revert. */ - unpauseTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates cancelTreasury; throws a typed error on revert. */ - cancelTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates configureTreasury; throws a typed error on revert. */ + /** Simulates pauseTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + pauseTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates unpauseTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + unpauseTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates cancelTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + cancelTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates configureTreasury; returns a SimulationResult on success, throws a typed error on revert. */ configureTreasury( config: KeepWhatsRaisedConfig, campaignData: CampaignData, feeKeys: KeepWhatsRaisedFeeKeys, feeValues: KeepWhatsRaisedFeeValues, options?: CallSignerOptions, - ): Promise; - /** Simulates addRewards; throws a typed error on revert. */ - addRewards(rewardNames: readonly Hex[], rewards: readonly TieredReward[], options?: CallSignerOptions): Promise; - /** Simulates removeReward; throws a typed error on revert. */ - removeReward(rewardName: Hex, options?: CallSignerOptions): Promise; - /** Simulates approveWithdrawal; throws a typed error on revert. */ - approveWithdrawal(options?: CallSignerOptions): Promise; - /** Simulates setPaymentGatewayFee; throws a typed error on revert. */ - setPaymentGatewayFee(pledgeId: Hex, fee: bigint, options?: CallSignerOptions): Promise; - /** Simulates setFeeAndPledge; throws a typed error on revert. */ + ): Promise; + /** Simulates addRewards; returns a SimulationResult on success, throws a typed error on revert. */ + addRewards(rewardNames: readonly Hex[], rewards: readonly TieredReward[], options?: CallSignerOptions): Promise; + /** Simulates removeReward; returns a SimulationResult on success, throws a typed error on revert. */ + removeReward(rewardName: Hex, options?: CallSignerOptions): Promise; + /** Simulates approveWithdrawal; returns a SimulationResult on success, throws a typed error on revert. */ + approveWithdrawal(options?: CallSignerOptions): Promise; + /** Simulates setPaymentGatewayFee; returns a SimulationResult on success, throws a typed error on revert. */ + setPaymentGatewayFee(pledgeId: Hex, fee: bigint, options?: CallSignerOptions): Promise; + /** Simulates setFeeAndPledge; returns a SimulationResult on success, throws a typed error on revert. */ setFeeAndPledge( pledgeId: Hex, backer: Address, @@ -167,8 +167,8 @@ export interface KeepWhatsRaisedSimulate { reward: readonly Hex[], isPledgeForAReward: boolean, options?: CallSignerOptions, - ): Promise; - /** Simulates pledgeForAReward; throws a typed error on revert. */ + ): Promise; + /** Simulates pledgeForAReward; returns a SimulationResult on success, throws a typed error on revert. */ pledgeForAReward( pledgeId: Hex, backer: Address, @@ -176,8 +176,8 @@ export interface KeepWhatsRaisedSimulate { tip: bigint, rewardNames: readonly Hex[], options?: CallSignerOptions, - ): Promise; - /** Simulates pledgeWithoutAReward; throws a typed error on revert. */ + ): Promise; + /** Simulates pledgeWithoutAReward; returns a SimulationResult on success, throws a typed error on revert. */ pledgeWithoutAReward( pledgeId: Hex, backer: Address, @@ -185,29 +185,29 @@ export interface KeepWhatsRaisedSimulate { pledgeAmount: bigint, tip: bigint, options?: CallSignerOptions, - ): Promise; - /** Simulates claimRefund; throws a typed error on revert. */ - claimRefund(tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates claimTip; throws a typed error on revert. */ - claimTip(options?: CallSignerOptions): Promise; - /** Simulates claimFund; throws a typed error on revert. */ - claimFund(options?: CallSignerOptions): Promise; - /** Simulates disburseFees; throws a typed error on revert. */ - disburseFees(options?: CallSignerOptions): Promise; - /** Simulates withdraw; throws a typed error on revert. */ - withdraw(token: Address, amount: bigint, options?: CallSignerOptions): Promise; - /** Simulates updateDeadline; throws a typed error on revert. */ - updateDeadline(deadline: bigint, options?: CallSignerOptions): Promise; - /** Simulates updateGoalAmount; throws a typed error on revert. */ - updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions): Promise; - /** Simulates approve; throws a typed error on revert. */ - approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates setApprovalForAll; throws a typed error on revert. */ - setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; - /** Simulates safeTransferFrom; throws a typed error on revert. */ - safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates transferFrom; throws a typed error on revert. */ - transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + ): Promise; + /** Simulates claimRefund; returns a SimulationResult on success, throws a typed error on revert. */ + claimRefund(tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates claimTip; returns a SimulationResult on success, throws a typed error on revert. */ + claimTip(options?: CallSignerOptions): Promise; + /** Simulates claimFund; returns a SimulationResult on success, throws a typed error on revert. */ + claimFund(options?: CallSignerOptions): Promise; + /** Simulates disburseFees; returns a SimulationResult on success, throws a typed error on revert. */ + disburseFees(options?: CallSignerOptions): Promise; + /** Simulates withdraw; returns a SimulationResult on success, throws a typed error on revert. */ + withdraw(token: Address, amount: bigint, options?: CallSignerOptions): Promise; + /** Simulates updateDeadline; returns a SimulationResult on success, throws a typed error on revert. */ + updateDeadline(deadline: bigint, options?: CallSignerOptions): Promise; + /** Simulates updateGoalAmount; returns a SimulationResult on success, throws a typed error on revert. */ + updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions): Promise; + /** Simulates approve; returns a SimulationResult on success, throws a typed error on revert. */ + approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates setApprovalForAll; returns a SimulationResult on success, throws a typed error on revert. */ + setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; + /** Simulates safeTransferFrom; returns a SimulationResult on success, throws a typed error on revert. */ + safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates transferFrom; returns a SimulationResult on success, throws a typed error on revert. */ + transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; } /** Event helpers for KeepWhatsRaised. */ diff --git a/packages/contracts/src/contracts/payment-treasury/simulate.ts b/packages/contracts/src/contracts/payment-treasury/simulate.ts index 85283984..0bbbc2f1 100644 --- a/packages/contracts/src/contracts/payment-treasury/simulate.ts +++ b/packages/contracts/src/contracts/payment-treasury/simulate.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { PAYMENT_TREASURY_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { PaymentTreasurySimulate } from "./types"; import type { LineItem, ExternalFees } from "../../types/structs"; import type { CallSignerOptions } from "../../client/types"; @@ -37,7 +37,7 @@ export function createPaymentTreasurySimulate( options?: CallSignerOptions, ) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -55,6 +55,7 @@ export function createPaymentTreasurySimulate( ], }), ); + return toSimulationResult(response); }, async createPaymentBatch( paymentIds: readonly Hex[], @@ -68,7 +69,7 @@ export function createPaymentTreasurySimulate( options?: CallSignerOptions, ) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -86,6 +87,7 @@ export function createPaymentTreasurySimulate( ], }), ); + return toSimulationResult(response); }, async processCryptoPayment( paymentId: Hex, @@ -98,7 +100,7 @@ export function createPaymentTreasurySimulate( options?: CallSignerOptions, ) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -115,10 +117,11 @@ export function createPaymentTreasurySimulate( ], }), ); + return toSimulationResult(response); }, async cancelPayment(paymentId: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -127,10 +130,11 @@ export function createPaymentTreasurySimulate( args: [paymentId], }), ); + return toSimulationResult(response); }, async confirmPayment(paymentId: Hex, buyerAddress: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -139,10 +143,11 @@ export function createPaymentTreasurySimulate( args: [paymentId, buyerAddress], }), ); + return toSimulationResult(response); }, async confirmPaymentBatch(paymentIds: readonly Hex[], buyerAddresses: readonly Address[], options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -151,10 +156,11 @@ export function createPaymentTreasurySimulate( args: [[...paymentIds], [...buyerAddresses]], }), ); + return toSimulationResult(response); }, async disburseFees(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -163,10 +169,11 @@ export function createPaymentTreasurySimulate( args: [], }), ); + return toSimulationResult(response); }, async withdraw(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -175,10 +182,11 @@ export function createPaymentTreasurySimulate( args: [], }), ); + return toSimulationResult(response); }, async claimRefund(paymentId: Hex, refundAddress: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -187,10 +195,11 @@ export function createPaymentTreasurySimulate( args: [paymentId, refundAddress], }), ); + return toSimulationResult(response); }, async claimRefundSelf(paymentId: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -199,10 +208,11 @@ export function createPaymentTreasurySimulate( args: [paymentId], }), ); + return toSimulationResult(response); }, async claimExpiredFunds(options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -211,10 +221,11 @@ export function createPaymentTreasurySimulate( args: [], }), ); + return toSimulationResult(response); }, async claimNonGoalLineItems(token: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -223,10 +234,11 @@ export function createPaymentTreasurySimulate( args: [token], }), ); + return toSimulationResult(response); }, async pauseTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -235,10 +247,11 @@ export function createPaymentTreasurySimulate( args: [message], }), ); + return toSimulationResult(response); }, async unpauseTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -247,10 +260,11 @@ export function createPaymentTreasurySimulate( args: [message], }), ); + return toSimulationResult(response); }, async cancelTreasury(message: Hex, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -259,6 +273,7 @@ export function createPaymentTreasurySimulate( args: [message], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/payment-treasury/types.ts b/packages/contracts/src/contracts/payment-treasury/types.ts index 1a83961c..71d25843 100644 --- a/packages/contracts/src/contracts/payment-treasury/types.ts +++ b/packages/contracts/src/contracts/payment-treasury/types.ts @@ -1,6 +1,6 @@ import type { Address, Hex } from "../../lib"; import type { PaymentData, LineItem, ExternalFees } from "../../types/structs"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for PaymentTreasury. */ @@ -90,7 +90,7 @@ export interface PaymentTreasuryWrites { /** Simulate counterparts for PaymentTreasury write methods. */ export interface PaymentTreasurySimulate { - /** Simulates createPayment; throws a typed error on revert. */ + /** Simulates createPayment; returns a SimulationResult on success, throws a typed error on revert. */ createPayment( paymentId: Hex, buyerId: Hex, @@ -101,8 +101,8 @@ export interface PaymentTreasurySimulate { lineItems: readonly LineItem[], externalFees: readonly ExternalFees[], options?: CallSignerOptions, - ): Promise; - /** Simulates createPaymentBatch; throws a typed error on revert. */ + ): Promise; + /** Simulates createPaymentBatch; returns a SimulationResult on success, throws a typed error on revert. */ createPaymentBatch( paymentIds: readonly Hex[], buyerIds: readonly Hex[], @@ -113,8 +113,8 @@ export interface PaymentTreasurySimulate { lineItemsArray: readonly (readonly LineItem[])[], externalFeesArray: readonly (readonly ExternalFees[])[], options?: CallSignerOptions, - ): Promise; - /** Simulates processCryptoPayment; throws a typed error on revert. */ + ): Promise; + /** Simulates processCryptoPayment; returns a SimulationResult on success, throws a typed error on revert. */ processCryptoPayment( paymentId: Hex, itemId: Hex, @@ -124,31 +124,31 @@ export interface PaymentTreasurySimulate { lineItems: readonly LineItem[], externalFees: readonly ExternalFees[], options?: CallSignerOptions, - ): Promise; - /** Simulates cancelPayment; throws a typed error on revert. */ - cancelPayment(paymentId: Hex, options?: CallSignerOptions): Promise; - /** Simulates confirmPayment; throws a typed error on revert. */ - confirmPayment(paymentId: Hex, buyerAddress: Address, options?: CallSignerOptions): Promise; - /** Simulates confirmPaymentBatch; throws a typed error on revert. */ - confirmPaymentBatch(paymentIds: readonly Hex[], buyerAddresses: readonly Address[], options?: CallSignerOptions): Promise; - /** Simulates disburseFees; throws a typed error on revert. */ - disburseFees(options?: CallSignerOptions): Promise; - /** Simulates withdraw; throws a typed error on revert. */ - withdraw(options?: CallSignerOptions): Promise; - /** Simulates claimRefund; throws a typed error on revert. */ - claimRefund(paymentId: Hex, refundAddress: Address, options?: CallSignerOptions): Promise; - /** Simulates claimRefundSelf; throws a typed error on revert. */ - claimRefundSelf(paymentId: Hex, options?: CallSignerOptions): Promise; - /** Simulates claimExpiredFunds; throws a typed error on revert. */ - claimExpiredFunds(options?: CallSignerOptions): Promise; - /** Simulates claimNonGoalLineItems; throws a typed error on revert. */ - claimNonGoalLineItems(token: Address, options?: CallSignerOptions): Promise; - /** Simulates pauseTreasury; throws a typed error on revert. */ - pauseTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates unpauseTreasury; throws a typed error on revert. */ - unpauseTreasury(message: Hex, options?: CallSignerOptions): Promise; - /** Simulates cancelTreasury; throws a typed error on revert. */ - cancelTreasury(message: Hex, options?: CallSignerOptions): Promise; + ): Promise; + /** Simulates cancelPayment; returns a SimulationResult on success, throws a typed error on revert. */ + cancelPayment(paymentId: Hex, options?: CallSignerOptions): Promise; + /** Simulates confirmPayment; returns a SimulationResult on success, throws a typed error on revert. */ + confirmPayment(paymentId: Hex, buyerAddress: Address, options?: CallSignerOptions): Promise; + /** Simulates confirmPaymentBatch; returns a SimulationResult on success, throws a typed error on revert. */ + confirmPaymentBatch(paymentIds: readonly Hex[], buyerAddresses: readonly Address[], options?: CallSignerOptions): Promise; + /** Simulates disburseFees; returns a SimulationResult on success, throws a typed error on revert. */ + disburseFees(options?: CallSignerOptions): Promise; + /** Simulates withdraw; returns a SimulationResult on success, throws a typed error on revert. */ + withdraw(options?: CallSignerOptions): Promise; + /** Simulates claimRefund; returns a SimulationResult on success, throws a typed error on revert. */ + claimRefund(paymentId: Hex, refundAddress: Address, options?: CallSignerOptions): Promise; + /** Simulates claimRefundSelf; returns a SimulationResult on success, throws a typed error on revert. */ + claimRefundSelf(paymentId: Hex, options?: CallSignerOptions): Promise; + /** Simulates claimExpiredFunds; returns a SimulationResult on success, throws a typed error on revert. */ + claimExpiredFunds(options?: CallSignerOptions): Promise; + /** Simulates claimNonGoalLineItems; returns a SimulationResult on success, throws a typed error on revert. */ + claimNonGoalLineItems(token: Address, options?: CallSignerOptions): Promise; + /** Simulates pauseTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + pauseTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates unpauseTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + unpauseTreasury(message: Hex, options?: CallSignerOptions): Promise; + /** Simulates cancelTreasury; returns a SimulationResult on success, throws a typed error on revert. */ + cancelTreasury(message: Hex, options?: CallSignerOptions): Promise; } /** Event helpers for PaymentTreasury. */ diff --git a/packages/contracts/src/contracts/treasury-factory/simulate.ts b/packages/contracts/src/contracts/treasury-factory/simulate.ts index b399cd6e..a8bfbe6d 100644 --- a/packages/contracts/src/contracts/treasury-factory/simulate.ts +++ b/packages/contracts/src/contracts/treasury-factory/simulate.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient, WalletClient, Chain } from "../../lib"; import { TREASURY_FACTORY_ABI } from "./abi"; import { requireSigner, requireAccount } from "../../utils/account"; -import { simulateWithErrorDecode } from "../../errors"; +import { simulateWithErrorDecode, toSimulationResult } from "../../errors"; import type { TreasuryFactorySimulate } from "./types"; import type { CallSignerOptions } from "../../client/types"; @@ -24,9 +24,9 @@ export function createTreasuryFactorySimulate( const contract = { address, abi: TREASURY_FACTORY_ABI } as const; return { - async deploy(platformHash: Hex, infoAddress: Address, implementationId: bigint, options?: CallSignerOptions): Promise { + async deploy(platformHash: Hex, infoAddress: Address, implementationId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -35,10 +35,11 @@ export function createTreasuryFactorySimulate( args: [platformHash, infoAddress, implementationId], }), ); + return toSimulationResult(response); }, - async registerTreasuryImplementation(platformHash: Hex, implementationId: bigint, implementation: Address, options?: CallSignerOptions): Promise { + async registerTreasuryImplementation(platformHash: Hex, implementationId: bigint, implementation: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -47,10 +48,11 @@ export function createTreasuryFactorySimulate( args: [platformHash, implementationId, implementation], }), ); + return toSimulationResult(response); }, - async approveTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions): Promise { + async approveTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -59,10 +61,11 @@ export function createTreasuryFactorySimulate( args: [platformHash, implementationId], }), ); + return toSimulationResult(response); }, - async disapproveTreasuryImplementation(implementation: Address, options?: CallSignerOptions): Promise { + async disapproveTreasuryImplementation(implementation: Address, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -71,10 +74,11 @@ export function createTreasuryFactorySimulate( args: [implementation], }), ); + return toSimulationResult(response); }, - async removeTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions): Promise { + async removeTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions) { const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => + const response = await simulateWithErrorDecode(() => publicClient.simulateContract({ ...contract, chain, @@ -83,6 +87,7 @@ export function createTreasuryFactorySimulate( args: [platformHash, implementationId], }), ); + return toSimulationResult(response); }, }; } diff --git a/packages/contracts/src/contracts/treasury-factory/types.ts b/packages/contracts/src/contracts/treasury-factory/types.ts index 3c37770d..e9cad1a0 100644 --- a/packages/contracts/src/contracts/treasury-factory/types.ts +++ b/packages/contracts/src/contracts/treasury-factory/types.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from "../../lib"; -import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog } from "../../types/events"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for TreasuryFactory (none in ABI). */ @@ -21,16 +21,16 @@ export interface TreasuryFactoryWrites { /** Simulate counterparts for TreasuryFactory write methods. */ export interface TreasuryFactorySimulate { - /** Simulates deploy; throws a typed error on revert. */ - deploy(platformHash: Hex, infoAddress: Address, implementationId: bigint, options?: CallSignerOptions): Promise; - /** Simulates registerTreasuryImplementation; throws a typed error on revert. */ - registerTreasuryImplementation(platformHash: Hex, implementationId: bigint, implementation: Address, options?: CallSignerOptions): Promise; - /** Simulates approveTreasuryImplementation; throws a typed error on revert. */ - approveTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions): Promise; - /** Simulates disapproveTreasuryImplementation; throws a typed error on revert. */ - disapproveTreasuryImplementation(implementation: Address, options?: CallSignerOptions): Promise; - /** Simulates removeTreasuryImplementation; throws a typed error on revert. */ - removeTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions): Promise; + /** Simulates deploy; returns a SimulationResult on success, throws a typed error on revert. */ + deploy(platformHash: Hex, infoAddress: Address, implementationId: bigint, options?: CallSignerOptions): Promise; + /** Simulates registerTreasuryImplementation; returns a SimulationResult on success, throws a typed error on revert. */ + registerTreasuryImplementation(platformHash: Hex, implementationId: bigint, implementation: Address, options?: CallSignerOptions): Promise; + /** Simulates approveTreasuryImplementation; returns a SimulationResult on success, throws a typed error on revert. */ + approveTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions): Promise; + /** Simulates disapproveTreasuryImplementation; returns a SimulationResult on success, throws a typed error on revert. */ + disapproveTreasuryImplementation(implementation: Address, options?: CallSignerOptions): Promise; + /** Simulates removeTreasuryImplementation; returns a SimulationResult on success, throws a typed error on revert. */ + removeTreasuryImplementation(platformHash: Hex, implementationId: bigint, options?: CallSignerOptions): Promise; } /** Event helpers for a TreasuryFactory contract instance. */ From 19dd0c658c83daf33fde6579b73bd34c8283e66c Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:47:06 +0600 Subject: [PATCH 08/86] feat: add getReceipt tests and enhance contract simulation utilities for improved error handling --- .../contracts/__tests__/unit/client.test.ts | 47 ++++++++ .../__tests__/unit/contract-entities.test.ts | 5 +- .../__tests__/unit/error-parsing.test.ts | 39 ++++++- .../contracts/__tests__/unit/metrics.test.ts | 100 +++++++++++++++++- 4 files changed, 187 insertions(+), 4 deletions(-) diff --git a/packages/contracts/__tests__/unit/client.test.ts b/packages/contracts/__tests__/unit/client.test.ts index 8f6b5944..e3e864b2 100644 --- a/packages/contracts/__tests__/unit/client.test.ts +++ b/packages/contracts/__tests__/unit/client.test.ts @@ -137,6 +137,42 @@ describe("createOakContractsClient", () => { expect(results).toEqual([5n, 250n]); }); + it("getReceipt returns receipt for mined transaction", async () => { + const client = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: RPC, + privateKey: PK, + }); + const mockReceipt = { + blockNumber: 456n, + gasUsed: 42000n, + logs: [{ topics: ["0xabc"], data: "0xdef" }], + }; + ( + client.publicClient as unknown as { getTransactionReceipt: jest.Mock } + ).getTransactionReceipt = jest.fn().mockResolvedValue(mockReceipt); + + const receipt = await client.getReceipt("0xdeadbeef"); + expect(receipt).not.toBeNull(); + expect(receipt!.blockNumber).toBe(456n); + expect(receipt!.gasUsed).toBe(42000n); + expect(receipt!.logs).toHaveLength(1); + }); + + it("getReceipt returns null when transaction is not found", async () => { + const client = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: RPC, + privateKey: PK, + }); + ( + client.publicClient as unknown as { getTransactionReceipt: jest.Mock } + ).getTransactionReceipt = jest.fn().mockRejectedValue(new Error("not found")); + + const receipt = await client.getReceipt("0xdeadbeef"); + expect(receipt).toBeNull(); + }); + it("waitForReceipt calls publicClient.waitForTransactionReceipt", async () => { const client = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -173,4 +209,15 @@ describe("contracts barrel export", () => { expect(contractsIndex.createKeepWhatsRaisedEntity).toBeDefined(); expect(contractsIndex.createItemRegistryEntity).toBeDefined(); }); + + it("re-exports all contract ABIs", () => { + expect(contractsIndex.GLOBAL_PARAMS_ABI).toBeDefined(); + expect(contractsIndex.CAMPAIGN_INFO_FACTORY_ABI).toBeDefined(); + expect(contractsIndex.CAMPAIGN_INFO_ABI).toBeDefined(); + expect(contractsIndex.TREASURY_FACTORY_ABI).toBeDefined(); + expect(contractsIndex.PAYMENT_TREASURY_ABI).toBeDefined(); + expect(contractsIndex.ALL_OR_NOTHING_ABI).toBeDefined(); + expect(contractsIndex.KEEP_WHATS_RAISED_ABI).toBeDefined(); + expect(contractsIndex.ITEM_REGISTRY_ABI).toBeDefined(); + }); }); diff --git a/packages/contracts/__tests__/unit/contract-entities.test.ts b/packages/contracts/__tests__/unit/contract-entities.test.ts index dd00d863..03869bff 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -15,7 +15,10 @@ type WatchContractEventArgs = { onLogs: (logs: unknown[]) => void }; function mockPublicClient(): PublicClient { return { readContract: jest.fn().mockResolvedValue(0n), - simulateContract: jest.fn().mockResolvedValue({ result: undefined }), + simulateContract: jest.fn().mockResolvedValue({ + result: undefined, + request: { to: ADDR, data: "0x00" as `0x${string}`, value: 0n, gas: 21000n }, + }), getContractEvents: jest.fn().mockResolvedValue([]), watchContractEvent: jest.fn().mockImplementation((_args: WatchContractEventArgs) => () => {}), } as unknown as PublicClient; diff --git a/packages/contracts/__tests__/unit/error-parsing.test.ts b/packages/contracts/__tests__/unit/error-parsing.test.ts index 36004319..b0adeeff 100644 --- a/packages/contracts/__tests__/unit/error-parsing.test.ts +++ b/packages/contracts/__tests__/unit/error-parsing.test.ts @@ -5,6 +5,7 @@ import { parseContractError, getRevertData, simulateWithErrorDecode, + toSimulationResult, } from "../../src/errors/parse-contract-error"; import { parseGlobalParamsError } from "../../src/errors/parse/global-params"; import { parseCampaignInfoFactoryError } from "../../src/errors/parse/campaign-info-factory"; @@ -150,8 +151,8 @@ describe("getRevertData", () => { }); describe("simulateWithErrorDecode", () => { - it("does not throw on success", async () => { - await expect(simulateWithErrorDecode(async () => "ok")).resolves.toBeUndefined(); + it("returns the operation result on success", async () => { + await expect(simulateWithErrorDecode(async () => "ok")).resolves.toBe("ok"); }); it("throws typed error when revert data is parseable", async () => { @@ -170,6 +171,40 @@ describe("simulateWithErrorDecode", () => { }); }); +describe("toSimulationResult", () => { + it("maps viem simulate response to SimulationResult", () => { + const response = { + result: 42n, + request: { + to: "0x0000000000000000000000000000000000000001", + data: "0xdeadbeef", + value: 100n, + gas: 21000n, + }, + }; + const mapped = toSimulationResult(response); + expect(mapped.result).toBe(42n); + expect(mapped.request.to).toBe("0x0000000000000000000000000000000000000001"); + expect(mapped.request.data).toBe("0xdeadbeef"); + expect(mapped.request.value).toBe(100n); + expect(mapped.request.gas).toBe(21000n); + }); + + it("handles undefined value and gas", () => { + const response = { + result: undefined, + request: { + to: "0x0000000000000000000000000000000000000001", + data: "0x00", + }, + }; + const mapped = toSimulationResult(response); + expect(mapped.result).toBeUndefined(); + expect(mapped.request.value).toBeUndefined(); + expect(mapped.request.gas).toBeUndefined(); + }); +}); + describe("parseContractError", () => { it("returns null for empty string", () => { expect(parseContractError("")).toBeNull(); diff --git a/packages/contracts/__tests__/unit/metrics.test.ts b/packages/contracts/__tests__/unit/metrics.test.ts index dd1d6055..29423c4a 100644 --- a/packages/contracts/__tests__/unit/metrics.test.ts +++ b/packages/contracts/__tests__/unit/metrics.test.ts @@ -1,9 +1,11 @@ -import type { Address, PublicClient } from "../../src/lib"; +import type { Address, PublicClient, Chain } from "../../src/lib"; import { getPlatformStats } from "../../src/metrics/platform"; import { getCampaignSummary } from "../../src/metrics/campaign"; import { getTreasuryReport } from "../../src/metrics/treasury"; import { multicall } from "../../src/utils/multicall"; +import { prepareContractWrite, toPreparedTransaction } from "../../src/utils/prepare"; import type { TreasuryType } from "../../src/metrics/types"; +import type { SimulationResult } from "../../src/types/events"; const ADDR = "0x0000000000000000000000000000000000000001" as Address; @@ -204,3 +206,99 @@ describe("getTreasuryReport", () => { expect(report.cancelled).toBe(true); }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// prepareContractWrite + toPreparedTransaction +// ────────────────────────────────────────────────────────────────────────────── + +const TEST_ABI = [ + { + type: "function" as const, + name: "transfer", + stateMutability: "nonpayable" as const, + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + +describe("prepareContractWrite", () => { + it("encodes calldata and estimates gas", async () => { + const pub = { + estimateContractGas: jest.fn().mockResolvedValue(50000n), + } as unknown as PublicClient; + + const mockChain = { id: 1, name: "test" } as Chain; + + const result = await prepareContractWrite(pub, { + address: ADDR, + abi: TEST_ABI, + functionName: "transfer", + args: [ADDR, 100n], + account: ADDR, + chain: mockChain, + }); + + expect(result.to).toBe(ADDR); + expect(result.data).toMatch(/^0x/); + expect(result.value).toBe(0n); + expect(result.gas).toBe(50000n); + expect(pub.estimateContractGas).toHaveBeenCalled(); + }); + + it("passes value through when provided", async () => { + const pub = { + estimateContractGas: jest.fn().mockResolvedValue(21000n), + } as unknown as PublicClient; + + const mockChain = { id: 1, name: "test" } as Chain; + + const result = await prepareContractWrite(pub, { + address: ADDR, + abi: TEST_ABI, + functionName: "transfer", + args: [ADDR, 100n], + account: ADDR, + chain: mockChain, + value: 500n, + }); + + expect(result.value).toBe(500n); + }); +}); + +describe("toPreparedTransaction", () => { + it("extracts PreparedTransaction from SimulationResult", () => { + const simResult: SimulationResult = { + result: undefined, + request: { + to: ADDR, + data: "0xdeadbeef" as `0x${string}`, + value: 100n, + gas: 21000n, + }, + }; + + const prepared = toPreparedTransaction(simResult); + expect(prepared.to).toBe(ADDR); + expect(prepared.data).toBe("0xdeadbeef"); + expect(prepared.value).toBe(100n); + expect(prepared.gas).toBe(21000n); + }); + + it("defaults value and gas to 0n when undefined", () => { + const simResult: SimulationResult = { + result: undefined, + request: { + to: ADDR, + data: "0x00" as `0x${string}`, + }, + }; + + const prepared = toPreparedTransaction(simResult); + expect(prepared.value).toBe(0n); + expect(prepared.gas).toBe(0n); + }); +}); From 379024388a20043ba04495353970dddec95c2eba Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 17:47:27 +0600 Subject: [PATCH 09/86] docs: update README to include transaction receipt methods and simulation utilities for enhanced transaction handling --- packages/contracts/README.md | 116 +++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 51ee1fc7..ee953007 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -171,6 +171,27 @@ When a write or simulate method is called, the signer is resolved in this order: > For a detailed step-by-step guide, please refer to the complete [Client Configuration](https://oaknetwork.org/docs/contracts-sdk/client) documentation. +### Transaction Receipts + +The client provides two methods for fetching transaction receipts: + +```typescript +// Wait for a pending transaction to be mined (blocking) +const receipt = await oak.waitForReceipt(txHash); +console.log(`Mined in block ${receipt.blockNumber}, gas used: ${receipt.gasUsed}`); + +// Look up a receipt for an already-mined transaction (non-blocking) +// Returns null if the transaction hasn't been mined yet +const receipt = await oak.getReceipt(txHash); +if (receipt) { + console.log(`Block: ${receipt.blockNumber}`); +} +``` + +Use `waitForReceipt` when you've just sent a transaction and need to block until it's confirmed. Use `getReceipt` when you already have a tx hash (e.g. from a webhook, indexer, or previous session) and want to fetch the receipt without waiting. + +--- + ## Contract Entities ### GlobalParams @@ -759,6 +780,7 @@ import type { EventFilterOptions, EventWatchHandler, RawLog, + SimulationResult, } from "@oaknetwork/contracts-sdk"; // EventFilterOptions — optional block range for get*Logs @@ -781,6 +803,17 @@ interface RawLog { // EventWatchHandler — callback for watch* methods type EventWatchHandler = (logs: readonly DecodedEventLog[]) => void; + +// SimulationResult — returned by entity simulate methods +interface SimulationResult { + result: T; + request: { + to: Address; + data: Hex; + value?: bigint; + gas?: bigint; + }; +} ``` > For complete details on contract events, please visit the following link: [Events](https://oaknetwork.org/docs/contracts-sdk/events). @@ -827,6 +860,76 @@ try { --- +## Simulation & Transaction Preparation + +Every entity exposes a `simulate` namespace that dry-runs write calls against the current chain state. Simulate methods now return a `SimulationResult` containing both the predicted return value and prepared transaction parameters — useful for gas estimation, account-abstraction (ERC-4337), or Safe multisig batching. + +### SimulationResult + +```typescript +import type { SimulationResult } from "@oaknetwork/contracts-sdk"; + +const gp = oak.globalParams("0x..."); + +// Simulate a write — returns SimulationResult instead of void +const sim = await gp.simulate.enlistPlatform(hash, adminAddr, fee, adapter); + +console.log(sim.result); // Contract return value (void for most writes) +console.log(sim.request.to); // Target contract address +console.log(sim.request.data); // ABI-encoded calldata +console.log(sim.request.gas); // Estimated gas limit +console.log(sim.request.value); // Native token value (wei) +``` + +If the simulation reverts, a typed SDK error is thrown — the same as before. + +### Preparing Transactions Without Sending + +For flows where you need raw transaction parameters without sending (e.g. account-abstraction UserOps, Safe multisig, or custom signing), use `prepareContractWrite` or extract params from a simulation result with `toPreparedTransaction`: + +```typescript +import { + prepareContractWrite, + toPreparedTransaction, + GLOBAL_PARAMS_ABI, +} from "@oaknetwork/contracts-sdk"; + +// Option 1: Prepare directly from ABI + function name +const tx = await prepareContractWrite(oak.publicClient, { + address: "0x...", + abi: GLOBAL_PARAMS_ABI, + functionName: "enlistPlatform", + args: [platformHash, adminAddress, feePercent, adapterAddress], + account: "0xMyWallet...", + chain: oak.config.chain, +}); +// tx = { to, data, value, gas } + +// Option 2: Extract from an existing SimulationResult +const sim = await gp.simulate.enlistPlatform(hash, admin, fee, adapter); +const prepared = toPreparedTransaction(sim); +// prepared = { to, data, value, gas } +``` + +### Exported ABI Constants + +All contract ABIs are now exported for use with `prepareContractWrite`, custom viem calls, or third-party tools: + +```typescript +import { + GLOBAL_PARAMS_ABI, + CAMPAIGN_INFO_FACTORY_ABI, + CAMPAIGN_INFO_ABI, + TREASURY_FACTORY_ABI, + PAYMENT_TREASURY_ABI, + ALL_OR_NOTHING_ABI, + KEEP_WHATS_RAISED_ABI, + ITEM_REGISTRY_ABI, +} from "@oaknetwork/contracts-sdk"; +``` + +--- + ## Utility Functions The SDK exports pure utility functions and constants that have no client dependency. Import them from @oaknetwork/contracts-sdk or @oaknetwork/contracts-sdk/utils. @@ -837,6 +940,9 @@ import { id, toHex, stringToHex, + encodeFunctionData, + decodeFunctionResult, + decodeEventLog, parseEther, formatEther, parseUnits, @@ -846,6 +952,8 @@ import { addDays, getChainFromId, multicall, + prepareContractWrite, + toPreparedTransaction, createJsonRpcProvider, createWallet, createBrowserProvider, @@ -884,11 +992,11 @@ For complete guidelines on utility functions, please refer to the following link | Entry point | Contents | | ------------------------------------- | ------------------------------------------------------------------------------ | -| `@oaknetwork/contracts-sdk` | Everything — client, types, utils, errors | -| `@oaknetwork/contracts-sdk/utils` | Utility functions only (no client) | -| `@oaknetwork/contracts-sdk/contracts` | Contract entity factories only | +| `@oaknetwork/contracts-sdk` | Everything — client, types, utils, errors, ABI constants | +| `@oaknetwork/contracts-sdk/utils` | Utility functions + `prepareContractWrite` / `toPreparedTransaction` | +| `@oaknetwork/contracts-sdk/contracts` | Contract entity factories + ABI constants | | `@oaknetwork/contracts-sdk/client` | `createOakContractsClient` only | -| `@oaknetwork/contracts-sdk/errors` | Error classes and `parseContractError` only | +| `@oaknetwork/contracts-sdk/errors` | Error classes, `parseContractError`, and `toSimulationResult` | | `@oaknetwork/contracts-sdk/metrics` | Platform, campaign, and treasury reporting helpers (not re-exported from root) | ## Multicall From 9425a8d50624951ce2069f7be4fbe1c34354b2b4 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 18:05:48 +0600 Subject: [PATCH 10/86] docs(contracts): slim down README and reflect latest client changes - Reduce README from ~1,100 lines to ~430 lines by replacing exhaustive API listings with a summary table and doc links. - Add documentation for new client features: getReceipt(), SimulationResult return type, prepareContractWrite/toPreparedTransaction utilities, and ABI exports. --- packages/contracts/README.md | 815 +++-------------------------------- 1 file changed, 59 insertions(+), 756 deletions(-) diff --git a/packages/contracts/README.md b/packages/contracts/README.md index ee953007..351154b2 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -58,7 +58,7 @@ See the full [Quickstart](https://oaknetwork.org/docs/contracts-sdk/quickstart) ## Client Configuration -Four config/signer patterns are supported. Mix and match as needed. +Five config/signer patterns are supported. Mix and match as needed. ### Pattern 1 — Simple (chainId + rpcUrl + privateKey) @@ -194,797 +194,147 @@ Use `waitForReceipt` when you've just sent a transaction and need to block until ## Contract Entities -### GlobalParams - -Protocol-wide configuration registry. Manages platform listings, fee settings, token currencies, line item types, and a general-purpose key-value registry. +Each entity is created from the client with a deployed contract address. Every entity exposes typed read methods, write methods, a `simulate` namespace, and an `events` namespace. ```typescript const gp = oak.globalParams("0x..."); // Reads const admin = await gp.getProtocolAdminAddress(); -const fee = await gp.getProtocolFeePercent(); // bigint bps (e.g. 100 = 1%) -const count = await gp.getNumberOfListedPlatforms(); +const fee = await gp.getProtocolFeePercent(); const isListed = await gp.checkIfPlatformIsListed(platformHash); -const platAdmin = await gp.getPlatformAdminAddress(platformHash); -const platFee = await gp.getPlatformFeePercent(platformHash); -const delay = await gp.getPlatformClaimDelay(platformHash); -const adapter = await gp.getPlatformAdapter(platformHash); -const tokens = await gp.getTokensForCurrency(currency); // Address[] -const lineItem = await gp.getPlatformLineItemType(platformHash, typeId); -const value = await gp.getFromRegistry(key); // Writes await gp.enlistPlatform(platformHash, adminAddress, feePercent, adapterAddress); -await gp.delistPlatform(platformHash); -await gp.updatePlatformAdminAddress(platformHash, newAdmin); -await gp.updatePlatformClaimDelay(platformHash, delaySeconds); -await gp.updateProtocolAdminAddress(newAdmin); await gp.updateProtocolFeePercent(newFeePercent); -await gp.setPlatformAdapter(platformHash, adapterAddress); -await gp.setPlatformLineItemType( - platformHash, - typeId, - label, - countsTowardGoal, - applyProtocolFee, - canRefund, - instantTransfer, -); -await gp.removePlatformLineItemType(platformHash, typeId); -await gp.addTokenToCurrency(currency, tokenAddress); -await gp.removeTokenFromCurrency(currency, tokenAddress); -await gp.addPlatformData(platformHash, platformDataKey); -await gp.removePlatformData(platformHash, platformDataKey); -await gp.addToRegistry(key, value); -await gp.transferOwnership(newOwner); -``` - -> For complete details on the Global Params contract entity, please visit the following link: [Global Params](https://oaknetwork.org/docs/contracts-sdk/global-params). - ---- - -### CampaignInfoFactory - -Deploys new CampaignInfo contracts. Each campaign gets its own on-chain CampaignInfo instance with its own address, NFT collection, and configuration. - -```typescript -import { - createOakContractsClient, - keccak256, - toHex, - getCurrentTimestamp, - addDays, - CHAIN_IDS, -} from "@oaknetwork/contracts-sdk"; - -const factory = oak.campaignInfoFactory("0x..."); - -const PLATFORM_HASH = keccak256(toHex("my-platform")); -const CURRENCY = toHex("USD", { size: 32 }); -const identifierHash = keccak256(toHex("my-campaign-slug")); -const now = getCurrentTimestamp(); - -// Reads -const infoAddress = await factory.identifierToCampaignInfo(identifierHash); -const isValid = await factory.isValidCampaignInfo(infoAddress); - -// Writes -const txHash = await factory.createCampaign({ - creator: "0x...", - identifierHash, - selectedPlatformHash: [PLATFORM_HASH], - campaignData: { - launchTime: now + 3_600n, // 1 hour from now - deadline: addDays(now, 30), // 30 days from now - goalAmount: 1_000_000n, - currency: CURRENCY, - }, - nftName: "My Campaign NFT", - nftSymbol: "MCN", - nftImageURI: "https://example.com/nft.png", - contractURI: "https://example.com/contract.json", -}); - -const receipt = await oak.waitForReceipt(txHash); -const campaignAddress = await factory.identifierToCampaignInfo(identifierHash); -``` - -> For complete details on the Campaign Info Factory contract entity, please visit the following link: [Campaign Info Factory](https://oaknetwork.org/docs/contracts-sdk/campaign-info-factory). - ---- - -### CampaignInfo - -Per-campaign configuration and state. Each campaign deployed via the CampaignInfoFactory gets its own CampaignInfo contract that tracks funding progress, accepted tokens, platform settings, and NFT pledge records. - -```typescript -const ci = oak.campaignInfo("0x..."); - -// Reads -const launchTime = await ci.getLaunchTime(); -const deadline = await ci.getDeadline(); -const goalAmount = await ci.getGoalAmount(); -const currency = await ci.getCampaignCurrency(); -const totalRaised = await ci.getTotalRaisedAmount(); -const available = await ci.getTotalAvailableRaisedAmount(); -const isLocked = await ci.isLocked(); -const isCancelled = await ci.cancelled(); -const config = await ci.getCampaignConfig(); -const tokens = await ci.getAcceptedTokens(); -// Writes -await ci.updateDeadline(newDeadline); -await ci.updateGoalAmount(newGoal); -await ci.pauseCampaign(message); -await ci.unpauseCampaign(message); -await ci.cancelCampaign(message); -``` - -> For complete details on the Campaign Info contract entity, please visit the following link: [Campaign Info](https://oaknetwork.org/docs/contracts-sdk/campaign-info). - ---- - -### TreasuryFactory - -Deploys treasury contracts for a given CampaignInfo. Manages treasury implementations that platforms can register, approve, and deploy. - -```typescript -const tf = oak.treasuryFactory("0x..."); - -// Deploy -const txHash = await tf.deploy(platformHash, infoAddress, implementationId); - -// Implementation management -await tf.registerTreasuryImplementation( - platformHash, - implementationId, - implAddress, -); -await tf.approveTreasuryImplementation(platformHash, implementationId); -await tf.disapproveTreasuryImplementation(implAddress); -await tf.removeTreasuryImplementation(platformHash, implementationId); -``` - -> For complete details on the Treasury Factory contract entity, please visit the following link: [Treasury Factory](https://oaknetwork.org/docs/contracts-sdk/treasury-factory). - ---- - -### PaymentTreasury - -Handles fiat-style payments via a payment gateway. Manages payment creation, confirmation, refunds, fee disbursement, and fund withdrawal for campaigns. - -> **Two treasury variants, one SDK method.** The `paymentTreasury()` method works with both on-chain implementations: -> -> | Variant | Description | -> | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -> | **PaymentTreasury** | Standard payment treasury with no time restrictions. Payments can be created, confirmed, and refunded at any time while the treasury is active. | -> | **TimeConstrainedPaymentTreasury** | Time-constrained variant that enforces launch-time and deadline windows on-chain. Payments can only be created within the campaign window (launch → deadline + buffer). Refunds, withdrawals, and fee disbursements are only available after launch. | -> -> Both contracts share the same ABI and the same SDK interface. Time enforcement is handled entirely on-chain — simply pass the deployed contract address regardless of which variant was deployed: - -```typescript -// Works for both PaymentTreasury and TimeConstrainedPaymentTreasury -const pt = oak.paymentTreasury("0x..."); - -// Reads -const raised = await pt.getRaisedAmount(); -const refunded = await pt.getRefundedAmount(); -const payment = await pt.getPaymentData(paymentId); +// Simulate (dry-run a write, returns SimulationResult) +const sim = await gp.simulate.enlistPlatform(hash, admin, fee, adapter); -// Writes -const txHash = await pt.createPayment( - paymentId, - buyerId, - itemId, - paymentToken, - amount, - expiration, - lineItems, - externalFees, -); -await pt.confirmPayment(paymentId, buyerAddress); -await pt.claimRefund(paymentId, refundAddress); -await pt.claimRefundSelf(paymentId); -await pt.disburseFees(); -await pt.withdraw(); -await pt.pauseTreasury(message); -await pt.unpauseTreasury(message); -await pt.cancelTreasury(message); +// Events +const logs = await gp.events.getPlatformEnlistedLogs(); +const unwatch = gp.events.watchPlatformEnlisted((logs) => { /* ... */ }); ``` -> **Note:** When using a `TimeConstrainedPaymentTreasury`, calls made outside the allowed time window will revert on-chain. For example, `createPayment()` will revert if called before launch or after the deadline + buffer period. - -> For complete details on the Payment Treasury contract entity, please visit the following link: [Payment Treasury](https://oaknetwork.org/docs/contracts-sdk/payment-treasury). - ---- +### Available Entities -### AllOrNothing Treasury - -Crowdfunding treasury where funds are only released if the campaign goal is met. If the goal is not reached, backers can claim full refunds. Includes ERC-721 pledge NFTs. - -```typescript -const aon = oak.allOrNothingTreasury("0x..."); - -// Reads -const raised = await aon.getRaisedAmount(); -const reward = await aon.getReward(rewardName); - -// Writes -await aon.addRewards(rewardNames, rewards); -await aon.pledgeForAReward(backer, pledgeToken, shippingFee, rewardNames); -await aon.pledgeWithoutAReward(backer, pledgeToken, pledgeAmount); -await aon.claimRefund(tokenId); -await aon.disburseFees(); -await aon.withdraw(); -await aon.pauseTreasury(message); -await aon.unpauseTreasury(message); -await aon.cancelTreasury(message); - -// ERC-721 -const owner = await aon.ownerOf(tokenId); -const uri = await aon.tokenURI(tokenId); -await aon.safeTransferFrom(from, to, tokenId); -``` +| Entity | Factory method | Description | Docs | +| --- | --- | --- | --- | +| **GlobalParams** | `oak.globalParams(addr)` | Protocol-wide config: platforms, fees, currencies, registry | [Docs](https://oaknetwork.org/docs/contracts-sdk/global-params) | +| **CampaignInfoFactory** | `oak.campaignInfoFactory(addr)` | Deploys new CampaignInfo contracts | [Docs](https://oaknetwork.org/docs/contracts-sdk/campaign-info-factory) | +| **CampaignInfo** | `oak.campaignInfo(addr)` | Per-campaign state: deadlines, goals, funding progress | [Docs](https://oaknetwork.org/docs/contracts-sdk/campaign-info) | +| **TreasuryFactory** | `oak.treasuryFactory(addr)` | Deploys and manages treasury implementations | [Docs](https://oaknetwork.org/docs/contracts-sdk/treasury-factory) | +| **PaymentTreasury** | `oak.paymentTreasury(addr)` | Fiat-style payments, confirmations, refunds, withdrawals | [Docs](https://oaknetwork.org/docs/contracts-sdk/payment-treasury) | +| **AllOrNothing** | `oak.allOrNothingTreasury(addr)` | Crowdfunding treasury — funds released only if goal is met | [Docs](https://oaknetwork.org/docs/contracts-sdk/all-or-nothing) | +| **KeepWhatsRaised** | `oak.keepWhatsRaisedTreasury(addr)` | Crowdfunding treasury — creator keeps all funds raised | [Docs](https://oaknetwork.org/docs/contracts-sdk/keep-whats-raised) | +| **ItemRegistry** | `oak.itemRegistry(addr)` | Manages purchasable items with metadata | [Docs](https://oaknetwork.org/docs/contracts-sdk/item-registry) | -> For complete details on the AllOrNothing Treasury contract entity, please visit the following link: [AllOrNothing Treasury](https://oaknetwork.org/docs/contracts-sdk/all-or-nothing). +> `paymentTreasury()` supports both **PaymentTreasury** and **TimeConstrainedPaymentTreasury** variants — same ABI, same SDK interface. --- -### KeepWhatsRaised Treasury +## Simulation & Transaction Preparation -Crowdfunding treasury where the creator keeps all funds raised regardless of whether the goal is met. Includes configurable fee structures, withdrawal delays, and ERC-721 pledge NFTs. +Simulate methods return a `SimulationResult` with the predicted return value and prepared transaction parameters. On revert, a typed SDK error is thrown. ```typescript -const kwr = oak.keepWhatsRaisedTreasury("0x..."); - -// Reads -const raised = await kwr.getRaisedAmount(); -const available = await kwr.getAvailableRaisedAmount(); -const reward = await kwr.getReward(rewardName); - -// Writes -await kwr.configureTreasury(config, campaignData, feeKeys, feeValues); -await kwr.addRewards(rewardNames, rewards); -await kwr.pledgeForAReward(pledgeId, backer, token, tip, rewardNames); -await kwr.pledgeWithoutAReward(pledgeId, backer, token, amount, tip); -await kwr.approveWithdrawal(); -await kwr.claimFund(); -await kwr.claimTip(); -await kwr.claimRefund(tokenId); -await kwr.disburseFees(); -await kwr.withdraw(token, amount); -await kwr.pauseTreasury(message); -await kwr.unpauseTreasury(message); -await kwr.cancelTreasury(message); +const sim = await gp.simulate.enlistPlatform(hash, adminAddr, fee, adapter); +// sim.result — contract return value +// sim.request — { to, data, value, gas } ``` -> For complete details on the KeepWhatsRaised Treasury contract entity, please visit the following link: [KeepWhatsRaised Treasury](https://oaknetwork.org/docs/contracts-sdk/keep-whats-raised). - ---- - -### ItemRegistry +For account-abstraction, Safe multisig, or custom signing flows, use `prepareContractWrite` to build raw calldata + gas without sending, or `toPreparedTransaction` to extract params from a `SimulationResult`. All contract ABIs are exported (e.g. `GLOBAL_PARAMS_ABI`, `CAMPAIGN_INFO_ABI`, etc.) for use with these utilities. -Manages items available for purchase in campaigns. Items represent physical goods with dimensions, weight, and category metadata. - -```typescript -const ir = oak.itemRegistry("0x..."); - -// Read -const item = await ir.getItem(ownerAddress, itemId); - -// Writes -await ir.addItem(itemId, item); -await ir.addItemsBatch(itemIds, items); -``` - -> For complete details on the Item Registry contract entity, please visit the following link: [Item Registry](https://oaknetwork.org/docs/contracts-sdk/item-registry). +> Full simulation and transaction preparation docs: [Simulation](https://oaknetwork.org/docs/contracts-sdk/simulation) --- -## Metrics - -Pre-built aggregation functions that combine multiple on-chain reads into meaningful reports. Import from `@oaknetwork/contracts-sdk/metrics`. - -### Platform Stats - -Protocol-level statistics from GlobalParams: - -```typescript -import { getPlatformStats } from "@oaknetwork/contracts-sdk/metrics"; - -const stats = await getPlatformStats({ - globalParamsAddress: "0x...", - publicClient: oak.publicClient, -}); - -console.log(`${stats.platformCount} platforms enlisted`); -console.log(`Protocol fee: ${stats.protocolFeePercent} bps`); -``` - -### Campaign Summary - -Financial aggregation from a deployed CampaignInfo contract: - -```typescript -import { getCampaignSummary } from "@oaknetwork/contracts-sdk/metrics"; - -const summary = await getCampaignSummary({ - campaignInfoAddress: "0x...", - publicClient: oak.publicClient, -}); - -console.log(`Total raised: ${summary.totalRaised}`); -console.log(`Goal: ${summary.goalAmount}`); -console.log(`Goal reached: ${summary.goalReached}`); -console.log(`Refunded: ${summary.totalRefunded}`); -``` - -### Treasury Report - -Per-treasury financial report for any treasury type: - -```typescript -import { getTreasuryReport } from "@oaknetwork/contracts-sdk/metrics"; - -const report = await getTreasuryReport({ - treasuryAddress: "0x...", - treasuryType: "all-or-nothing", // or "keep-whats-raised" | "payment-treasury" - publicClient: oak.publicClient, -}); - -console.log(`Raised: ${report.raisedAmount}`); -console.log(`Refunded: ${report.refundedAmount}`); -console.log(`Fee: ${report.platformFeePercent} bps`); -console.log(`Cancelled: ${report.cancelled}`); -``` - -> For complete metrics documentation, see: [Metrics](https://oaknetwork.org/docs/contracts-sdk/metrics). - ## Events -Every contract entity exposes an `events` property with three capabilities: - -1. **Fetch historical logs** — query past event logs from the blockchain -2. **Decode raw logs** — parse raw transaction receipt logs into typed event objects -3. **Watch live events** — subscribe to real-time event notifications - -### Fetching historical logs - -Each event has a `get*Logs()` method that returns all matching logs from the entire chain history. You can optionally pass `{ fromBlock, toBlock }` to narrow the search range. +Every entity exposes an `events` namespace with three capabilities: **fetch historical logs** (`get*Logs`), **decode raw logs** (`decodeLog`), and **watch live events** (`watch*`). ```typescript const gp = oak.globalParams("0x..."); -// All PlatformEnlisted events ever emitted by this contract -const logs = await gp.events.getPlatformEnlistedLogs(); +// Fetch historical logs (optionally filter by block range) +const logs = await gp.events.getPlatformEnlistedLogs({ fromBlock: 1_000_000n }); -for (const log of logs) { - console.log(log.eventName); // "PlatformEnlisted" - console.log(log.args); // { platformHash: "0x...", adminAddress: "0x...", ... } -} +// Decode a raw log from a transaction receipt +const decoded = gp.events.decodeLog({ topics: log.topics, data: log.data }); -// Filter by block range -const recentLogs = await gp.events.getPlatformEnlistedLogs({ - fromBlock: 1_000_000n, - toBlock: 2_000_000n, -}); -``` - -### Decoding raw logs - -Use `decodeLog()` to decode a raw log from a transaction receipt. This is useful when you have a receipt and want to decode its logs without knowing which event they belong to. - -```typescript -const receipt = await oak.waitForReceipt(txHash); - -for (const log of receipt.logs) { - try { - const decoded = gp.events.decodeLog({ - topics: log.topics, - data: log.data, - }); - console.log(decoded.eventName, decoded.args); - } catch { - // Log doesn't match any event in this contract's ABI - } -} -``` - -### Watching live events - -Each event has a `watch*()` method that subscribes to real-time event notifications. The method returns an `unwatch` function to stop listening. - -```typescript -const gp = oak.globalParams("0x..."); - -// Start watching for new PlatformEnlisted events +// Watch live events const unwatch = gp.events.watchPlatformEnlisted((logs) => { - for (const log of logs) { - console.log("New platform enlisted:", log.args); - } + for (const log of logs) console.log(log.args); }); - -// Later — stop watching -unwatch(); -``` - -### Available events per contract - -#### GlobalParams - -```typescript -const gp = oak.globalParams("0x..."); - -// Fetch historical logs -await gp.events.getPlatformEnlistedLogs(options?); -await gp.events.getPlatformDelistedLogs(options?); -await gp.events.getPlatformAdminAddressUpdatedLogs(options?); -await gp.events.getPlatformDataAddedLogs(options?); -await gp.events.getPlatformDataRemovedLogs(options?); -await gp.events.getPlatformAdapterSetLogs(options?); -await gp.events.getPlatformClaimDelayUpdatedLogs(options?); -await gp.events.getProtocolAdminAddressUpdatedLogs(options?); -await gp.events.getProtocolFeePercentUpdatedLogs(options?); -await gp.events.getTokenAddedToCurrencyLogs(options?); -await gp.events.getTokenRemovedFromCurrencyLogs(options?); -await gp.events.getOwnershipTransferredLogs(options?); -await gp.events.getPausedLogs(options?); -await gp.events.getUnpausedLogs(options?); - -// Decode a raw log -gp.events.decodeLog({ topics, data }); - -// Watch live events -const unwatch = gp.events.watchPlatformEnlisted(handler); -const unwatch = gp.events.watchPlatformDelisted(handler); -const unwatch = gp.events.watchTokenAddedToCurrency(handler); -const unwatch = gp.events.watchTokenRemovedFromCurrency(handler); -``` - -#### CampaignInfoFactory - -```typescript -const factory = oak.campaignInfoFactory("0x..."); - -await factory.events.getCampaignCreatedLogs(options?); -await factory.events.getCampaignInitializedLogs(options?); -await factory.events.getOwnershipTransferredLogs(options?); -factory.events.decodeLog({ topics, data }); -const unwatch = factory.events.watchCampaignCreated(handler); -``` - -#### TreasuryFactory - -```typescript -const tf = oak.treasuryFactory("0x..."); - -await tf.events.getTreasuryDeployedLogs(options?); -await tf.events.getImplementationRegisteredLogs(options?); -await tf.events.getImplementationRemovedLogs(options?); -await tf.events.getImplementationApprovalLogs(options?); -tf.events.decodeLog({ topics, data }); -const unwatch = tf.events.watchTreasuryDeployed(handler); -const unwatch = tf.events.watchImplementationRegistered(handler); -``` - -#### CampaignInfo - -```typescript -const ci = oak.campaignInfo("0x..."); - -await ci.events.getDeadlineUpdatedLogs(options?); -await ci.events.getGoalAmountUpdatedLogs(options?); -await ci.events.getLaunchTimeUpdatedLogs(options?); -await ci.events.getPlatformInfoUpdatedLogs(options?); -await ci.events.getSelectedPlatformUpdatedLogs(options?); -await ci.events.getOwnershipTransferredLogs(options?); -await ci.events.getPausedLogs(options?); -await ci.events.getUnpausedLogs(options?); -ci.events.decodeLog({ topics, data }); -const unwatch = ci.events.watchDeadlineUpdated(handler); -const unwatch = ci.events.watchPlatformInfoUpdated(handler); -const unwatch = ci.events.watchSelectedPlatformUpdated(handler); -``` - -#### PaymentTreasury - -```typescript -const pt = oak.paymentTreasury("0x..."); - -await pt.events.getPaymentCreatedLogs(options?); -await pt.events.getPaymentCancelledLogs(options?); -await pt.events.getPaymentConfirmedLogs(options?); -await pt.events.getPaymentBatchConfirmedLogs(options?); -await pt.events.getPaymentBatchCreatedLogs(options?); -await pt.events.getFeesDisbursedLogs(options?); -await pt.events.getWithdrawalWithFeeSuccessfulLogs(options?); -await pt.events.getRefundClaimedLogs(options?); -await pt.events.getNonGoalLineItemsClaimedLogs(options?); -await pt.events.getExpiredFundsClaimedLogs(options?); -pt.events.decodeLog({ topics, data }); -const unwatch = pt.events.watchPaymentCreated(handler); -const unwatch = pt.events.watchPaymentConfirmed(handler); -const unwatch = pt.events.watchPaymentCancelled(handler); -const unwatch = pt.events.watchRefundClaimed(handler); -const unwatch = pt.events.watchFeesDisbursed(handler); -``` - -#### AllOrNothing Treasury - -```typescript -const aon = oak.allOrNothingTreasury("0x..."); - -await aon.events.getReceiptLogs(options?); -await aon.events.getRefundClaimedLogs(options?); -await aon.events.getWithdrawalSuccessfulLogs(options?); -await aon.events.getFeesDisbursedLogs(options?); -await aon.events.getRewardsAddedLogs(options?); -await aon.events.getRewardRemovedLogs(options?); -await aon.events.getPausedLogs(options?); -await aon.events.getUnpausedLogs(options?); -await aon.events.getTransferLogs(options?); -await aon.events.getSuccessConditionNotFulfilledLogs(options?); -aon.events.decodeLog({ topics, data }); -const unwatch = aon.events.watchReceipt(handler); -const unwatch = aon.events.watchRefundClaimed(handler); -const unwatch = aon.events.watchWithdrawalSuccessful(handler); -const unwatch = aon.events.watchFeesDisbursed(handler); +unwatch(); // stop watching ``` -#### KeepWhatsRaised Treasury - -```typescript -const kwr = oak.keepWhatsRaisedTreasury("0x..."); - -await kwr.events.getReceiptLogs(options?); -await kwr.events.getRefundClaimedLogs(options?); -await kwr.events.getWithdrawalWithFeeSuccessfulLogs(options?); -await kwr.events.getWithdrawalApprovedLogs(options?); -await kwr.events.getFeesDisbursedLogs(options?); -await kwr.events.getTreasuryConfiguredLogs(options?); -await kwr.events.getRewardsAddedLogs(options?); -await kwr.events.getRewardRemovedLogs(options?); -await kwr.events.getTipClaimedLogs(options?); -await kwr.events.getFundClaimedLogs(options?); -await kwr.events.getDeadlineUpdatedLogs(options?); -await kwr.events.getGoalAmountUpdatedLogs(options?); -await kwr.events.getPaymentGatewayFeeSetLogs(options?); -await kwr.events.getPausedLogs(options?); -await kwr.events.getUnpausedLogs(options?); -await kwr.events.getTransferLogs(options?); -kwr.events.decodeLog({ topics, data }); -const unwatch = kwr.events.watchReceipt(handler); -const unwatch = kwr.events.watchRefundClaimed(handler); -const unwatch = kwr.events.watchWithdrawalWithFeeSuccessful(handler); -const unwatch = kwr.events.watchFeesDisbursed(handler); -``` - -#### ItemRegistry - -```typescript -const ir = oak.itemRegistry("0x..."); - -await ir.events.getItemAddedLogs(options?); -ir.events.decodeLog({ topics, data }); -const unwatch = ir.events.watchItemAdded(handler); -``` - -### Types - -All event methods use shared types from `@oaknetwork/contracts-sdk`: - -```typescript -import type { - DecodedEventLog, - EventFilterOptions, - EventWatchHandler, - RawLog, - SimulationResult, -} from "@oaknetwork/contracts-sdk"; - -// EventFilterOptions — optional block range for get*Logs -interface EventFilterOptions { - fromBlock?: bigint; // defaults to 0n (genesis) if omitted - toBlock?: bigint; // defaults to latest block if omitted -} - -// DecodedEventLog — returned by get*Logs and decodeLog -interface DecodedEventLog { - eventName: string; - args: Record; -} - -// RawLog — input to decodeLog -interface RawLog { - topics: readonly `0x${string}`[]; - data: `0x${string}`; -} - -// EventWatchHandler — callback for watch* methods -type EventWatchHandler = (logs: readonly DecodedEventLog[]) => void; - -// SimulationResult — returned by entity simulate methods -interface SimulationResult { - result: T; - request: { - to: Address; - data: Hex; - value?: bigint; - gas?: bigint; - }; -} -``` - -> For complete details on contract events, please visit the following link: [Events](https://oaknetwork.org/docs/contracts-sdk/events). +> Full event reference for all contracts: [Events](https://oaknetwork.org/docs/contracts-sdk/events) --- ## Error Handling -Contract calls can revert with on-chain errors. The SDK decodes raw revert data into typed error classes with decoded arguments and human-readable recovery hints. - -### Decoding revert errors: +The SDK decodes on-chain revert data into typed error classes with recovery hints. ```typescript import { parseContractError, getRevertData } from "@oaknetwork/contracts-sdk"; -function handleError(err) { - // If the error is already a typed SDK error (thrown by simulate methods) - if (typeof err?.recoveryHint === "string") { - console.error("Reverted:", err.name); - console.error("Args:", err.args); - console.error("Hint:", err.recoveryHint); - return; - } - // Otherwise extract raw revert hex from the viem error chain and decode it +try { + await factory.createCampaign({ ... }); +} catch (err) { const revertData = getRevertData(err); const parsed = parseContractError(revertData ?? ""); if (parsed) { - console.error("Reverted:", parsed.name); - console.error("Args:", parsed.args); - if (parsed.recoveryHint) console.error("Hint:", parsed.recoveryHint); - return; + console.error(parsed.name, parsed.args, parsed.recoveryHint); } - console.error("Unknown error:", err.message); -} - -try { - const txHash = await factory.createCampaign({ ... }); -} catch (err) { - handleError(err); } ``` -> See the full error handling guidelines here: [Error handling](https://oaknetwork.org/docs/contracts-sdk/error-handling) +> Full error handling guide: [Error Handling](https://oaknetwork.org/docs/contracts-sdk/error-handling) --- -## Simulation & Transaction Preparation - -Every entity exposes a `simulate` namespace that dry-runs write calls against the current chain state. Simulate methods now return a `SimulationResult` containing both the predicted return value and prepared transaction parameters — useful for gas estimation, account-abstraction (ERC-4337), or Safe multisig batching. +## Multicall -### SimulationResult +Batch multiple read calls into a single RPC round-trip: ```typescript -import type { SimulationResult } from "@oaknetwork/contracts-sdk"; - const gp = oak.globalParams("0x..."); +const ci = oak.campaignInfo("0x..."); -// Simulate a write — returns SimulationResult instead of void -const sim = await gp.simulate.enlistPlatform(hash, adminAddr, fee, adapter); - -console.log(sim.result); // Contract return value (void for most writes) -console.log(sim.request.to); // Target contract address -console.log(sim.request.data); // ABI-encoded calldata -console.log(sim.request.gas); // Estimated gas limit -console.log(sim.request.value); // Native token value (wei) +const [platformCount, goalAmount] = await oak.multicall([ + () => gp.getNumberOfListedPlatforms(), + () => ci.getGoalAmount(), +]); ``` -If the simulation reverts, a typed SDK error is thrown — the same as before. - -### Preparing Transactions Without Sending +> Full multicall documentation: [Multicall](https://oaknetwork.org/docs/contracts-sdk/multicall) -For flows where you need raw transaction parameters without sending (e.g. account-abstraction UserOps, Safe multisig, or custom signing), use `prepareContractWrite` or extract params from a simulation result with `toPreparedTransaction`: - -```typescript -import { - prepareContractWrite, - toPreparedTransaction, - GLOBAL_PARAMS_ABI, -} from "@oaknetwork/contracts-sdk"; - -// Option 1: Prepare directly from ABI + function name -const tx = await prepareContractWrite(oak.publicClient, { - address: "0x...", - abi: GLOBAL_PARAMS_ABI, - functionName: "enlistPlatform", - args: [platformHash, adminAddress, feePercent, adapterAddress], - account: "0xMyWallet...", - chain: oak.config.chain, -}); -// tx = { to, data, value, gas } - -// Option 2: Extract from an existing SimulationResult -const sim = await gp.simulate.enlistPlatform(hash, admin, fee, adapter); -const prepared = toPreparedTransaction(sim); -// prepared = { to, data, value, gas } -``` +--- -### Exported ABI Constants +## Metrics -All contract ABIs are now exported for use with `prepareContractWrite`, custom viem calls, or third-party tools: +Pre-built aggregation helpers for platform stats, campaign summaries, and treasury reports. Import from `@oaknetwork/contracts-sdk/metrics`. -```typescript -import { - GLOBAL_PARAMS_ABI, - CAMPAIGN_INFO_FACTORY_ABI, - CAMPAIGN_INFO_ABI, - TREASURY_FACTORY_ABI, - PAYMENT_TREASURY_ABI, - ALL_OR_NOTHING_ABI, - KEEP_WHATS_RAISED_ABI, - ITEM_REGISTRY_ABI, -} from "@oaknetwork/contracts-sdk"; -``` +> Full metrics documentation: [Metrics](https://oaknetwork.org/docs/contracts-sdk/metrics) --- ## Utility Functions -The SDK exports pure utility functions and constants that have no client dependency. Import them from @oaknetwork/contracts-sdk or @oaknetwork/contracts-sdk/utils. +The SDK exports common helpers with no client dependency: `keccak256`, `toHex`, `parseEther`, `formatEther`, `getCurrentTimestamp`, `addDays`, `getChainFromId`, `createWallet`, `getSigner`, `encodeFunctionData`, `prepareContractWrite`, `toPreparedTransaction`, and more. ```typescript -import { - keccak256, - id, - toHex, - stringToHex, - encodeFunctionData, - decodeFunctionResult, - decodeEventLog, - parseEther, - formatEther, - parseUnits, - isAddress, - getAddress, - getCurrentTimestamp, - addDays, - getChainFromId, - multicall, - prepareContractWrite, - toPreparedTransaction, - createJsonRpcProvider, - createWallet, - createBrowserProvider, - getSigner, - CHAIN_IDS, - BPS_DENOMINATOR, - BYTES32_ZERO, - DATA_REGISTRY_KEYS, - scopedToPlatform, -} from "@oaknetwork/contracts-sdk"; +import { keccak256, toHex, getCurrentTimestamp, addDays } from "@oaknetwork/contracts-sdk"; -// Hash a string to bytes32 const platformHash = keccak256(toHex("my-platform")); - -// Encode string to fixed bytes32 const currency = toHex("USD", { size: 32 }); - -// Timestamp helpers -const now = getCurrentTimestamp(); // bigint seconds -const deadline = addDays(now, 30); // 30 days from now - -// Fee calculations (fees are in basis points, 10_000 = 100%) -const feeAmount = (raisedAmount * platformFee) / BPS_DENOMINATOR; - -// Browser wallet (frontend) -const chain = getChainFromId(CHAIN_IDS.CELO_TESTNET_SEPOLIA); -const provider = createBrowserProvider(window.ethereum, chain); -const signer = await getSigner(window.ethereum, chain); +const now = getCurrentTimestamp(); +const deadline = addDays(now, 30); ``` -For complete guidelines on utility functions, please refer to the following link: [Utility Functions](https://oaknetwork.org/docs/contracts-sdk/utilities). +> Full utility reference: [Utilities](https://oaknetwork.org/docs/contracts-sdk/utilities) --- @@ -999,53 +349,6 @@ For complete guidelines on utility functions, please refer to the following link | `@oaknetwork/contracts-sdk/errors` | Error classes, `parseContractError`, and `toSimulationResult` | | `@oaknetwork/contracts-sdk/metrics` | Platform, campaign, and treasury reporting helpers (not re-exported from root) | -## Multicall - -Batch multiple entity read calls into a single RPC round-trip via the on-chain Multicall3 contract. Pass an array of lazy closures — the same entity read methods you'd normally `await` individually. - -### Standalone utility - -```typescript -import { multicall } from "@oaknetwork/contracts-sdk"; - -const gp = oak.globalParams("0x..."); - -const [platformCount, feePercent, admin] = await multicall([ - () => gp.getNumberOfListedPlatforms(), - () => gp.getProtocolFeePercent(), - () => gp.getProtocolAdminAddress(), -]); -``` - -### Client convenience method - -```typescript -const gp = oak.globalParams("0x..."); - -const [count, fee] = await oak.multicall([ - () => gp.getNumberOfListedPlatforms(), - () => gp.getProtocolFeePercent(), -]); -``` - -### Cross-contract batching - -Reads from different entities are batched into one RPC call automatically: - -```typescript -const gp = oak.globalParams("0x..."); -const ci = oak.campaignInfo("0x..."); -const aon = oak.allOrNothingTreasury("0x..."); - -const [platformCount, goalAmount, raisedAmount] = await oak.multicall([ - () => gp.getNumberOfListedPlatforms(), - () => ci.getGoalAmount(), - () => aon.getRaisedAmount(), -]); -``` - -> Under the hood, the SDK enables viem's `batch.multicall` transport option. All `readContract` calls dispatched within the same tick are automatically aggregated into a single Multicall3 on-chain call — no raw ABI descriptors needed. - --- ## Local Development & Testing From 3f67e0759e8fbfe297f260a92389fb80a0bbff9c Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 18:34:26 +0600 Subject: [PATCH 11/86] feat: update simulation utilities to use structured request format and improve error handling - Refactor simulateContract response handling to utilize structured request fields (address, abi, functionName, args) instead of raw to/data fields. - Enhance toSimulationResult function to encode calldata from the new request structure. - Update unit tests to reflect changes in request structure and expected results. --- .../__tests__/unit/contract-entities.test.ts | 4 +- .../__tests__/unit/error-parsing.test.ts | 40 ++++++++++++++----- .../src/errors/parse-contract-error.ts | 19 ++++++++- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/contracts/__tests__/unit/contract-entities.test.ts b/packages/contracts/__tests__/unit/contract-entities.test.ts index 03869bff..a9228588 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -10,6 +10,8 @@ import { keccak256, toHex } from "viem"; const ADDR = "0x0000000000000000000000000000000000000001" as Address; const B32 = ("0x" + "00".repeat(32)) as `0x${string}`; +const MOCK_ABI = [{ name: "mock", type: "function" as const, stateMutability: "nonpayable" as const, inputs: [], outputs: [] }] as const; + type WatchContractEventArgs = { onLogs: (logs: unknown[]) => void }; function mockPublicClient(): PublicClient { @@ -17,7 +19,7 @@ function mockPublicClient(): PublicClient { readContract: jest.fn().mockResolvedValue(0n), simulateContract: jest.fn().mockResolvedValue({ result: undefined, - request: { to: ADDR, data: "0x00" as `0x${string}`, value: 0n, gas: 21000n }, + request: { address: ADDR, abi: MOCK_ABI, functionName: "mock", args: [], value: 0n, gas: 21000n }, }), getContractEvents: jest.fn().mockResolvedValue([]), watchContractEvent: jest.fn().mockImplementation((_args: WatchContractEventArgs) => () => {}), diff --git a/packages/contracts/__tests__/unit/error-parsing.test.ts b/packages/contracts/__tests__/unit/error-parsing.test.ts index b0adeeff..e7735978 100644 --- a/packages/contracts/__tests__/unit/error-parsing.test.ts +++ b/packages/contracts/__tests__/unit/error-parsing.test.ts @@ -172,21 +172,37 @@ describe("simulateWithErrorDecode", () => { }); describe("toSimulationResult", () => { - it("maps viem simulate response to SimulationResult", () => { + const TEST_ABI = [ + { + name: "transfer", + type: "function" as const, + stateMutability: "nonpayable" as const, + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + ] as const; + + it("maps viem simulate response to SimulationResult with encoded calldata", () => { const response = { - result: 42n, + result: true, request: { - to: "0x0000000000000000000000000000000000000001", - data: "0xdeadbeef", - value: 100n, + address: "0x0000000000000000000000000000000000000001", + abi: TEST_ABI, + functionName: "transfer", + args: ["0x0000000000000000000000000000000000000002", 100n], + value: 0n, gas: 21000n, }, }; const mapped = toSimulationResult(response); - expect(mapped.result).toBe(42n); + expect(mapped.result).toBe(true); expect(mapped.request.to).toBe("0x0000000000000000000000000000000000000001"); - expect(mapped.request.data).toBe("0xdeadbeef"); - expect(mapped.request.value).toBe(100n); + expect(mapped.request.data).toMatch(/^0x/); + expect(mapped.request.data.length).toBeGreaterThan(10); + expect(mapped.request.value).toBe(0n); expect(mapped.request.gas).toBe(21000n); }); @@ -194,12 +210,16 @@ describe("toSimulationResult", () => { const response = { result: undefined, request: { - to: "0x0000000000000000000000000000000000000001", - data: "0x00", + address: "0x0000000000000000000000000000000000000001", + abi: TEST_ABI, + functionName: "transfer", + args: ["0x0000000000000000000000000000000000000002", 1n], }, }; const mapped = toSimulationResult(response); expect(mapped.result).toBeUndefined(); + expect(mapped.request.to).toBe("0x0000000000000000000000000000000000000001"); + expect(mapped.request.data).toMatch(/^0x/); expect(mapped.request.value).toBeUndefined(); expect(mapped.request.gas).toBeUndefined(); }); diff --git a/packages/contracts/src/errors/parse-contract-error.ts b/packages/contracts/src/errors/parse-contract-error.ts index dc6d68a9..246d8c09 100644 --- a/packages/contracts/src/errors/parse-contract-error.ts +++ b/packages/contracts/src/errors/parse-contract-error.ts @@ -1,4 +1,5 @@ import type { Address, Hex } from "../lib"; +import { encodeFunctionData } from "../lib"; import { isHex } from "../utils"; import type { ContractErrorBase } from "./base"; import type { SimulationResult } from "../types/events"; @@ -98,16 +99,30 @@ export async function simulateWithErrorDecode(operation: () => Prom /** * Converts the raw viem simulateContract response into the SDK's SimulationResult shape. * + * viem's simulateContract returns `{ result, request }` where `request` contains + * `address`, `abi`, `functionName`, `args` (a write-request shape for walletClient.writeContract), + * not raw `to`/`data` fields. This function encodes the calldata from those fields. + * * @param response - Raw response from publicClient.simulateContract * @returns SimulationResult with the contract return value and prepared transaction params */ export function toSimulationResult(response: { result: T; request: Record }): SimulationResult { const req = response.request; + const abi = req["abi"] as readonly unknown[]; + const functionName = req["functionName"] as string; + const args = req["args"] as readonly unknown[] | undefined; + + const data = encodeFunctionData({ + abi, + functionName, + args: args as unknown[], + }); + return { result: response.result, request: { - to: req["to"] as Address, - data: req["data"] as Hex, + to: req["address"] as Address, + data, value: req["value"] as bigint | undefined, gas: req["gas"] as bigint | undefined, }, From a9fd51287f2dbd4bb1eb90573efb93840d416a47 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 8 Apr 2026 18:47:11 +0600 Subject: [PATCH 12/86] fix: update toPreparedTransaction to handle gas estimation correctly - Modify the toPreparedTransaction function to preserve undefined gas values instead of defaulting to 0n. - Update unit tests to reflect the new behavior of gas handling in prepared transactions. --- packages/contracts/__tests__/unit/metrics.test.ts | 4 ++-- packages/contracts/src/utils/prepare.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/contracts/__tests__/unit/metrics.test.ts b/packages/contracts/__tests__/unit/metrics.test.ts index 29423c4a..79207e7a 100644 --- a/packages/contracts/__tests__/unit/metrics.test.ts +++ b/packages/contracts/__tests__/unit/metrics.test.ts @@ -288,7 +288,7 @@ describe("toPreparedTransaction", () => { expect(prepared.gas).toBe(21000n); }); - it("defaults value and gas to 0n when undefined", () => { + it("defaults value to 0n and preserves undefined gas", () => { const simResult: SimulationResult = { result: undefined, request: { @@ -299,6 +299,6 @@ describe("toPreparedTransaction", () => { const prepared = toPreparedTransaction(simResult); expect(prepared.value).toBe(0n); - expect(prepared.gas).toBe(0n); + expect(prepared.gas).toBeUndefined(); }); }); diff --git a/packages/contracts/src/utils/prepare.ts b/packages/contracts/src/utils/prepare.ts index cf3cf5ef..fb65f3bb 100644 --- a/packages/contracts/src/utils/prepare.ts +++ b/packages/contracts/src/utils/prepare.ts @@ -36,8 +36,8 @@ export interface PreparedTransaction { data: Hex; /** Native token value to send (wei). */ value: bigint; - /** Estimated gas limit. */ - gas: bigint; + /** Estimated gas limit. Undefined when the source did not include a gas estimate — callers should estimate separately before submitting. */ + gas?: bigint; } /** @@ -106,6 +106,6 @@ export function toPreparedTransaction(result: SimulationResult): PreparedTransac to: result.request.to, data: result.request.data, value: result.request.value ?? 0n, - gas: result.request.gas ?? 0n, + gas: result.request.gas, }; } From 8f6e55f1fd65ea20714d4bf71d976ffd25fbf148 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:39:32 +0600 Subject: [PATCH 13/86] feat: add scenario-based examples for platform enlistment process - Add step-by-step examples covering the complete platform onboarding flow: enlistment, verification, treasury implementation registration, and approval. - Include optional configuration steps for line item types, claim delays, platform data keys, and protocol admin functions. - Document the process in a README with roles, responsibilities, and a role reference table from the smart contract. --- .../01-enlist-platform.ts | 42 +++ .../02-verify-enlistment.ts | 41 ++ .../03-register-treasury-implementations.ts | 56 +++ .../04-approve-implementations.ts | 36 ++ .../00-platform-enlistment/05-verify-setup.ts | 65 ++++ .../06-optional-configuration.ts | 356 ++++++++++++++++++ .../examples/00-platform-enlistment/README.md | 81 ++++ 7 files changed, 677 insertions(+) create mode 100644 packages/contracts/src/examples/00-platform-enlistment/01-enlist-platform.ts create mode 100644 packages/contracts/src/examples/00-platform-enlistment/02-verify-enlistment.ts create mode 100644 packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts create mode 100644 packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts create mode 100644 packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts create mode 100644 packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts create mode 100644 packages/contracts/src/examples/00-platform-enlistment/README.md diff --git a/packages/contracts/src/examples/00-platform-enlistment/01-enlist-platform.ts b/packages/contracts/src/examples/00-platform-enlistment/01-enlist-platform.ts new file mode 100644 index 00000000..fc0c2d68 --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/01-enlist-platform.ts @@ -0,0 +1,42 @@ +/** + * Step 1: Enlist a Platform (Protocol Admin) + * + * NovaPay has contacted Oak support and agreed on terms. The Protocol Admin + * now calls `enlistPlatform` on GlobalParams. This single transaction sets: + * + * - platformHash — keccak256("NOVAPAY"), the permanent on-chain ID + * - platformAdminAddress — NovaPay's ops wallet, authorized for day-to-day actions + * - platformFeePercent — 250 bps (2.5%), within protocol limits + * - platformAdapter — 0x0 (no meta-transaction adapter) + * + * Only the protocol admin can call this. Any other caller reverts with + * GlobalParamsUnauthorizedError. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PROTOCOL_ADMIN_PRIVATE_KEY! as `0x${string}`, +}); + +const globalParams = oak.globalParams( + process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("NOVAPAY")); +const platformAdminAddress = process.env.NOVAPAY_ADMIN_ADDRESS! as `0x${string}`; +const platformFeePercent = 250n; // 2.5% in basis points +const noAdapter = "0x0000000000000000000000000000000000000000" as `0x${string}`; + +const txHash = await globalParams.enlistPlatform( + platformHash, + platformAdminAddress, + platformFeePercent, + noAdapter, +); + +const receipt = await oak.waitForReceipt(txHash); +console.log(`NovaPay enlisted at block ${receipt.blockNumber}`); +console.log("Platform hash:", platformHash); diff --git a/packages/contracts/src/examples/00-platform-enlistment/02-verify-enlistment.ts b/packages/contracts/src/examples/00-platform-enlistment/02-verify-enlistment.ts new file mode 100644 index 00000000..620dd29c --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/02-verify-enlistment.ts @@ -0,0 +1,41 @@ +/** + * Step 2: Verify Platform Enlistment (Anyone) + * + * After the Protocol Admin enlists NovaPay, anyone with a read-only client + * can verify the on-chain state. No private key is needed — these are + * pure view calls against GlobalParams. + * + * This step confirms: + * - The platform is listed + * - The admin address matches what was submitted + * - The fee percent is what was agreed + * - The adapter is unset (zero address) + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const globalParams = oak.globalParams( + process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("NOVAPAY")); + +const isListed = await globalParams.checkIfPlatformIsListed(platformHash); +console.log("Platform listed:", isListed); // true + +const adminAddress = await globalParams.getPlatformAdminAddress(platformHash); +console.log("Admin address:", adminAddress); + +const feePercent = await globalParams.getPlatformFeePercent(platformHash); +console.log("Fee percent:", Number(feePercent), "bps"); + +const adapter = await globalParams.getPlatformAdapter(platformHash); +console.log("Adapter:", adapter); // 0x0000...0000 + +const totalPlatforms = await globalParams.getNumberOfListedPlatforms(); +console.log("Total enlisted platforms:", Number(totalPlatforms)); diff --git a/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts b/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts new file mode 100644 index 00000000..6b0c5329 --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts @@ -0,0 +1,56 @@ +/** + * Step 3: Register a Treasury Implementation (Platform Admin) + * + * Now that NovaPay is enlisted, their Platform Admin registers + * the treasury model they want to use. Each model goes into a + * numbered slot (implementationId) on TreasuryFactory. + * + * A platform can register as many or as few implementations as + * they need. This example registers one (AllOrNothing at slot 0). + * Additional models can be added later at any time. + * + * Registrations are NOT immediately active — they sit in a + * "pending" state until the Protocol Admin approves them in Step 4. + * + * The implementation address is the deployed treasury master copy + * provided by the protocol team during onboarding. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.NOVAPAY_ADMIN_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryFactory = oak.treasuryFactory( + process.env.TREASURY_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("NOVAPAY")); + +// --- Register one implementation --- + +const allOrNothingImpl = process.env.ALL_OR_NOTHING_IMPL! as `0x${string}`; + +const txHash = await treasuryFactory.registerTreasuryImplementation( + platformHash, + 0n, // slot 0 + allOrNothingImpl, +); +console.log("AllOrNothing registered at slot 0:", txHash); +console.log("Awaiting Protocol Admin approval before it can be used."); + +// --- Optional: register additional models at other slots --- +// +// A platform can fill as many slots as they need. The slot ID is +// an integer you choose; register and deploy must use the same ID. +// +// const keepWhatsRaisedImpl = process.env.KEEP_WHATS_RAISED_IMPL! as `0x${string}`; +// await treasuryFactory.registerTreasuryImplementation(platformHash, 1n, keepWhatsRaisedImpl); +// +// const paymentTreasuryImpl = process.env.PAYMENT_TREASURY_IMPL! as `0x${string}`; +// await treasuryFactory.registerTreasuryImplementation(platformHash, 2n, paymentTreasuryImpl); +// +// Each slot requires a separate Protocol Admin approval. diff --git a/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts b/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts new file mode 100644 index 00000000..a1dab162 --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts @@ -0,0 +1,36 @@ +/** + * Step 4: Approve a Treasury Implementation (Protocol Admin) + * + * The Platform Admin registered an AllOrNothing implementation at + * slot 0 in Step 3. It cannot be used until the Protocol Admin + * explicitly approves it. This is a safety gate — the protocol + * team verifies the implementation contract before allowing the + * platform to deploy treasuries from it. + * + * Each registered slot requires its own approval call. If the + * platform registered multiple slots, approve each one separately. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PROTOCOL_ADMIN_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryFactory = oak.treasuryFactory( + process.env.TREASURY_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("NOVAPAY")); + +// Approve the AllOrNothing implementation at slot 0 +const txHash = await treasuryFactory.approveTreasuryImplementation(platformHash, 0n); +console.log("AllOrNothing approved (slot 0):", txHash); +console.log("NovaPay can now deploy AllOrNothing treasuries."); + +// --- If additional slots were registered, approve each one --- +// +// await treasuryFactory.approveTreasuryImplementation(platformHash, 1n); +// await treasuryFactory.approveTreasuryImplementation(platformHash, 2n); diff --git a/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts new file mode 100644 index 00000000..9625246f --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts @@ -0,0 +1,65 @@ +/** + * Step 5: Verify Full Platform Setup (Platform Admin) + * + * Before going live, NovaPay's admin runs a final check to confirm + * every piece of the onboarding is in place: + * + * 1. Platform is enlisted on GlobalParams + * 2. Admin address and fee percent match the agreed terms + * 3. Treasury implementations are registered and approved + * + * Once everything checks out, NovaPay is fully onboarded and can + * begin creating campaigns and deploying treasuries through the SDK. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const globalParams = oak.globalParams( + process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`, +); + +const treasuryFactory = oak.treasuryFactory( + process.env.TREASURY_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("NOVAPAY")); + +// 1. Confirm platform state on GlobalParams +const isListed = await globalParams.checkIfPlatformIsListed(platformHash); +const adminAddress = await globalParams.getPlatformAdminAddress(platformHash); +const feePercent = await globalParams.getPlatformFeePercent(platformHash); +const claimDelay = await globalParams.getPlatformClaimDelay(platformHash); + +console.log("=== GlobalParams ==="); +console.log("Listed:", isListed); +console.log("Admin:", adminAddress); +console.log("Fee:", Number(feePercent), "bps"); +console.log("Claim delay:", Number(claimDelay), "seconds"); + +// 2. Confirm treasury registrations via events +const registeredLogs = await treasuryFactory.events.getImplementationRegisteredLogs(); +const approvalLogs = await treasuryFactory.events.getImplementationApprovalLogs(); + +console.log("\n=== TreasuryFactory ==="); +console.log("Registered implementations:", registeredLogs.length); +console.log("Approval events:", approvalLogs.length); + +// 3. Confirm enlistment event was emitted +const enlistmentLogs = await globalParams.events.getPlatformEnlistedLogs(); +const novaPayLog = enlistmentLogs.find( + (log) => log.args?.platformHash === platformHash, +); + +if (novaPayLog) { + console.log("\n=== Enlistment Confirmed ==="); + console.log("Event:", novaPayLog.eventName); + console.log("Platform hash:", novaPayLog.args?.platformHash); + console.log("NovaPay is fully onboarded and ready to launch campaigns."); +} else { + console.error("Enlistment event not found — check the transaction."); +} diff --git a/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts new file mode 100644 index 00000000..6519e901 --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts @@ -0,0 +1,356 @@ +/** + * Step 6: Optional Platform Configuration (Platform Admin / Protocol Admin) + * + * This file collects all optional configuration steps that can be + * performed after the core onboarding (Steps 1–5). None of these + * are required to get started — you can skip any or all and come + * back later. Each section is independent. + * + * Contents: + * + * A. Line Item Types (Platform Admin, PaymentTreasury only) + * — Define how payment components (product, shipping, tax) are + * categorized on-chain: goal contribution, fees, refundability. + * Includes removing a line item type. + * + * B. Claim Delay (Platform Admin, PaymentTreasury only) + * — Set a safety window after a treasury's deadline that protects + * buyers before the platform can sweep remaining funds + * + * C. Platform Data Keys (Platform Admin) + * — Register custom metadata fields for campaigns (e.g., category, + * internal order ID). Includes reading data key ownership and + * removing a key. + * + * D. Platform Adapter (Protocol Admin) + * — Set an ERC-2771 trusted forwarder to enable gasless transactions + * across all treasury types + * + * E. Protocol Admin Functions (Protocol Admin only) + * — Currency/token management, global data registry, delisting, + * admin address updates, fee updates. Listed for completeness; + * platforms coordinate with Oak support for these. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +// ============================================================ +// A. Line Item Types (PaymentTreasury Only) +// ============================================================ +// +// Line item types define how different components of a payment are +// categorized and handled on-chain. Each type controls: +// +// - countsTowardGoal — does this amount count toward the funding target? +// - applyProtocolFee — does the protocol fee apply? +// - canRefund — can the buyer claim a refund for this item? +// - instantTransfer — are funds transferred immediately on confirmation? +// +// NovaPay sets up "product" (refundable, counts toward goal) and +// "shipping" (non-refundable, instant transfer, no protocol fee). + +async function setupLineItemTypes(): Promise { + const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.NOVAPAY_ADMIN_PRIVATE_KEY! as `0x${string}`, + }); + + const globalParams = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); + const platformHash = keccak256(toHex("NOVAPAY")); + + // "product" line item type + // + // Constraint: when countsTowardGoal is true, applyProtocolFee must be + // false, canRefund must be true, and instantTransfer must be false. + const productTypeId = keccak256(toHex("product")); + const tx1 = await globalParams.setPlatformLineItemType( + platformHash, + productTypeId, + "product", + true, // countsTowardGoal + false, // applyProtocolFee (must be false when countsTowardGoal is true) + true, // canRefund (must be true when countsTowardGoal is true) + false, // instantTransfer (must be false when countsTowardGoal is true) + ); + console.log("Line item type 'product' set:", tx1); + + // "shipping" line item type + const shippingTypeId = keccak256(toHex("shipping")); + const tx2 = await globalParams.setPlatformLineItemType( + platformHash, + shippingTypeId, + "shipping", + false, // countsTowardGoal + false, // applyProtocolFee + false, // canRefund + true, // instantTransfer + ); + console.log("Line item type 'shipping' set:", tx2); + + // Verify + const productInfo = await globalParams.getPlatformLineItemType(platformHash, productTypeId); + console.log("Product type:", { + exists: productInfo.exists, + countsTowardGoal: productInfo.countsTowardGoal, + applyProtocolFee: productInfo.applyProtocolFee, + canRefund: productInfo.canRefund, + instantTransfer: productInfo.instantTransfer, + }); + + // --- Remove a line item type (optional) --- + // + // If a line item type is no longer needed, the Platform Admin can + // remove it. This sets `exists` to false, preventing new payments + // from using that type. Existing payments are unaffected. + + // const removeTx = await globalParams.removePlatformLineItemType( + // platformHash, + // shippingTypeId, + // ); + // await oak.waitForReceipt(removeTx); + // console.log("'shipping' line item type removed"); +} + +// ============================================================ +// B. Claim Delay (PaymentTreasury Only) +// ============================================================ +// +// The claim delay is a safety window after a PaymentTreasury's +// deadline. Until it expires, `claimExpiredFunds()` reverts with +// `PaymentTreasuryClaimWindowNotReached`. +// +// Formula: claimableAt = deadline + claimDelay +// +// A 7-day delay gives buyers a full week after the deadline to +// claim refunds before the platform can sweep remaining funds. +// Default is 0 (platform can claim immediately after deadline). + +async function setClaimDelay(): Promise { + const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.NOVAPAY_ADMIN_PRIVATE_KEY! as `0x${string}`, + }); + + const globalParams = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); + const platformHash = keccak256(toHex("NOVAPAY")); + + const sevenDaysInSeconds = 7n * 24n * 60n * 60n; // 604,800 seconds + const txHash = await globalParams.updatePlatformClaimDelay(platformHash, sevenDaysInSeconds); + await oak.waitForReceipt(txHash); + console.log("Claim delay set to 7 days"); + + const claimDelay = await globalParams.getPlatformClaimDelay(platformHash); + console.log("Current claim delay:", Number(claimDelay), "seconds"); +} + +// ============================================================ +// C. Platform Data Keys +// ============================================================ +// +// Platform data keys provide a key-value metadata store for campaigns. +// The Platform Admin registers valid keys in GlobalParams using +// `addPlatformData`. Campaign creators pass key-value pairs when +// calling `createCampaign` — the factory validates each key. +// +// Platform data is purely informational — not used by any treasury. +// Common uses: campaign categories, platform-specific IDs, tagging. + +async function registerPlatformDataKeys(): Promise { + const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.NOVAPAY_ADMIN_PRIVATE_KEY! as `0x${string}`, + }); + + const globalParams = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); + const platformHash = keccak256(toHex("NOVAPAY")); + + // Register a "category" data key + const categoryKey = keccak256(toHex("novapay:category")); + const tx1 = await globalParams.addPlatformData(platformHash, categoryKey); + await oak.waitForReceipt(tx1); + console.log("Data key 'novapay:category' registered"); + + // Register an "internal-id" data key + const internalIdKey = keccak256(toHex("novapay:internal-id")); + const tx2 = await globalParams.addPlatformData(platformHash, internalIdKey); + await oak.waitForReceipt(tx2); + console.log("Data key 'novapay:internal-id' registered"); + + // Verify + const isValid = await globalParams.checkIfPlatformDataKeyValid(categoryKey); + console.log("'novapay:category' valid:", isValid); // true + + const owner = await globalParams.getPlatformDataOwner(categoryKey); + console.log("Data key owner:", owner); // should match platformHash + + // How creators use these keys: + // + // await factory.createCampaign({ + // ...campaignData, + // platformDataKey: [categoryKey, internalIdKey], + // platformDataValue: [ + // toHex("electronics", { size: 32 }), + // toHex("NP-2026-00451", { size: 32 }), + // ], + // }); + + // --- Remove a platform data key (optional) --- + // + // If a data key is no longer needed, the Platform Admin can remove + // it. After removal, `checkIfPlatformDataKeyValid` returns false + // and `getPlatformDataOwner` returns zero bytes. + + // const removeTx = await globalParams.removePlatformData(platformHash, internalIdKey); + // await oak.waitForReceipt(removeTx); + // console.log("'novapay:internal-id' data key removed"); +} + +// ============================================================ +// D. Platform Adapter (Meta-Transactions) — Protocol Admin +// ============================================================ +// +// The platform adapter is an ERC-2771 trusted forwarder that enables +// gasless meta-transactions across all treasury types. +// +// How it works: +// 1. Protocol Admin sets the adapter via `setPlatformAdapter` +// (this is onlyOwner — the platform admin cannot set it) +// 2. When a treasury is deployed, it receives the adapter address +// 3. Transactions from the adapter extract the real sender from +// the last 20 bytes of calldata (standard ERC-2771) +// +// Set to the zero address to disable (the default). + +async function setPlatformAdapter(): Promise { + const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PROTOCOL_ADMIN_PRIVATE_KEY! as `0x${string}`, + }); + + const globalParams = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); + const platformHash = keccak256(toHex("NOVAPAY")); + const adapterAddress = process.env.NOVAPAY_ADAPTER_ADDRESS! as `0x${string}`; + + const txHash = await globalParams.setPlatformAdapter(platformHash, adapterAddress); + await oak.waitForReceipt(txHash); + console.log("Platform adapter set to:", adapterAddress); + + const currentAdapter = await globalParams.getPlatformAdapter(platformHash); + console.log("Current adapter:", currentAdapter); + + // To disable later: + // await globalParams.setPlatformAdapter( + // platformHash, + // "0x0000000000000000000000000000000000000000" as `0x${string}`, + // ); +} + +// ============================================================ +// E. Protocol Admin Functions (Protocol Admin Only) +// ============================================================ +// +// The functions below are restricted to the contract owner +// (Protocol Admin). Platform admins cannot call them. They are +// listed here for completeness — a platform would coordinate +// with the Oak support team to request any of these actions. + +async function protocolAdminExamples(): Promise { + const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PROTOCOL_ADMIN_PRIVATE_KEY! as `0x${string}`, + }); + + const globalParams = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); + const platformHash = keccak256(toHex("NOVAPAY")); + + // --- Currency management --- + // + // The Protocol Admin manages which ERC-20 tokens are accepted + // for each currency. Campaigns specify a currency (e.g., "USD"), + // and the protocol resolves it to a list of accepted token addresses. + + const usdCurrency = toHex("USD", { size: 32 }); + const cusdToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; + + // const addTokenTx = await globalParams.addTokenToCurrency(usdCurrency, cusdToken); + // await oak.waitForReceipt(addTokenTx); + // console.log("cUSD added as accepted token for USD"); + + const usdTokens = await globalParams.getTokensForCurrency(usdCurrency); + console.log("USD accepted tokens:", usdTokens); + + // const removeTokenTx = await globalParams.removeTokenFromCurrency(usdCurrency, cusdToken); + // await oak.waitForReceipt(removeTokenTx); + // console.log("cUSD removed from USD currency"); + + // --- Global data registry --- + // + // A key-value store for protocol-level data (e.g., storing + // the CampaignInfoFactory address, treasury templates, or + // other protocol constants). + + // const registryKey = keccak256(toHex("campaignInfoFactory")); + // const registryValue = toHex(process.env.CAMPAIGN_INFO_FACTORY_ADDRESS!, { size: 32 }); + // const registryTx = await globalParams.addToRegistry(registryKey, registryValue); + // await oak.waitForReceipt(registryTx); + // console.log("Registry entry added"); + + const factoryKey = keccak256(toHex("campaignInfoFactory")); + const factoryValue = await globalParams.getFromRegistry(factoryKey); + console.log("Registry value for 'campaignInfoFactory':", factoryValue); + + // --- Delist a platform --- + // + // Removes a platform from the protocol entirely. The platform + // admin address and fee percent are reset to zero. Existing + // deployed treasuries continue to function, but no new ones + // can be created. + + // const delistTx = await globalParams.delistPlatform(platformHash); + // await oak.waitForReceipt(delistTx); + // console.log("Platform delisted"); + + // --- Update platform admin address --- + // + // Changes the admin wallet for a platform. Only the Protocol + // Admin can do this (not the current platform admin). + + // const newAdmin = process.env.NOVAPAY_NEW_ADMIN_ADDRESS! as `0x${string}`; + // const updateAdminTx = await globalParams.updatePlatformAdminAddress(platformHash, newAdmin); + // await oak.waitForReceipt(updateAdminTx); + // console.log("Platform admin updated to:", newAdmin); + + // --- Update protocol fee percent --- + + // const updateFeeTx = await globalParams.updateProtocolFeePercent(300n); // 3% + // await oak.waitForReceipt(updateFeeTx); + // console.log("Protocol fee updated to 300 bps"); + + const protocolFee = await globalParams.getProtocolFeePercent(); + console.log("Current protocol fee:", Number(protocolFee), "bps"); + + // --- Update protocol admin address --- + + // const newProtocolAdmin = process.env.NEW_PROTOCOL_ADMIN_ADDRESS! as `0x${string}`; + // const updateProtocolAdminTx = await globalParams.updateProtocolAdminAddress(newProtocolAdmin); + // await oak.waitForReceipt(updateProtocolAdminTx); + // console.log("Protocol admin updated"); + + const protocolAdmin = await globalParams.getProtocolAdminAddress(); + console.log("Current protocol admin:", protocolAdmin); + + const contractOwner = await globalParams.owner(); + console.log("Contract owner:", contractOwner); +} + +// Run the configuration you need: +// await setupLineItemTypes(); +// await setClaimDelay(); +// await registerPlatformDataKeys(); +// await setPlatformAdapter(); +// await protocolAdminExamples(); diff --git a/packages/contracts/src/examples/00-platform-enlistment/README.md b/packages/contracts/src/examples/00-platform-enlistment/README.md new file mode 100644 index 00000000..8b3e4b21 --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/README.md @@ -0,0 +1,81 @@ +# Scenario 0: Platform Enlistment + +## The Story + +**NovaPay** is a digital marketplace that helps independent sellers accept payments online. They want to integrate Oak Protocol to offer their merchants on-chain payment processing and crowdfunding capabilities. Before any campaign can be created or treasury deployed on their platform, NovaPay must first be **enlisted as a platform** on the protocol. + +Platform enlistment is a coordinated process between two roles: + +- The **Protocol Admin** (the Oak Network team) — who governs the GlobalParams contract and must approve every new platform joining the protocol +- The **Platform Admin** (NovaPay's operations wallet) — who will manage the platform's day-to-day configuration once enlisted, such as treasury registration, fee settings, and payment operations + +There is no self-service signup. NovaPay contacts the Oak support team, provides their admin wallet address, and agrees on a fee structure. The Protocol Admin then records the enlistment on-chain in a single transaction. + +Once enlisted, NovaPay registers the treasury implementation contracts they want to use. A platform can register as many or as few treasury models as they need — even a single model is enough to get started. Each registration enters a "pending" state and must be explicitly approved by the Protocol Admin before it can be used to deploy treasuries. + +## How It Unfolds + +1. **Protocol Admin** enlists NovaPay by calling `enlistPlatform` on GlobalParams — this sets the platform hash, admin address, fee percent, and adapter in one transaction +2. **Anyone** can verify the enlistment by reading back the on-chain state: is the platform listed, who is the admin, what is the fee percent +3. **Platform Admin (NovaPay)** registers a treasury implementation on TreasuryFactory — one call per implementation slot. A platform only needs to register the models they plan to use +4. **Protocol Admin** approves the registered implementation — only approved implementations can be used to deploy treasuries +5. **Platform Admin (NovaPay)** runs a final verification to confirm every piece of the onboarding is in place +6. **Platform Admin (NovaPay)** — optionally — configures additional features like line item types, claim delay, platform data keys, or a meta-transaction adapter. These are all collected in a single reference file + +## Platform Hash + +Every platform on Oak Protocol is identified by a `bytes32` value called the **platform hash**. It is the `keccak256` hash of the platform name and remains fixed for the lifetime of the platform. It is used everywhere — in GlobalParams, TreasuryFactory, and every campaign created on the platform. + +```typescript +const platformHash = keccak256(toHex("NOVAPAY")); +``` + +## Implementation ID Layout + +Each platform maintains its own mapping of implementation ID to treasury contract inside TreasuryFactory. The implementation ID is a numeric slot that you choose when registering. The same ID is used when deploying a treasury from that slot. Register only the models your platform needs: + +| Implementation ID | Treasury Model | Use Case | +| --- | --- | --- | +| `0n` | AllOrNothing | Crowdfunding — backers get a full refund if the goal is not met | +| `1n` | KeepWhatsRaised | Crowdfunding — the creator keeps whatever is raised, even if the goal is not met | +| `2n` | PaymentTreasury | E-commerce — structured payments with line items, confirmations, and refunds | + +## Optional Configuration (Step 6) + +After the core onboarding, a platform can configure additional features. These are all optional and independent — skip any you don't need. They are documented in `06-optional-configuration.ts`: + +| Section | Feature | Who Calls | Applies To | Description | +| --- | --- | --- | --- | --- | +| A | Line Item Types | Platform Admin | PaymentTreasury only | Define how payment components are categorized (+ remove a type) | +| B | Claim Delay | Platform Admin | PaymentTreasury only | Set a buyer-protection window after a treasury's deadline | +| C | Platform Data Keys | Platform Admin | All treasury types | Register/remove custom metadata fields for campaigns | +| D | Platform Adapter | Protocol Admin | All treasury types | Enable gasless meta-transactions via an ERC-2771 trusted forwarder | +| E | Protocol Admin Functions | Protocol Admin | Protocol-wide | Currency/token management, data registry, delisting, fee/admin updates | + +## Files + +| Step | File | Role | Description | +| --- | --- | --- | --- | +| 1 | `01-enlist-platform.ts` | Protocol Admin | Enlist NovaPay with admin address, fee percent, and adapter | +| 2 | `02-verify-enlistment.ts` | Anyone | Read back on-chain state to confirm the enlistment | +| 3 | `03-register-treasury-implementations.ts` | Platform Admin | Register a treasury implementation on TreasuryFactory | +| 4 | `04-approve-implementations.ts` | Protocol Admin | Approve the registered implementation for use | +| 5 | `05-verify-setup.ts` | Platform Admin | Run a final check to confirm everything is live | +| 6 | `06-optional-configuration.ts` | Platform Admin / Protocol Admin | All optional configuration — line items, claim delay, data keys, adapter, protocol admin functions | + +## Role Reference (from the Smart Contract) + +| Function | Who can call | Contract modifier | +| --- | --- | --- | +| `enlistPlatform` | Protocol Admin | `onlyOwner` | +| `delistPlatform` | Protocol Admin | `onlyOwner` | +| `updatePlatformAdminAddress` | Protocol Admin | `onlyOwner` | +| `updateProtocolAdminAddress` | Protocol Admin | `onlyOwner` | +| `updateProtocolFeePercent` | Protocol Admin | `onlyOwner` | +| `addTokenToCurrency` / `removeTokenFromCurrency` | Protocol Admin | `onlyOwner` | +| `addToRegistry` | Protocol Admin | `onlyOwner` | +| `setPlatformAdapter` | Protocol Admin | `onlyOwner` | +| `setPlatformLineItemType` / `removePlatformLineItemType` | Platform Admin | `onlyPlatformAdmin` | +| `updatePlatformClaimDelay` | Platform Admin | `onlyPlatformAdmin` | +| `addPlatformData` / `removePlatformData` | Platform Admin | `onlyPlatformAdmin` | +| All `get*` / `check*` reads | Anyone | (read-only) | From 5911b8ea2515459db86834924fe16db1a868bc15 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:40:31 +0600 Subject: [PATCH 14/86] feat: implement all-or-nothing crowdfunding campaign example - Add a comprehensive set of examples demonstrating the all-or-nothing crowdfunding process, including campaign creation, treasury deployment, reward management, backer pledges, and monitoring campaign progress. - Include functionality for fee disbursement, fund withdrawal upon success, and refund claims upon failure. - Document the entire flow in a README, outlining roles, responsibilities, and a reference table for smart contract functions. --- .../01-create-campaign.ts | 58 ++++++++++++++ .../02-lookup-campaign.ts | 31 ++++++++ .../03-review-campaign.ts | 40 ++++++++++ .../04-deploy-treasury.ts | 44 +++++++++++ .../05-manage-rewards.ts | 78 +++++++++++++++++++ .../06-backer-pledge.ts | 78 +++++++++++++++++++ .../07-monitor-progress.ts | 65 ++++++++++++++++ .../08-disburse-fees.ts | 39 ++++++++++ .../09a-success-withdraw.ts | 34 ++++++++ .../09b-failure-refund.ts | 41 ++++++++++ .../10-pause-unpause-treasury.ts | 43 ++++++++++ .../11-cancel-treasury.ts | 41 ++++++++++ .../01-campaign-all-or-nothing/README.md | 56 +++++++++++++ 13 files changed, 648 insertions(+) create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/03-review-campaign.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/05-manage-rewards.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/07-monitor-progress.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/08-disburse-fees.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/11-cancel-treasury.ts create mode 100644 packages/contracts/src/examples/01-campaign-all-or-nothing/README.md diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts new file mode 100644 index 00000000..745882b2 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts @@ -0,0 +1,58 @@ +/** + * Step 1: Create a Campaign (Creator) + * + * Maya wants to crowdfund $5,000 for her "Earth & Fire" ceramic collection. + * She creates the campaign through the CampaignInfoFactory, which deploys + * a new CampaignInfo contract on-chain. The campaign includes: + * + * - A $5,000 funding goal (in 6-decimal token units) + * - A 30-day deadline from today + * - ArtFund as the selected platform (identified by its platform hash) + * - NFT metadata so each backer receives a collectible receipt + * + * The factory assigns a unique contract address to the campaign, which + * Maya will look up in the next step. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + getCurrentTimestamp, + addDays, + CHAIN_IDS, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("artfund")); +const identifierHash = keccak256(toHex("earth-and-fire-2026")); +const currency = toHex("USD", { size: 32 }); +const now = getCurrentTimestamp(); + +const txHash = await factory.createCampaign({ + creator: process.env.MAYA_ADDRESS! as `0x${string}`, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now + 3600n, // launches 1 hour from now + deadline: addDays(now, 30), // 30-day campaign + goalAmount: 5_000_000_000n, // $5,000 (assuming 6-decimal token) + currency, + }, + nftName: "Earth & Fire Backers", + nftSymbol: "EF26", + nftImageURI: "ipfs://QmXyz.../earth-fire.png", + contractURI: "ipfs://QmXyz.../metadata.json", +}); + +const receipt = await oak.waitForReceipt(txHash); +console.log(`Campaign created at block ${receipt.blockNumber}`); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts new file mode 100644 index 00000000..c1d5ac27 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts @@ -0,0 +1,31 @@ +/** + * Step 2: Look Up the Campaign Address (Creator) + * + * After creating the campaign in Step 1, Maya needs to find the address + * of the deployed CampaignInfo contract. She uses the same identifier hash + * she chose during creation — this acts as a human-readable lookup key. + * + * She also validates that the address is recognized by the factory as a + * legitimate campaign, which is useful for front-end verification before + * displaying campaign data to users. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +const identifierHash = keccak256(toHex("earth-and-fire-2026")); + +const campaignInfoAddress = await factory.identifierToCampaignInfo(identifierHash); +console.log("CampaignInfo deployed at:", campaignInfoAddress); + +const isValid = await factory.isValidCampaignInfo(campaignInfoAddress); +console.log("Is valid campaign:", isValid); // true diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/03-review-campaign.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/03-review-campaign.ts new file mode 100644 index 00000000..522e3f08 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/03-review-campaign.ts @@ -0,0 +1,40 @@ +/** + * Step 3: Review Campaign Details (Creator) + * + * Before sharing the campaign link with her community, Maya reads back + * the on-chain campaign details to confirm everything matches her intent: + * launch time, deadline, funding goal, currency, selected platforms, + * and the protocol configuration (treasury factory address, protocol fee). + * + * This verification step is good practice — it catches configuration + * mistakes before backers start pledging. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, +}); + +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; +const campaign = oak.campaignInfo(campaignInfoAddress); + +const launchTime = await campaign.getLaunchTime(); +const deadline = await campaign.getDeadline(); +const goalAmount = await campaign.getGoalAmount(); +const campaignCurrency = await campaign.getCampaignCurrency(); + +console.log("Launch:", new Date(Number(launchTime) * 1000).toISOString()); +console.log("Deadline:", new Date(Number(deadline) * 1000).toISOString()); +console.log("Goal: $", Number(goalAmount) / 1_000_000); +console.log("Currency:", campaignCurrency); + +const platformHash = keccak256(toHex("artfund")); +const isPlatformSelected = await campaign.checkIfPlatformSelected(platformHash); +console.log("ArtFund selected:", isPlatformSelected); + +const config = await campaign.getCampaignConfig(); +console.log("Treasury factory:", config.treasuryFactory); +console.log("Protocol fee:", Number(config.protocolFeePercent), "bps"); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts new file mode 100644 index 00000000..3067202c --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts @@ -0,0 +1,44 @@ +/** + * Step 4: Deploy an All-or-Nothing Treasury (Creator) + * + * Every campaign needs a treasury — the smart contract that holds all + * pledged funds until the campaign outcome is decided. Maya deploys an + * All-or-Nothing treasury through the TreasuryFactory. + * + * The factory creates a new treasury clone linked to Maya's campaign + * and emits a TreasuryDeployed event containing the treasury address. + * Maya reads this event to discover the address of her new treasury. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryFactory = oak.treasuryFactory( + process.env.TREASURY_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("artfund")); +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; +const allOrNothingImplementationId = 0n; + +const deployTxHash = await treasuryFactory.deploy( + platformHash, + campaignInfoAddress, + allOrNothingImplementationId, +); + +const deployReceipt = await oak.waitForReceipt(deployTxHash); +console.log(`Treasury deployed at block ${deployReceipt.blockNumber}`); + +// Get the deployed treasury address from the event +const logs = await treasuryFactory.events.getTreasuryDeployedLogs({ + fromBlock: BigInt(deployReceipt.blockNumber), +}); + +const treasuryAddress = logs[0]?.args?.treasury; +console.log("All-or-Nothing treasury deployed at:", treasuryAddress); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/05-manage-rewards.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/05-manage-rewards.ts new file mode 100644 index 00000000..ec2d3ee7 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/05-manage-rewards.ts @@ -0,0 +1,78 @@ +/** + * Step 5: Manage Reward Tiers (Creator) + * + * Maya sets up reward tiers for her backers. Each tier has a minimum + * pledge value. When a backer pledges at a tier, they receive an NFT + * receipt representing their pledge and chosen reward. + * + * This file covers both adding and removing rewards: + * + * - `addRewards` — registers one or more tiers in a single call + * - `removeReward` — deletes a tier by its bytes32 name (e.g., if + * the creator decides a tier is not cost-effective) + * - `getReward` — reads back a tier's details to verify + * + * Removing a reward is optional — most campaigns keep their tiers + * unchanged. Once removed, no new backers can pledge for that tier. + * Existing pledges for other tiers are unaffected. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = oak.allOrNothingTreasury(treasuryAddress); + +// --- Add reward tiers --- + +const stickerReward = keccak256(toHex("sticker-pack")); +const printReward = keccak256(toHex("signed-print")); +const originalReward = keccak256(toHex("original-piece")); + +const addTxHash = await treasury.addRewards( + [stickerReward, printReward, originalReward], + [ + { + rewardValue: 25_000_000n, // $25 minimum pledge + isRewardTier: true, + itemId: [], + itemValue: [], + itemQuantity: [], + }, + { + rewardValue: 100_000_000n, // $100 minimum pledge + isRewardTier: true, + itemId: [], + itemValue: [], + itemQuantity: [], + }, + { + rewardValue: 250_000_000n, // $250 minimum pledge + isRewardTier: true, + itemId: [], + itemValue: [], + itemQuantity: [], + }, + ], +); + +await oak.waitForReceipt(addTxHash); +console.log("Reward tiers added: Sticker Pack ($25), Signed Print ($100), Original Piece ($250)"); + +// --- Remove a reward tier (optional) --- +// +// Maya decides the $25 Sticker Pack tier is not cost-effective. +// She removes it before any backers have pledged for it. + +const removeTxHash = await treasury.removeReward(stickerReward); +await oak.waitForReceipt(removeTxHash); +console.log('"Sticker Pack" reward removed'); + +// Verify the reward no longer exists +const removedReward = await treasury.getReward(stickerReward); +console.log("Removed reward value:", removedReward.rewardValue); // 0n diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts new file mode 100644 index 00000000..5fe1e5e0 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts @@ -0,0 +1,78 @@ +/** + * Step 6: Backer Pledges (Backer) + * + * Backers can pledge in two ways: + * + * 1. `pledgeForAReward` — choose a specific reward tier and pledge + * the minimum amount required for that tier + * 2. `pledgeWithoutAReward` — pledge a flat token amount without + * selecting any reward tier + * + * In both cases, the treasury transfers the backer's ERC-20 tokens + * into the treasury and mints an NFT receipt to the backer's wallet. + * This NFT serves two purposes: + * - It proves the pledge and entitles the holder to the reward + * (if the campaign succeeds) + * - It can be used to claim a full refund (if the campaign fails) + * + * Prerequisite: the backer must have already approved the treasury + * contract to spend their ERC-20 tokens. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +// --- Pledge FOR a reward --- +// +// Alex pledges $100 for the "Signed Print" tier. + +const alexOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.ALEX_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const alexTreasury = alexOak.allOrNothingTreasury(treasuryAddress); + +const pledgeToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const shippingFee = 5_000_000n; // $5 shipping +const printReward = keccak256(toHex("signed-print")); + +const pledgeTxHash = await alexTreasury.pledgeForAReward( + process.env.ALEX_ADDRESS! as `0x${string}`, + pledgeToken, + shippingFee, + [printReward], +); + +const pledgeReceipt = await alexOak.waitForReceipt(pledgeTxHash); +console.log(`Alex pledged for "Signed Print" at block ${pledgeReceipt.blockNumber}`); + +const alexBalance = await alexTreasury.balanceOf(process.env.ALEX_ADDRESS! as `0x${string}`); +console.log("Alex's NFT balance:", alexBalance); // 1n + +// --- Pledge WITHOUT a reward --- +// +// Sam wants to support Maya without choosing a tier. He pledges +// a flat $50. He still receives an NFT receipt and is entitled +// to a full refund if the campaign fails. + +const samOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.SAM_PRIVATE_KEY! as `0x${string}`, +}); + +const samTreasury = samOak.allOrNothingTreasury(treasuryAddress); + +const samPledgeTxHash = await samTreasury.pledgeWithoutAReward( + process.env.SAM_ADDRESS! as `0x${string}`, + pledgeToken, + 50_000_000n, // $50 +); + +await samOak.waitForReceipt(samPledgeTxHash); +console.log("Sam pledged $50 (no reward)"); + +const samBalance = await samTreasury.balanceOf(process.env.SAM_ADDRESS! as `0x${string}`); +console.log("Sam's NFT balance:", samBalance); // 1n diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/07-monitor-progress.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/07-monitor-progress.ts new file mode 100644 index 00000000..5c04d43d --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/07-monitor-progress.ts @@ -0,0 +1,65 @@ +/** + * Step 7: Monitor Campaign Progress (Anyone) + * + * One of the strengths of on-chain crowdfunding is transparency. + * Anyone — Maya, her backers, journalists, or curious visitors — + * can check the campaign's progress at any time using read-only + * calls. No wallet or private key is needed, just an RPC endpoint. + * + * This step combines reads from both the CampaignInfo contract + * (goal, deadline, currency) and the AllOrNothing treasury + * (raised amount, lifetime raised, refunded, platform hash, fees, + * reward tiers, paused/cancelled state). + * + * This is the kind of data a campaign dashboard would display: + * progress percentage, total raised, days remaining, treasury + * health, and whether the goal has been reached. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; + +const campaign = oak.campaignInfo(campaignInfoAddress); +const treasury = oak.allOrNothingTreasury(treasuryAddress); + +// --- CampaignInfo reads --- +const goalAmount = await campaign.getGoalAmount(); +const deadline = await campaign.getDeadline(); +const now = BigInt(Math.floor(Date.now() / 1000)); + +// --- Treasury reads --- +const raisedAmount = await treasury.getRaisedAmount(); +const lifetimeRaised = await treasury.getLifetimeRaisedAmount(); +const refundedAmount = await treasury.getRefundedAmount(); +const platformHash = await treasury.getPlatformHash(); +const platformFeePercent = await treasury.getPlatformFeePercent(); +const isPaused = await treasury.paused(); +const isCancelled = await treasury.cancelled(); + +// Inspect a specific reward tier +const printReward = keccak256(toHex("signed-print")); +const rewardDetails = await treasury.getReward(printReward); + +// --- Dashboard output --- +const progressPercent = goalAmount > 0n ? Number((raisedAmount * 100n) / goalAmount) : 0; +const daysRemaining = deadline > now ? Number((deadline - now) / 86400n) : 0; + +console.log("=== Campaign Dashboard ==="); +console.log(`Goal: $${Number(goalAmount) / 1_000_000}`); +console.log(`Raised: $${Number(raisedAmount) / 1_000_000} (${progressPercent}%)`); +console.log(`Lifetime Raised: $${Number(lifetimeRaised) / 1_000_000}`); +console.log(`Refunded: $${Number(refundedAmount) / 1_000_000}`); +console.log(`Days Remaining: ${daysRemaining}`); +console.log(`Goal Reached: ${raisedAmount >= goalAmount ? "YES" : "Not yet"}`); +console.log(`Platform Hash: ${platformHash}`); +console.log(`Platform Fee: ${Number(platformFeePercent)} basis points`); +console.log(`Treasury Paused: ${isPaused}`); +console.log(`Treasury Cancelled: ${isCancelled}`); +console.log(`"Signed Print" reward value: $${Number(rewardDetails.rewardValue) / 1_000_000}`); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/08-disburse-fees.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/08-disburse-fees.ts new file mode 100644 index 00000000..e048ec04 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/08-disburse-fees.ts @@ -0,0 +1,39 @@ +/** + * Step 8: Disburse Protocol and Platform Fees (Anyone) + * + * Before anyone can withdraw funds from a successful campaign, the + * protocol and platform fees must be disbursed first. This is a + * separate on-chain call because the fee recipients (the Oak Protocol + * treasury and the ArtFund platform wallet) are different from the + * campaign creator. + * + * `disburseFees()` has no role restriction — anyone can call it. + * The contract verifies internally that: + * - The campaign deadline has passed + * - The funding goal has been met (success condition) + * - Fees have not already been disbursed + * + * It calculates the protocol fee and platform fee based on the raised + * amount, then transfers them to the respective recipients in a single + * transaction. The remaining balance becomes available for withdrawal + * (Step 9a). It only needs to be called once. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = oak.allOrNothingTreasury(treasuryAddress); + +const feeTxHash = await treasury.disburseFees(); +const feeReceipt = await oak.waitForReceipt(feeTxHash); +console.log(`Fees disbursed at block ${feeReceipt.blockNumber}`); + +// Verify the fee percent that was applied +const platformFeePercent = await treasury.getPlatformFeePercent(); +console.log(`Platform fee: ${Number(platformFeePercent)} basis points`); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts new file mode 100644 index 00000000..221eac88 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts @@ -0,0 +1,34 @@ +/** + * Step 9a: Success — Goal Met, Withdraw Funds (Anyone) + * + * After fees have been disbursed (Step 8), the remaining funds are + * available for withdrawal. `withdraw()` has no role restriction — + * anyone can call it. The contract always sends the funds to the + * campaign owner (`INFO.owner()`), regardless of who initiates the + * transaction. + * + * In practice, the creator usually calls this themselves, but a + * platform admin or even a bot could trigger it on their behalf. + * + * After withdrawal, the treasury balance drops to zero. Maya can now + * use the funds to produce and ship rewards to her backers. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = oak.allOrNothingTreasury(treasuryAddress); + +const withdrawTxHash = await treasury.withdraw(); +const withdrawReceipt = await oak.waitForReceipt(withdrawTxHash); +console.log(`Funds withdrawn at block ${withdrawReceipt.blockNumber}`); + +// Verify the treasury is empty +const remaining = await treasury.getRaisedAmount(); +console.log("Remaining in treasury:", remaining); // 0n diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts new file mode 100644 index 00000000..b599d0d3 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts @@ -0,0 +1,41 @@ +/** + * Step 9b: Failure — Goal Not Met, Claim Refund (Anyone) + * + * The 30-day deadline has passed but the campaign did not reach the + * $5,000 goal. Under the All-or-Nothing model, every backer is + * entitled to a full refund. + * + * `claimRefund(tokenId)` has no role restriction — anyone can call it + * for any token ID. The contract always sends the refund to the + * **current NFT owner** (`INFO.ownerOf(tokenId)`), not to `msg.sender`. + * This means a backer can call it themselves, or a platform bot could + * trigger refunds on behalf of all backers. + * + * The contract does two things in a single transaction: + * + * 1. Burns the pledge NFT (the token is permanently destroyed) + * 2. Returns the pledged tokens to the NFT owner's wallet + * + * Because `claimRefund` already burns the NFT, there is no need to + * call `burn` separately. After this call, the token no longer exists + * and the backer's balance decreases by one. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const alexOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.ALEX_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = alexOak.allOrNothingTreasury(treasuryAddress); + +const tokenId = 0n; // Alex's pledge NFT token ID (received when pledging) +const refundTxHash = await treasury.claimRefund(tokenId); +const refundReceipt = await alexOak.waitForReceipt(refundTxHash); +console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); + +const refundedAmount = await treasury.getRefundedAmount(); +console.log("Total refunded from treasury:", refundedAmount); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts new file mode 100644 index 00000000..aca8ce43 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts @@ -0,0 +1,43 @@ +/** + * Step 10: Pause and Unpause the Treasury (Platform Admin) + * + * The ArtFund platform receives a report that Maya's campaign images + * may contain copyrighted material. While the team investigates, they + * pause the treasury to temporarily freeze all activity — no new + * pledges can be made and no withdrawals or refunds can be processed. + * + * `pauseTreasury(message)` takes a bytes32 reason code that is emitted + * in the Paused event. This helps auditors and the community understand + * why the treasury was frozen. + * + * Once the investigation concludes and the artwork is verified, the + * platform unpauses the treasury with `unpauseTreasury(message)`. + * Normal operations resume immediately. + * + * The `paused()` read method returns true while the treasury is frozen. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = platformOak.allOrNothingTreasury(treasuryAddress); + +// Pause the treasury +const pauseReason = keccak256(toHex("copyright-investigation")); +const pauseTxHash = await treasury.pauseTreasury(pauseReason); +await platformOak.waitForReceipt(pauseTxHash); +console.log("Treasury paused:", await treasury.paused()); // true + +// --- Investigation complete, artwork verified --- + +// Unpause the treasury +const unpauseReason = keccak256(toHex("investigation-cleared")); +const unpauseTxHash = await treasury.unpauseTreasury(unpauseReason); +await platformOak.waitForReceipt(unpauseTxHash); +console.log("Treasury paused:", await treasury.paused()); // false diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/11-cancel-treasury.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/11-cancel-treasury.ts new file mode 100644 index 00000000..22427d30 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/11-cancel-treasury.ts @@ -0,0 +1,41 @@ +/** + * Step 11: Cancel the Treasury (Platform Admin or Creator) + * + * In rare cases, a campaign must be permanently shut down — for + * example, if the creator violates the platform's terms of service, + * the project is determined to be fraudulent, or the creator + * themselves decides to abandon the campaign. + * + * Both the **platform admin** and the **campaign owner** can cancel + * the treasury (the contract checks both roles). Once cancelled: + * + * - No new pledges can be made + * - No withdrawals by the creator are allowed + * - Backers can still claim full refunds via `claimRefund` + * + * `cancelTreasury(message)` takes a bytes32 reason code. The + * cancellation is permanent — there is no "uncancel." + * + * The `cancelled()` read method returns true once the treasury + * has been cancelled. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = platformOak.allOrNothingTreasury(treasuryAddress); + +const cancelReason = keccak256(toHex("duplicate-campaign")); +const cancelTxHash = await treasury.cancelTreasury(cancelReason); +await platformOak.waitForReceipt(cancelTxHash); +console.log("Treasury permanently cancelled"); + +const isCancelled = await treasury.cancelled(); +console.log("Is treasury cancelled:", isCancelled); // true +console.log("Backers may now call claimRefund() to retrieve their pledged tokens."); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md b/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md new file mode 100644 index 00000000..ad84c9ba --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md @@ -0,0 +1,56 @@ +# Scenario 1: Crowdfunding Campaign — All-or-Nothing + +## The Story + +Maya is a ceramic artist who sells her handmade pottery through **ArtFund**, a creative crowdfunding platform built on Oak Protocol. She wants to raise **$5,000** to fund a new collection called "Earth & Fire" — a series of hand-thrown vases and bowls inspired by volcanic landscapes. + +Maya chooses the **All-or-Nothing** funding model. This means every dollar pledged is held in an on-chain treasury until the campaign deadline. If the campaign reaches its $5,000 goal, Maya can withdraw the funds and fulfill rewards to her backers. If the goal is not met, every backer receives a full refund automatically — no questions asked. + +This model builds trust with backers because their funds are protected by the smart contract. Maya cannot access the money unless the community collectively meets the target. + +## How It Unfolds + +1. **Maya (Creator)** creates the campaign through the CampaignInfoFactory, setting the funding goal, deadline, platform, and NFT metadata for backer receipts +2. **Maya** looks up the deployed campaign contract address using her unique campaign identifier +3. **Maya** reviews the on-chain campaign details to confirm everything matches her intent +4. **Maya** deploys an All-or-Nothing treasury via the TreasuryFactory — this is the contract that will hold all pledged funds +5. **Maya** adds reward tiers (and optionally removes one she no longer wants to offer) +6. **Backers** pledge — either by choosing a reward tier or by contributing a flat amount without a reward +7. **Anyone** monitors the campaign dashboard: total raised vs. goal, days remaining, treasury state, and reward details +8. **Anyone** disburses protocol and platform fees — this must happen before withdrawal +9. The campaign deadline arrives. Two outcomes are possible: + - **(a) Success:** Anyone triggers a withdrawal — funds always go to the campaign owner (Maya) + - **(b) Failure:** The goal is not met. Anyone can call `claimRefund(tokenId)` — funds always go to the NFT owner, and the NFT is burned +10. **ArtFund (Platform Admin)** can pause and unpause a treasury if an investigation is needed +11. **ArtFund (Platform Admin) or Maya (Creator)** can permanently cancel the treasury — backers can still claim refunds + +## Files + +| Step | File | Role | Description | +| --- | --- | --- | --- | +| 1 | `01-create-campaign.ts` | Creator | Create a new campaign with goal, deadline, and NFT metadata | +| 2 | `02-lookup-campaign.ts` | Creator | Look up the deployed campaign contract address | +| 3 | `03-review-campaign.ts` | Creator | Read back on-chain campaign details to verify | +| 4 | `04-deploy-treasury.ts` | Creator | Deploy an All-or-Nothing treasury for the campaign | +| 5 | `05-manage-rewards.ts` | Creator | Add reward tiers + optionally remove a tier | +| 6 | `06-backer-pledge.ts` | Backer | Pledge with or without a reward tier | +| 7 | `07-monitor-progress.ts` | Anyone | Full campaign dashboard — raised amount, treasury state, reward details | +| 8 | `08-disburse-fees.ts` | Anyone | Disburse protocol and platform fees | +| 9a | `09a-success-withdraw.ts` | Anyone | Goal met — withdraw funds (always sent to campaign owner) | +| 9b | `09b-failure-refund.ts` | Anyone | Goal not met — `claimRefund` burns NFT and returns tokens to NFT owner | +| 10 | `10-pause-unpause-treasury.ts` | Platform Admin | Temporarily freeze and resume treasury operations | +| 11 | `11-cancel-treasury.ts` | Platform Admin or Creator | Permanently cancel a treasury (backers can still refund) | + +## Role Reference (from the Smart Contract) + +| Function | Who can call | Contract modifier | +| --- | --- | --- | +| `addRewards` / `removeReward` | Creator | `onlyCampaignOwner` | +| `pledgeForAReward` / `pledgeWithoutAReward` | Anyone (backer) | (no role modifier — time-gated) | +| `claimRefund(tokenId)` | Anyone (refund goes to NFT owner) | (no role modifier) | +| `disburseFees` | Anyone | (no role modifier — requires deadline passed + goal met) | +| `withdraw` | Anyone (funds go to campaign owner) | (no role modifier — requires fees disbursed) | +| `pauseTreasury` / `unpauseTreasury` | Platform Admin | `onlyPlatformAdmin` | +| `cancelTreasury` | Platform Admin or Creator | custom check (both roles) | +| `getReward`, `getRaisedAmount`, `paused`, `cancelled`, etc. | Anyone | (read-only) | + From cd1865feb9f1c5543bbdbd581716c9f1ae8b6f76 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:41:43 +0600 Subject: [PATCH 15/86] feat: implement Keep-What's-Raised crowdfunding campaign example - Add a complete set of examples demonstrating the Keep-What's-Raised crowdfunding process, including campaign creation, treasury deployment, reward management, backer pledges, and monitoring campaign progress. - Include functionality for partial and final withdrawals, fee disbursement, and refund claims. - Document the entire flow in a README, outlining roles, responsibilities, and a reference table for smart contract functions. --- .../01-create-campaign.ts | 56 +++++++++++ .../02-deploy-treasury.ts | 42 +++++++++ .../03-configure-treasury.ts | 84 +++++++++++++++++ .../04-manage-rewards.ts | 63 +++++++++++++ .../05-backer-pledge.ts | 92 +++++++++++++++++++ .../06a-partial-withdrawal.ts | 59 ++++++++++++ .../06b-final-withdrawal.ts | 44 +++++++++ .../07-monitor-progress.ts | 66 +++++++++++++ .../08-disburse-fees.ts | 35 +++++++ .../09-claim-fund.ts | 41 +++++++++ .../10-claim-tips.ts | 30 ++++++ .../11-claim-refund.ts | 37 ++++++++ .../12-update-campaign.ts | 49 ++++++++++ .../13-pause-unpause-treasury.ts | 34 +++++++ .../14-cancel-treasury.ts | 31 +++++++ .../02-campaign-keep-whats-raised/README.md | 71 ++++++++++++++ 16 files changed, 834 insertions(+) create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/04-manage-rewards.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/07-monitor-progress.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/08-disburse-fees.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/09-claim-fund.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/10-claim-tips.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/14-cancel-treasury.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts new file mode 100644 index 00000000..3ac552f3 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts @@ -0,0 +1,56 @@ +/** + * Step 1: Create the Campaign (Creator) + * + * TechForge wants to raise $10,000 over 60 days to fund their + * open-source code review tool. They create the campaign through + * the CampaignInfoFactory on the ArtFund platform. + * + * After creation, they immediately look up the deployed CampaignInfo + * contract address using the identifier hash — this address is needed + * for all subsequent steps (deploying the treasury, adding rewards, etc.). + */ + +import { + createOakContractsClient, + keccak256, + toHex, + getCurrentTimestamp, + addDays, + CHAIN_IDS, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("artfund")); +const identifierHash = keccak256(toHex("techforge-devtool-2026")); +const currency = toHex("USD", { size: 32 }); +const now = getCurrentTimestamp(); + +const createTxHash = await factory.createCampaign({ + creator: process.env.TECHFORGE_ADDRESS! as `0x${string}`, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now + 1800n, // launches in 30 minutes + deadline: addDays(now, 60), // 60-day campaign + goalAmount: 10_000_000_000n, // $10,000 + currency, + }, + nftName: "TechForge Early Backers", + nftSymbol: "TFEB", + nftImageURI: "ipfs://QmAbc.../techforge.png", + contractURI: "ipfs://QmAbc.../metadata.json", +}); + +await oak.waitForReceipt(createTxHash); + +const campaignInfoAddress = await factory.identifierToCampaignInfo(identifierHash); +console.log("Campaign at:", campaignInfoAddress); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts new file mode 100644 index 00000000..91fd1237 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts @@ -0,0 +1,42 @@ +/** + * Step 2: Deploy a Keep-What's-Raised Treasury (Creator) + * + * TechForge deploys a Keep-What's-Raised treasury for their campaign. + * This treasury model allows the creator to keep whatever funds are + * raised, even if the full goal is not met — unlike All-or-Nothing, + * which requires the goal to be reached before any funds are released. + * + * After deployment, TechForge reads the TreasuryDeployed event to + * discover the treasury contract address. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryFactory = oak.treasuryFactory( + process.env.TREASURY_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("artfund")); +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; +const kwrImplementationId = 1n; + +const deployTxHash = await treasuryFactory.deploy( + platformHash, + campaignInfoAddress, + kwrImplementationId, +); + +const deployReceipt = await oak.waitForReceipt(deployTxHash); + +const deployLogs = await treasuryFactory.events.getTreasuryDeployedLogs({ + fromBlock: BigInt(deployReceipt.blockNumber), +}); + +const treasuryAddress = deployLogs[0]?.args?.treasury; +console.log("KWR Treasury at:", treasuryAddress); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts new file mode 100644 index 00000000..b7a2ef95 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts @@ -0,0 +1,84 @@ +/** + * Step 3: Configure the Treasury (Platform Admin) + * + * The platform admin configures the treasury before it can accept + * pledges. This is a platform-level responsibility — the creator + * cannot call this function. + * + * Configuration includes: + * + * - Withdrawal delay: how long after approval before funds can be + * withdrawn (gives backers visibility) + * - Refund delay: how long after the deadline (or cancellation) + * backers must wait before claiming refunds + * - Config lock period: prevents parameter changes close to the + * deadline (protects backers from last-minute rule changes) + * - Fee structure: flat fees, cumulative flat fees, and gross + * percentage-based fees applied to each pledge + * + * These parameters balance creator flexibility with backer protection. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + getCurrentTimestamp, + addDays, + CHAIN_IDS, +} from "@oaknetwork/contracts-sdk"; +import type { + KeepWhatsRaisedConfig, + KeepWhatsRaisedFeeKeys, + KeepWhatsRaisedFeeValues, + CampaignData, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); + +const now = getCurrentTimestamp(); +const currency = toHex("USD", { size: 32 }); + +const config: KeepWhatsRaisedConfig = { + minimumWithdrawalForFeeExemption: 1_000_000_000n, // $1,000 — withdrawals above this skip flat fee + withdrawalDelay: 86400n, // 24 hours between approval and withdrawal + refundDelay: 259200n, // 3-day delay after deadline before backers can refund + configLockPeriod: 604800n, // config is locked for 7 days before deadline + isColombianCreator: false, +}; + +const campaignData: CampaignData = { + launchTime: now + 1800n, + deadline: addDays(now, 60), + goalAmount: 10_000_000_000n, + currency, +}; + +const feeKeys: KeepWhatsRaisedFeeKeys = { + flatFeeKey: keccak256(toHex("flatWithdrawalFee")), + cumulativeFlatFeeKey: keccak256(toHex("cumulativeFlatFee")), + grossPercentageFeeKeys: [keccak256(toHex("grossFee"))], +}; + +const feeValues: KeepWhatsRaisedFeeValues = { + flatFeeValue: 5_000_000n, // $5 flat fee per withdrawal + cumulativeFlatFeeValue: 50_000_000n, // $50 max cumulative flat fees + grossPercentageFeeValues: [200n], // 2% gross percentage fee +}; + +const configureTxHash = await treasury.configureTreasury( + config, + campaignData, + feeKeys, + feeValues, +); + +await oak.waitForReceipt(configureTxHash); +console.log("Treasury configured with withdrawal delays and fee structure"); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/04-manage-rewards.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/04-manage-rewards.ts new file mode 100644 index 00000000..a5e0405d --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/04-manage-rewards.ts @@ -0,0 +1,63 @@ +/** + * Step 4: Manage Reward Tiers (Creator) + * + * TechForge sets up reward tiers for their backers. Each tier has a + * minimum pledge value. This file covers both adding and removing: + * + * - `addRewards` — registers one or more tiers in a single call + * - `removeReward` — deletes a tier by its bytes32 name + * - `getReward` — reads back a tier's details to verify + * + * Removing a reward is optional — most campaigns keep their tiers + * unchanged after publishing. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); + +// --- Add reward tiers --- + +const earlyBirdReward = keccak256(toHex("early-bird")); +const proReward = keccak256(toHex("pro-license")); + +const addTxHash = await treasury.addRewards( + [earlyBirdReward, proReward], + [ + { + rewardValue: 50_000_000n, // $50 — Early Bird license + isRewardTier: true, + itemId: [], + itemValue: [], + itemQuantity: [], + }, + { + rewardValue: 200_000_000n, // $200 — Pro license + priority support + isRewardTier: true, + itemId: [], + itemValue: [], + itemQuantity: [], + }, + ], +); + +await oak.waitForReceipt(addTxHash); +console.log("Reward tiers added: Early Bird ($50), Pro License ($200)"); + +// --- Remove a reward tier (optional) --- + +// const removeTxHash = await treasury.removeReward(earlyBirdReward); +// await oak.waitForReceipt(removeTxHash); +// console.log('"Early Bird" reward removed'); + +// --- Verify a reward tier --- + +const earlyBirdDetails = await treasury.getReward(earlyBirdReward); +console.log("Early Bird value:", earlyBirdDetails.rewardValue); // 50_000_000n diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts new file mode 100644 index 00000000..e1b4d7f5 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts @@ -0,0 +1,92 @@ +/** + * Step 5: Backers Pledge (Backer) + * + * Three ways to pledge into a Keep-What's-Raised treasury: + * + * 1. `pledgeForAReward` — the backer specifies which reward tier + * they want; the pledge amount is determined by the tier value + * 2. `pledgeWithoutAReward` — the backer contributes a chosen + * amount without selecting a reward tier + * 3. `setFeeAndPledge` — (Platform Admin only) records a payment- + * gateway fee and the pledge in a single transaction. Used by + * platforms that charge on-ramp fees and want both recorded + * atomically. Tokens are transferred from the admin wallet. + * + * Additionally, `setPaymentGatewayFee` (Platform Admin only) lets + * the platform record a gateway fee for an existing pledge. + * + * Every pledge requires a unique `pledgeId` (a bytes32 value) and + * supports an optional `tip` that goes directly to the platform. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.BACKER_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); + +const pledgeToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const backerAddress = process.env.BACKER_ADDRESS! as `0x${string}`; +const earlyBirdReward = keccak256(toHex("early-bird")); + +// --- Pledge with a reward --- + +const pledgeId = keccak256(toHex("pledge-001")); +const pledgeTxHash = await treasury.pledgeForAReward( + pledgeId, + backerAddress, + pledgeToken, + 0n, // no tip + [earlyBirdReward], // the "Early Bird" reward +); +await oak.waitForReceipt(pledgeTxHash); +console.log("Pledged for Early Bird reward"); + +// --- Pledge without a reward — pure support --- + +const supportPledgeId = keccak256(toHex("pledge-002")); +const supporterAddress = process.env.SUPPORTER_ADDRESS! as `0x${string}`; +const noRewardTxHash = await treasury.pledgeWithoutAReward( + supportPledgeId, + supporterAddress, + pledgeToken, + 50_000_000n, // $50 pledge amount + 0n, // no tip +); +await oak.waitForReceipt(noRewardTxHash); +console.log("Pledged without reward"); + +// --- Set fee and pledge in one call (Platform Admin only) --- + +// const platformOak = createOakContractsClient({ +// chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, +// rpcUrl: process.env.RPC_URL!, +// privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +// }); +// const platformTreasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); +// +// const feeAndPledgeId = keccak256(toHex("pledge-003")); +// const feeAndPledgeTxHash = await platformTreasury.setFeeAndPledge( +// feeAndPledgeId, +// backerAddress, +// pledgeToken, +// 75_000_000n, // $75 pledge amount +// 0n, // tip +// 2_500_000n, // $2.50 gateway fee +// [earlyBirdReward], +// true, // isPledgeForAReward +// ); +// await platformOak.waitForReceipt(feeAndPledgeTxHash); +// console.log("Fee recorded + pledge created in one call"); + +// --- Record a gateway fee for an existing pledge (Platform Admin only) --- + +// const gatewayFee = 1_000_000n; // $1 fee +// const feeTxHash = await platformTreasury.setPaymentGatewayFee(pledgeId, gatewayFee); +// await platformOak.waitForReceipt(feeTxHash); +// console.log("Payment gateway fee recorded for pledge-001"); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts new file mode 100644 index 00000000..a3d5c856 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts @@ -0,0 +1,59 @@ +/** + * Step 6a: Partial Withdrawal — Mid-Campaign (Platform Admin + Creator) + * + * One of the key advantages of the Keep-What's-Raised model is that + * the creator does not have to wait until the campaign ends to access + * funds. TechForge needs $2,000 now to begin prototyping. + * + * The withdrawal process involves two parties: + * + * 1. The **platform admin** approves withdrawal capability for the + * treasury (`approveWithdrawal`). This is a one-time action — + * once approved, the withdrawal flag stays on. + * 2. The **creator or platform admin** executes the withdrawal + * (`withdraw(token, amount)`) after the configured delay period. + * + * Partial withdrawals let the creator specify exactly how much to + * withdraw. A cumulative or flat fee may apply depending on the + * amount relative to `minimumWithdrawalForFeeExemption`. + * + * This is distinct from the final withdrawal (Step 6b), which happens + * after the deadline and sweeps the remaining balance. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +// --- Step A: Platform admin approves withdrawal --- + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const platformTreasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); + +const approvalTxHash = await platformTreasury.approveWithdrawal(); +await platformOak.waitForReceipt(approvalTxHash); +console.log("Platform admin approved withdrawals"); + +const approvalStatus = await platformTreasury.getWithdrawalApprovalStatus(); +console.log("Withdrawal approved:", approvalStatus); // true + +// --- Step B: Creator withdraws after the delay period --- + +const creatorOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const creatorTreasury = creatorOak.keepWhatsRaisedTreasury(treasuryAddress); + +const withdrawToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const withdrawAmount = 2_000_000_000n; // $2,000 + +const withdrawTxHash = await creatorTreasury.withdraw(withdrawToken, withdrawAmount); +await creatorOak.waitForReceipt(withdrawTxHash); +console.log("Creator withdrew $2,000 for prototyping"); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts new file mode 100644 index 00000000..f92ddf54 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts @@ -0,0 +1,44 @@ +/** + * Step 6b: Final Withdrawal — After Deadline (Creator or Platform Admin) + * + * After the campaign deadline passes, the creator or platform admin + * can execute a final withdrawal. Unlike partial withdrawals (Step 6a), + * the final withdrawal sweeps the entire remaining balance of a + * specific token from the treasury. + * + * Key differences from partial withdrawal: + * + * - The `amount` parameter is ignored — the contract uses the full + * available balance for the token + * - If the total available is below `minimumWithdrawalForFeeExemption`, + * a flat fee (not cumulative) is deducted + * - If `isColombianCreator` is true, an additional tax is applied + * - The call must happen within `deadline + withdrawalDelay` — after + * that window, `withdraw` is no longer available (use `claimFund` + * instead once the withdrawal delay has fully elapsed) + * + * Call `disburseFees()` (Step 7) before this step so that protocol + * and platform fees have already been transferred out. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); + +const withdrawToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; + +// For a final withdrawal the contract ignores the amount parameter +// and uses the full available balance — pass 0n or any value +const finalWithdrawTxHash = await treasury.withdraw(withdrawToken, 0n); +const receipt = await oak.waitForReceipt(finalWithdrawTxHash); +console.log(`Final withdrawal completed at block ${receipt.blockNumber}`); + +const availableAfter = await treasury.getAvailableRaisedAmount(); +console.log(`Remaining available: $${Number(availableAfter) / 1_000_000}`); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/07-monitor-progress.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/07-monitor-progress.ts new file mode 100644 index 00000000..58f16cac --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/07-monitor-progress.ts @@ -0,0 +1,66 @@ +/** + * Step 7: Monitor Campaign Progress (Anyone) + * + * Anyone can check the campaign's progress at any time using read-only + * calls. No wallet or private key is needed, just an RPC endpoint. + * + * This step reads from both the CampaignInfo contract (goal, deadline) + * and the KeepWhatsRaised treasury (raised amounts, fees, state). + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); + +// Treasury reads +const raisedAmount = await treasury.getRaisedAmount(); +const lifetimeRaised = await treasury.getLifetimeRaisedAmount(); +const refundedAmount = await treasury.getRefundedAmount(); +const availableRaised = await treasury.getAvailableRaisedAmount(); +const platformHash = await treasury.getPlatformHash(); +const platformFeePercent = await treasury.getPlatformFeePercent(); +const goalAmount = await treasury.getGoalAmount(); +const deadline = await treasury.getDeadline(); +const launchTime = await treasury.getLaunchTime(); +const isPaused = await treasury.paused(); +const isCancelled = await treasury.cancelled(); +const withdrawalApproved = await treasury.getWithdrawalApprovalStatus(); + +// Inspect a specific reward tier +const earlyBirdReward = keccak256(toHex("early-bird")); +const rewardDetails = await treasury.getReward(earlyBirdReward); + +// Fee reads +const flatFeeKey = keccak256(toHex("flatWithdrawalFee")); +const flatFeeValue = await treasury.getFeeValue(flatFeeKey); + +// Payment gateway fee for a specific pledge +const pledgeId = keccak256(toHex("pledge-001")); +const gatewayFee = await treasury.getPaymentGatewayFee(pledgeId); + +const now = BigInt(Math.floor(Date.now() / 1000)); +const progressPercent = goalAmount > 0n ? Number((raisedAmount * 100n) / goalAmount) : 0; +const daysRemaining = deadline > now ? Number((deadline - now) / 86400n) : 0; + +console.log("=== Campaign Dashboard ==="); +console.log(`Goal: $${Number(goalAmount) / 1_000_000}`); +console.log(`Raised: $${Number(raisedAmount) / 1_000_000} (${progressPercent}%)`); +console.log(`Available for withdrawal: $${Number(availableRaised) / 1_000_000}`); +console.log(`Lifetime raised: $${Number(lifetimeRaised) / 1_000_000}`); +console.log(`Refunded: $${Number(refundedAmount) / 1_000_000}`); +console.log(`Launch: ${new Date(Number(launchTime) * 1000).toISOString()}`); +console.log(`Days remaining: ${daysRemaining}`); +console.log(`Platform hash: ${platformHash}`); +console.log(`Platform fee: ${Number(platformFeePercent)} bps`); +console.log(`Flat withdrawal fee: $${Number(flatFeeValue) / 1_000_000}`); +console.log(`Gateway fee for pledge-001: $${Number(gatewayFee) / 1_000_000}`); +console.log(`Withdrawal approved: ${withdrawalApproved}`); +console.log(`Paused: ${isPaused}`); +console.log(`Cancelled: ${isCancelled}`); +console.log(`"Early Bird" reward value: $${Number(rewardDetails.rewardValue) / 1_000_000}`); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/08-disburse-fees.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/08-disburse-fees.ts new file mode 100644 index 00000000..cce59f10 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/08-disburse-fees.ts @@ -0,0 +1,35 @@ +/** + * Step 8: Disburse Protocol and Platform Fees (Anyone) + * + * `disburseFees()` transfers all accumulated protocol and platform + * fees from the treasury to their respective recipients. Anyone can + * call this function — there is no role restriction. + * + * Important constraints: + * + * - `disburseFees` has a `whenNotCancelled` modifier — it must be + * called BEFORE the treasury is cancelled. If the treasury is + * cancelled first, fees can no longer be disbursed. + * - Fees accumulate per pledge (gross percentage fees, payment + * gateway fees, and protocol fees are calculated at pledge time). + * This call simply transfers the accumulated amounts. + * - Can be called multiple times if new fees accumulate. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); + +const feeTxHash = await treasury.disburseFees(); +const feeReceipt = await oak.waitForReceipt(feeTxHash); +console.log(`Fees disbursed at block ${feeReceipt.blockNumber}`); + +const platformFeePercent = await treasury.getPlatformFeePercent(); +console.log(`Platform fee: ${Number(platformFeePercent)} basis points`); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/09-claim-fund.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/09-claim-fund.ts new file mode 100644 index 00000000..e2725744 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/09-claim-fund.ts @@ -0,0 +1,41 @@ +/** + * Step 9: Claim Remaining Funds (Platform Admin) + * + * After the withdrawal delay has fully elapsed (deadline + withdrawalDelay), + * the platform admin can claim any remaining funds from the treasury + * using `claimFund()`. This transfers the full remaining balance of + * every accepted token to the platform admin's wallet. + * + * Only the **platform admin** can call this function — the creator + * cannot. This is a platform-level settlement step for sweeping + * residual balances after the withdrawal window has closed. + * + * If the treasury was cancelled, the platform admin must wait until + * `cancellationTime + refundDelay` before claiming. + * + * `claimFund` can only be called once — a second call reverts with + * `KeepWhatsRaisedAlreadyClaimed`. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); + +const claimTxHash = await treasury.claimFund(); +await platformOak.waitForReceipt(claimTxHash); +console.log("Platform admin claimed remaining funds"); + +const raised = await treasury.getRaisedAmount(); +const lifetime = await treasury.getLifetimeRaisedAmount(); +const refunded = await treasury.getRefundedAmount(); + +console.log(`Lifetime raised: $${Number(lifetime) / 1_000_000}`); +console.log(`Refunded: $${Number(refunded) / 1_000_000}`); +console.log(`Current balance: $${Number(raised) / 1_000_000}`); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/10-claim-tips.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/10-claim-tips.ts new file mode 100644 index 00000000..eb6135de --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/10-claim-tips.ts @@ -0,0 +1,30 @@ +/** + * Step 10: Claim Tips (Platform Admin) + * + * Some backers include a tip on top of their pledge as an extra show + * of support. Tips are tracked separately from the main pledge amounts + * and are claimed by the **platform admin** — not the creator. + * + * `claimTip()` can only be called after the campaign deadline has + * passed (or after the treasury is cancelled). Tips are transferred + * to the platform admin's wallet for all accepted tokens in a single + * call. + * + * `claimTip` can only be called once — a second call reverts with + * `KeepWhatsRaisedAlreadyClaimed`. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); + +const tipTxHash = await treasury.claimTip(); +await platformOak.waitForReceipt(tipTxHash); +console.log("Platform admin claimed tips!"); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts new file mode 100644 index 00000000..cb3b1dd6 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts @@ -0,0 +1,37 @@ +/** + * Step 11: Claim a Refund (Backer) + * + * A backer who wants their money back can claim a refund by calling + * `claimRefund` with their pledge NFT token ID. The contract burns + * the NFT and returns the pledged tokens (minus any payment fees) + * to the NFT owner's wallet in a single transaction. + * + * Refund eligibility timing: + * + * - If the campaign is NOT cancelled: refunds are available after + * the deadline has passed AND before `deadline + refundDelay` + * - If the campaign IS cancelled: refunds are available immediately + * after cancellation and until `cancellationTime + refundDelay` + * + * Note: `claimRefund` already burns the NFT — there is no need + * to call `burn` separately. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const backerOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.BACKER_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = backerOak.keepWhatsRaisedTreasury(treasuryAddress); + +const tokenId = 0n; // backer's pledge NFT token ID +const refundTxHash = await treasury.claimRefund(tokenId); +const refundReceipt = await backerOak.waitForReceipt(refundTxHash); +console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); + +const refundedAmount = await treasury.getRefundedAmount(); +console.log("Total refunded from treasury:", refundedAmount); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts new file mode 100644 index 00000000..b7df2a78 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts @@ -0,0 +1,49 @@ +/** + * Step 12: Update Campaign Parameters (Creator or Platform Admin) — Optional + * + * ⚙️ THIS STEP IS OPTIONAL — most campaigns do not change parameters + * after launch. Only use this if circumstances require it. + * + * The Keep-What's-Raised treasury allows the creator OR platform admin + * to update certain campaign parameters after deployment: + * + * - `updateDeadline` — extend or shorten the campaign deadline + * - `updateGoalAmount` — raise or lower the funding goal + * + * Constraints (from the contract's `onlyBeforeConfigLock` modifier): + * + * - Updates must happen BEFORE `deadline - configLockPeriod`. + * Once the lock period begins, no further changes are allowed. + * - The new deadline must be in the future and after launch time. + * - The new goal amount must be greater than zero. + */ + +import { createOakContractsClient, addDays, getCurrentTimestamp, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); + +// Extend the deadline by 30 more days +const now = getCurrentTimestamp(); +const newDeadline = addDays(now, 90); // extend to 90 days total +const deadlineTxHash = await treasury.updateDeadline(newDeadline); +await oak.waitForReceipt(deadlineTxHash); +console.log("Deadline extended to 90 days"); + +const currentDeadline = await treasury.getDeadline(); +console.log("New deadline:", new Date(Number(currentDeadline) * 1000).toISOString()); + +// Lower the goal to $7,500 (partial funding is enough) +const newGoal = 7_500_000_000n; // $7,500 +const goalTxHash = await treasury.updateGoalAmount(newGoal); +await oak.waitForReceipt(goalTxHash); +console.log("Goal updated to $7,500"); + +const currentGoal = await treasury.getGoalAmount(); +console.log("New goal:", Number(currentGoal) / 1_000_000); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts new file mode 100644 index 00000000..eeaf15f7 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts @@ -0,0 +1,34 @@ +/** + * Step 13: Pause and Unpause the Treasury (Platform Admin) — Optional + * + * The platform temporarily freezes all treasury activity — no new + * pledges, withdrawals, or refunds — while investigating an issue. + * + * `pauseTreasury(message)` takes a bytes32 reason code emitted in + * the Paused event. `unpauseTreasury(message)` resumes operations. + * + * The `paused()` read method returns true while the treasury is frozen. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); + +// Pause +const pauseReason = keccak256(toHex("compliance-review")); +const pauseTxHash = await treasury.pauseTreasury(pauseReason); +await platformOak.waitForReceipt(pauseTxHash); +console.log("Treasury paused:", await treasury.paused()); // true + +// Unpause +const unpauseReason = keccak256(toHex("review-cleared")); +const unpauseTxHash = await treasury.unpauseTreasury(unpauseReason); +await platformOak.waitForReceipt(unpauseTxHash); +console.log("Treasury paused:", await treasury.paused()); // false diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/14-cancel-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/14-cancel-treasury.ts new file mode 100644 index 00000000..c843824c --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/14-cancel-treasury.ts @@ -0,0 +1,31 @@ +/** + * Step 14: Cancel the Treasury (Platform Admin) — Optional + * + * In rare cases, a campaign must be permanently shut down. Once + * cancelled: + * + * - No new pledges can be made + * - No creator withdrawals or claims are allowed + * - Backers can still claim full refunds via `claimRefund` + * + * `cancelTreasury(message)` takes a bytes32 reason code. The + * cancellation is permanent — there is no "uncancel." + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const treasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); + +const cancelReason = keccak256(toHex("terms-violation")); +const cancelTxHash = await treasury.cancelTreasury(cancelReason); +await platformOak.waitForReceipt(cancelTxHash); +console.log("Treasury permanently cancelled"); +console.log("Is cancelled:", await treasury.cancelled()); // true +console.log("Backers may now call claimRefund() to retrieve their tokens."); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md new file mode 100644 index 00000000..c75beeee --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md @@ -0,0 +1,71 @@ +# Scenario 2: Crowdfunding Campaign — Keep What's Raised + +## The Story + +**TechForge** is a small team of developers building an open-source code review tool. They want to raise **$10,000** to fund a working prototype, but they know that even partial funding would let them build a smaller version. Unlike Maya's All-or-Nothing campaign (Scenario 1), TechForge wants the flexibility to **keep whatever they raise**, even if the full $10,000 is not reached. + +TechForge chooses the **Keep-What's-Raised** funding model on the **ArtFund** platform. This model offers several features that the All-or-Nothing model does not: + +- **Partial withdrawals** — The creator can request early access to raised funds mid-campaign (subject to platform approval and a configurable delay) +- **Final withdrawal** — After the deadline, the creator sweeps the remaining balance with applicable fees +- **Tips** — Backers can include an optional tip on top of their pledge +- **Configurable fee structure** — The platform sets flat fees, percentage-based fees, and fee exemption thresholds +- **Refund delays** — A configurable waiting period after the deadline before backers can claim refunds +- **Updatable parameters** — The creator or platform admin can extend the deadline or adjust the funding goal (before the config lock period) + +## How It Unfolds + +1. **TechForge (Creator)** creates the campaign with a $10,000 goal and a 60-day deadline +2. **TechForge** deploys a Keep-What's-Raised treasury for the campaign +3. **ArtFund (Platform Admin)** configures the treasury with withdrawal delays, refund policies, and the fee structure +4. **TechForge** adds reward tiers — and optionally removes one they no longer want to offer +5. **Backers** discover the campaign and pledge — some choose a reward tier, others pledge without a reward as a show of support +6. Two types of withdrawal: + - **(a) Partial:** ArtFund approves withdrawals, then TechForge withdraws $2,000 mid-campaign to begin prototyping + - **(b) Final:** After the deadline, TechForge sweeps the remaining balance minus applicable fees +7. **Anyone** monitors the campaign dashboard — total raised vs. goal, fee details, treasury state +8. **Anyone** disburses accumulated protocol and platform fees (must happen before cancellation) +9. **ArtFund (Platform Admin)** claims any residual funds after the withdrawal delay has fully elapsed +10. **ArtFund (Platform Admin)** claims tips that backers included with their pledges +11. **A backer** claims a refund after the deadline + refund delay window — the contract burns their NFT and returns tokens +12. **TechForge or ArtFund** updates the deadline or goal mid-campaign (subject to the config lock period) +13. **ArtFund (Platform Admin)** can pause and unpause a treasury if an investigation is needed +14. **ArtFund (Platform Admin)** can permanently cancel a fraudulent treasury — backers can still claim refunds + +## Files + +| Step | File | Role | Description | Required? | +| --- | --- | --- | --- | --- | +| 1 | `01-create-campaign.ts` | Creator | Create a 60-day campaign with a $10,000 goal | Required | +| 2 | `02-deploy-treasury.ts` | Creator | Deploy a Keep-What's-Raised treasury | Required | +| 3 | `03-configure-treasury.ts` | Platform Admin | Set withdrawal delays, refund policies, and fees | Required | +| 4 | `04-manage-rewards.ts` | Creator | Add reward tiers + optionally remove a tier | Required | +| 5 | `05-backer-pledge.ts` | Backer | Pledge with or without a reward; platform can set gateway fees | Required | +| 6a | `06a-partial-withdrawal.ts` | Platform Admin + Creator | Platform approves, then creator withdraws partial funds mid-campaign | Required | +| 6b | `06b-final-withdrawal.ts` | Creator or Platform Admin | Post-deadline withdrawal — sweep remaining balance with fees | Required | +| 7 | `07-monitor-progress.ts` | Anyone | Full campaign dashboard — raised amount, fees, treasury state | Required | +| 8 | `08-disburse-fees.ts` | Anyone | Disburse accumulated fees (must call before cancellation) | Required | +| 9 | `09-claim-fund.ts` | Platform Admin | Claim residual funds after the withdrawal delay elapses | Required | +| 10 | `10-claim-tips.ts` | Platform Admin | Claim tips that backers included with their pledges | Required | +| 11 | `11-claim-refund.ts` | Backer | Claim a refund after deadline + refund delay — burns NFT and returns tokens | Required | +| 12 | `12-update-campaign.ts` | Creator or Platform Admin | Update deadline or goal (before config lock period) | (Optional) | +| 13 | `13-pause-unpause-treasury.ts` | Platform Admin | Temporarily freeze and resume treasury operations | (Optional) | +| 14 | `14-cancel-treasury.ts` | Platform Admin | Permanently cancel a treasury (backers can still refund) | (Optional) | + +## Role Reference (from the Smart Contract) + +| Function | Who can call | Contract modifier | +| --- | --- | --- | +| `configureTreasury` | Platform Admin | `onlyPlatformAdmin` | +| `approveWithdrawal` | Platform Admin | `onlyPlatformAdmin` | +| `withdraw(token, amount)` | Platform Admin or Creator | `onlyPlatformAdminOrCampaignOwner` | +| `claimFund` | Platform Admin | `onlyPlatformAdmin` | +| `claimTip` | Platform Admin | `onlyPlatformAdmin` | +| `disburseFees` | Anyone | (no role modifier) | +| `addRewards` / `removeReward` | Creator | `onlyCampaignOwner` | +| `pledgeForAReward` / `pledgeWithoutAReward` | Anyone (backer) | (no role modifier — time-gated) | +| `setFeeAndPledge` / `setPaymentGatewayFee` | Platform Admin | `onlyPlatformAdmin` | +| `claimRefund` | Anyone (NFT owner) | (no role modifier — time-gated) | +| `updateDeadline` / `updateGoalAmount` | Platform Admin or Creator | `onlyPlatformAdminOrCampaignOwner` | +| `pauseTreasury` / `unpauseTreasury` | Platform Admin | (inherited) | +| `cancelTreasury` | Platform Admin or Creator | `onlyPlatformAdminOrCampaignOwner` | From f699af0cf8c527e4405344a3762425899f3136d5 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:42:50 +0600 Subject: [PATCH 16/86] feat: implement e-commerce payment flow examples using Payment Treasury - Add a complete set of examples demonstrating the e-commerce payment process, including treasury setup, payment creation, crypto payment processing, payment confirmation, and refund handling. - Include functionality for reading payment and treasury data, disbursing fees, withdrawing funds, and claiming expired or non-goal line items. - Document the entire flow in a README, outlining roles, responsibilities, and a reference table for smart contract functions. --- .../01-setup-treasury.ts | 26 ++++++ .../02-create-payment.ts | 92 +++++++++++++++++++ .../03-process-crypto-payment.ts | 55 +++++++++++ .../04-confirm-payment.ts | 44 +++++++++ .../05-read-payment-data.ts | 66 +++++++++++++ .../06-handle-refunds.ts | 86 +++++++++++++++++ .../07-disburse-fees.ts | 36 ++++++++ .../08-withdraw-funds.ts | 41 +++++++++ .../09-claim-expired-funds.ts | 40 ++++++++ .../10-claim-non-goal-line-items.ts | 32 +++++++ .../11-pause-unpause-treasury.ts | 37 ++++++++ .../12-cancel-treasury.ts | 37 ++++++++ .../03-campaign-payment-treasury/README.md | 83 +++++++++++++++++ 13 files changed, 675 insertions(+) create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/04-confirm-payment.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/05-read-payment-data.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/07-disburse-fees.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/09-claim-expired-funds.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/11-pause-unpause-treasury.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/12-cancel-treasury.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/README.md diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts new file mode 100644 index 00000000..e8bebb4a --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts @@ -0,0 +1,26 @@ +/** + * Step 1: Connect to the Payment Treasury (Platform Admin) + * + * CeloMarket's backend connects to its deployed PaymentTreasury contract + * and reads back the platform configuration — the platform hash it belongs + * to and the fee percent. This is typically done once at application startup + * to verify the treasury is correctly linked to the platform. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = oak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const platformHash = await paymentTreasury.getPlatformHash(); +console.log("Treasury's platform:", platformHash); + +const feePercent = await paymentTreasury.getPlatformFeePercent(); +console.log("Platform fee:", Number(feePercent), "bps"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts new file mode 100644 index 00000000..b1a9562e --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts @@ -0,0 +1,92 @@ +/** + * Step 2: Create a Payment Record (Platform Admin) + * + * Sam has added a handcrafted ceramic vase ($120) to his cart and + * proceeds to checkout. CeloMarket creates a payment record on-chain + * that describes the order: + * + * - A unique payment ID derived from the order number + * - Line items: product price ($120) and shipping ($15) + * - External fee metadata (e.g., payment processor fee) for accounting + * - A 24-hour expiration window — if Sam does not pay within this + * time, the payment record expires + * + * This step does not move any funds. It simply records the payment + * intent on-chain so the buyer can execute it in the next step. + * + * For high-volume platforms, `createPaymentBatch` is available to + * create multiple payment records in a single transaction. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import type { LineItem, ExternalFees } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = oak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const paymentId = keccak256(toHex("order-12345")); +const buyerId = keccak256(toHex("sam-user-id")); +const itemId = keccak256(toHex("handcrafted-vase-001")); +const paymentToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const totalAmount = 135_000_000n; // $135 total (product + shipping) +const expiration = BigInt(Math.floor(Date.now() / 1000)) + 86400n; // 24 hours + +const lineItems: LineItem[] = [ + { + typeId: keccak256(toHex("pledge")), // product price + amount: 120_000_000n, // $120 + }, + { + typeId: keccak256(toHex("shipping")), // shipping fee + amount: 15_000_000n, // $15 + }, +]; + +const externalFees: ExternalFees[] = [ + { + feeType: keccak256(toHex("payment-processor")), + feeAmount: 2_700_000n, // $2.70 (2% payment processor fee) + }, +]; + +// --- Create a single payment --- + +const txHash = await paymentTreasury.createPayment( + paymentId, + buyerId, + itemId, + paymentToken, + totalAmount, + expiration, + lineItems, + externalFees, +); + +await oak.waitForReceipt(txHash); +console.log("Payment created for order #12345"); + +// --- Batch creation — multiple payments in one transaction --- + +// const paymentId2 = keccak256(toHex("order-12346")); +// const buyerId2 = keccak256(toHex("alice-user-id")); +// const itemId2 = keccak256(toHex("ceramic-bowl-002")); +// +// const batchTxHash = await paymentTreasury.createPaymentBatch( +// [paymentId, paymentId2], +// [buyerId, buyerId2], +// [itemId, itemId2], +// [paymentToken, paymentToken], +// [totalAmount, 85_000_000n], +// [expiration, expiration], +// [lineItems, [{ typeId: keccak256(toHex("pledge")), amount: 85_000_000n }]], +// [externalFees, []], +// ); +// await oak.waitForReceipt(batchTxHash); +// console.log("2 payments created in a single transaction"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts new file mode 100644 index 00000000..a5c3195d --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts @@ -0,0 +1,55 @@ +/** + * Step 3: Process the Crypto Payment (Buyer) + * + * Sam completes the purchase by transferring ERC-20 tokens (e.g., cUSD) + * from his wallet to the treasury contract. This is the moment funds + * actually move on-chain. + * + * The payment details (line items, amounts, external fees) must match + * what the platform recorded in Step 2. If anything differs, the + * contract reverts to prevent mismatched payments. + * + * Prerequisite: Sam must have already approved the treasury contract + * to spend his ERC-20 tokens before calling this method. This is a + * standard ERC-20 approval, not specific to Oak Protocol. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import type { LineItem, ExternalFees } from "@oaknetwork/contracts-sdk"; + +const samOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.SAM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = samOak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const paymentId = keccak256(toHex("order-12345")); +const itemId = keccak256(toHex("handcrafted-vase-001")); +const paymentToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const totalAmount = 135_000_000n; + +const lineItems: LineItem[] = [ + { typeId: keccak256(toHex("pledge")), amount: 120_000_000n }, + { typeId: keccak256(toHex("shipping")), amount: 15_000_000n }, +]; + +const externalFees: ExternalFees[] = [ + { feeType: keccak256(toHex("payment-processor")), feeAmount: 2_700_000n }, +]; + +const cryptoPaymentTxHash = await paymentTreasury.processCryptoPayment( + paymentId, + itemId, + process.env.SAM_ADDRESS! as `0x${string}`, + paymentToken, + totalAmount, + lineItems, + externalFees, +); + +await samOak.waitForReceipt(cryptoPaymentTxHash); +console.log("Payment processed — tokens transferred to treasury"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/04-confirm-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/04-confirm-payment.ts new file mode 100644 index 00000000..de7c9f85 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/04-confirm-payment.ts @@ -0,0 +1,44 @@ +/** + * Step 4: Confirm the Payment (Platform Admin) + * + * After Sam's tokens arrive in the treasury, CeloMarket performs its + * off-chain verification — checking inventory, running fraud detection, + * and validating the shipping address. Once satisfied, the platform + * confirms the payment on-chain. + * + * Confirmation is what makes the funds available for withdrawal. + * Until a payment is confirmed, the funds remain in a pending state. + * + * For high-volume platforms, batch confirmation is available to confirm + * multiple payments in a single transaction, reducing gas costs. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = oak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +// Confirm a single payment +const paymentId = keccak256(toHex("order-12345")); + +const confirmTxHash = await paymentTreasury.confirmPayment( + paymentId, + process.env.SAM_ADDRESS! as `0x${string}`, +); +await oak.waitForReceipt(confirmTxHash); +console.log("Payment confirmed for order #12345"); + +// Batch confirmation — multiple payments in one transaction +// const batchTxHash = await paymentTreasury.confirmPaymentBatch( +// [paymentId1, paymentId2, paymentId3], +// [buyerAddress1, buyerAddress2, buyerAddress3], +// ); +// await oak.waitForReceipt(batchTxHash); +// console.log("3 payments confirmed in a single transaction"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/05-read-payment-data.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/05-read-payment-data.ts new file mode 100644 index 00000000..9bbef1eb --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/05-read-payment-data.ts @@ -0,0 +1,66 @@ +/** + * Step 5: Read Payment and Treasury Data (Anyone) + * + * All payment and treasury data is stored on-chain and publicly + * readable. No wallet connection is required — just an RPC endpoint. + * + * This step covers: + * + * - `getPaymentData` — full snapshot of a specific payment including + * buyer address, amount, confirmation status, and line item breakdown + * - Treasury-level reads — raised amount, available balance, expected + * pending amount, lifetime raised, refunded total, and cancellation + * status + * + * Useful for building order detail pages, customer support dashboards, + * treasury monitoring tools, or audit reports. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const paymentTreasury = oak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +// --- Read a specific payment --- + +const paymentId = keccak256(toHex("order-12345")); +const paymentData = await paymentTreasury.getPaymentData(paymentId); + +console.log("=== Payment Details ==="); +console.log("Buyer:", paymentData.buyerAddress); +console.log("Amount:", Number(paymentData.amount) / 1_000_000); +console.log("Confirmed:", paymentData.isConfirmed); +console.log("Is crypto payment:", paymentData.isCryptoPayment); +console.log("Token:", paymentData.paymentToken); +console.log("Expiration:", new Date(Number(paymentData.expiration) * 1000).toISOString()); + +for (const item of paymentData.lineItems) { + console.log(` Line item: $${Number(item.amount) / 1_000_000}`); +} + +// --- Treasury-level reads --- + +const platformHash = await paymentTreasury.getPlatformHash(); +const feePercent = await paymentTreasury.getPlatformFeePercent(); +const raised = await paymentTreasury.getRaisedAmount(); +const available = await paymentTreasury.getAvailableRaisedAmount(); +const expected = await paymentTreasury.getExpectedAmount(); +const lifetime = await paymentTreasury.getLifetimeRaisedAmount(); +const refunded = await paymentTreasury.getRefundedAmount(); +const isCancelled = await paymentTreasury.cancelled(); + +console.log("\n=== Treasury Dashboard ==="); +console.log(`Platform: ${platformHash}`); +console.log(`Fee: ${Number(feePercent)} bps`); +console.log(`Raised: $${Number(raised) / 1_000_000}`); +console.log(`Available: $${Number(available) / 1_000_000}`); +console.log(`Expected (pending): $${Number(expected) / 1_000_000}`); +console.log(`Lifetime raised: $${Number(lifetime) / 1_000_000}`); +console.log(`Refunded: $${Number(refunded) / 1_000_000}`); +console.log(`Cancelled: ${isCancelled}`); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts new file mode 100644 index 00000000..e0d367dd --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts @@ -0,0 +1,86 @@ +/** + * Step 6: Handle Refunds (Platform Admin / Buyer) + * + * Suppose the vase arrives damaged. Sam contacts CeloMarket's support + * team, and they decide to issue a refund. The mechanism depends on + * how the payment was originally made: + * + * **For off-chain payments (`createPayment`):** + * + * 1. The platform admin cancels the payment (`cancelPayment`) + * 2. The platform admin directs the refund to a specific address + * using `claimRefund(paymentId, refundAddress)` — this is for + * non-NFT payments only (the contract verifies `tokenId == 0`) + * + * **For on-chain crypto payments (`processCryptoPayment`):** + * + * 1. The buyer (NFT owner) calls `claimRefundSelf(paymentId)` — the + * contract looks up the NFT minted at payment time, verifies the + * caller owns it, burns the NFT, and sends the refundable amount + * to the current NFT owner + * + * In both cases, only line items marked as `canRefund: true` at + * creation time are returned. Non-refundable line items (e.g., + * shipping) are not included in the refund amount. + * + * Note: `claimRefundSelf` burns the NFT automatically — there is + * no need to call burn separately. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +// ============================================================ +// A. Off-chain payment refund (Platform Admin) +// ============================================================ +// +// For payments created via `createPayment` — no NFT was minted. +// The platform admin cancels and directs the refund. + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = oak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const paymentId = keccak256(toHex("order-12345")); + +// Step 1: Cancel the payment +const cancelTxHash = await paymentTreasury.cancelPayment(paymentId); +await oak.waitForReceipt(cancelTxHash); +console.log("Payment cancelled"); + +// Step 2: Direct the refund to the buyer's address (platform admin only) +const refundTxHash = await paymentTreasury.claimRefund( + paymentId, + process.env.SAM_ADDRESS! as `0x${string}`, +); +await oak.waitForReceipt(refundTxHash); +console.log("Refund sent to Sam's address"); + +// ============================================================ +// B. On-chain crypto payment refund (NFT Owner) +// ============================================================ +// +// For payments made via `processCryptoPayment` — an NFT was minted +// to the buyer. The buyer (current NFT owner) claims the refund +// themselves. The contract burns the NFT and sends tokens to the +// NFT owner. + +// const samOak = createOakContractsClient({ +// chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, +// rpcUrl: process.env.RPC_URL!, +// privateKey: process.env.SAM_PRIVATE_KEY! as `0x${string}`, +// }); +// +// const samTreasury = samOak.paymentTreasury( +// process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +// ); +// +// const cryptoPaymentId = keccak256(toHex("crypto-order-67890")); +// const selfRefundTxHash = await samTreasury.claimRefundSelf(cryptoPaymentId); +// await samOak.waitForReceipt(selfRefundTxHash); +// console.log("NFT burned + refund claimed by Sam"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/07-disburse-fees.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/07-disburse-fees.ts new file mode 100644 index 00000000..693cd0ad --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/07-disburse-fees.ts @@ -0,0 +1,36 @@ +/** + * Step 7: Disburse Protocol and Platform Fees (Anyone) + * + * `disburseFees()` transfers all accumulated protocol and platform + * fees from the treasury to their respective recipients. There is + * no role restriction — anyone can call this function. + * + * Fees are calculated per payment at confirmation time based on the + * platform fee percent and protocol fee percent. This call simply + * transfers the accumulated totals. + * + * Can be called multiple times as new payments are confirmed and + * new fees accumulate. + * + * For TimeConstrainedPaymentTreasury, this can only be called + * after the launch time. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = oak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const feeTxHash = await paymentTreasury.disburseFees(); +const feeReceipt = await oak.waitForReceipt(feeTxHash); +console.log(`Fees disbursed at block ${feeReceipt.blockNumber}`); + +const feePercent = await paymentTreasury.getPlatformFeePercent(); +console.log(`Platform fee: ${Number(feePercent)} bps`); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts new file mode 100644 index 00000000..32fecdca --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts @@ -0,0 +1,41 @@ +/** + * Step 8: Withdraw Confirmed Funds (Platform Admin or Creator) + * + * After fees have been disbursed (Step 7), the platform admin or the + * campaign owner withdraws all available confirmed funds from the + * treasury. The funds are transferred to the campaign owner's wallet. + * + * `withdraw()` takes no parameters — it calculates protocol and + * platform fees on the available balance, deducts them, and transfers + * the remainder to the campaign owner. + * + * After withdrawal, the treasury's available balance drops to zero + * (until new payments are confirmed). + * + * For TimeConstrainedPaymentTreasury, this can only be called + * after the launch time and before cancellation. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = oak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const withdrawTxHash = await paymentTreasury.withdraw(); +const receipt = await oak.waitForReceipt(withdrawTxHash); +console.log(`Funds withdrawn at block ${receipt.blockNumber}`); + +const raised = await paymentTreasury.getRaisedAmount(); +const available = await paymentTreasury.getAvailableRaisedAmount(); +const refunded = await paymentTreasury.getRefundedAmount(); + +console.log(`Raised: $${Number(raised) / 1_000_000}`); +console.log(`Available: $${Number(available) / 1_000_000}`); +console.log(`Refunded: $${Number(refunded) / 1_000_000}`); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/09-claim-expired-funds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/09-claim-expired-funds.ts new file mode 100644 index 00000000..a47921f9 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/09-claim-expired-funds.ts @@ -0,0 +1,40 @@ +/** + * Step 9: Claim Expired Funds (Platform Admin) + * + * If a TimeConstrainedPaymentTreasury is used, the platform admin can + * sweep all remaining balances after the campaign deadline plus the + * platform's `claimDelay` has elapsed. This includes: + * + * - Confirmed funds that were not yet withdrawn + * - Non-goal line item accumulations + * - Refundable amounts that backers did not claim + * - Platform fees and protocol fees + * + * `claimExpiredFunds()` takes no parameters — it transfers everything + * to the appropriate recipients (platform admin, protocol admin). + * + * If the `claimDelay` has not elapsed, the call reverts with + * `PaymentTreasuryClaimWindowNotReached`. + * + * Note: This function is specific to TimeConstrainedPaymentTreasury. + * A standard PaymentTreasury without a deadline does not use this. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = platformOak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const claimTxHash = await paymentTreasury.claimExpiredFunds(); +const receipt = await platformOak.waitForReceipt(claimTxHash); +console.log(`Expired funds claimed at block ${receipt.blockNumber}`); + +const available = await paymentTreasury.getAvailableRaisedAmount(); +console.log(`Remaining available: $${Number(available) / 1_000_000}`); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts new file mode 100644 index 00000000..e9dfe51a --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts @@ -0,0 +1,32 @@ +/** + * Step 10: Claim Non-Goal Line Items (Platform Admin) + * + * Some line item types are configured as "non-goal" — they do not + * count toward the campaign's fundraising goal. Common examples + * include shipping fees, handling charges, or platform service fees. + * + * These non-goal amounts accumulate separately in the treasury and + * can be claimed by the platform admin using `claimNonGoalLineItems`. + * + * The function takes a single `token` parameter — the ERC-20 token + * address to claim non-goal accumulations for. Call it once per + * accepted token if the treasury supports multiple currencies. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = platformOak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const cusdToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; + +const claimTxHash = await paymentTreasury.claimNonGoalLineItems(cusdToken); +const receipt = await platformOak.waitForReceipt(claimTxHash); +console.log(`Non-goal line items claimed at block ${receipt.blockNumber}`); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/11-pause-unpause-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/11-pause-unpause-treasury.ts new file mode 100644 index 00000000..7212a1b0 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/11-pause-unpause-treasury.ts @@ -0,0 +1,37 @@ +/** + * Step 11: Pause and Unpause the Treasury (Platform Admin) — Optional + * + * ⚙️ THIS STEP IS OPTIONAL — use only when an investigation or + * compliance review requires freezing all treasury activity. + * + * `pauseTreasury(message)` freezes the treasury — no new payments, + * confirmations, withdrawals, or refunds can be processed while + * paused. The `message` is a bytes32 reason code emitted in the + * Paused event for audit purposes. + * + * `unpauseTreasury(message)` resumes normal operations. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = platformOak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +// Pause +const pauseReason = keccak256(toHex("fraud-investigation")); +const pauseTxHash = await paymentTreasury.pauseTreasury(pauseReason); +await platformOak.waitForReceipt(pauseTxHash); +console.log("Treasury paused"); + +// Unpause +const unpauseReason = keccak256(toHex("investigation-cleared")); +const unpauseTxHash = await paymentTreasury.unpauseTreasury(unpauseReason); +await platformOak.waitForReceipt(unpauseTxHash); +console.log("Treasury resumed"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/12-cancel-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/12-cancel-treasury.ts new file mode 100644 index 00000000..9088a64e --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/12-cancel-treasury.ts @@ -0,0 +1,37 @@ +/** + * Step 12: Cancel the Treasury (Platform Admin or Creator) — Optional + * + * ⚙️ THIS STEP IS OPTIONAL — cancellation is permanent and + * irreversible. Use only for fraud, terms violation, or shutdown. + * + * Both the platform admin and the campaign owner can cancel the + * treasury (the contract checks both roles). + * + * Once cancelled: + * + * - No new payments can be created or confirmed + * - No withdrawals or fee disbursements are possible + * - Buyers can still claim refunds on cancelled/refundable payments + * + * `cancelTreasury(message)` takes a bytes32 reason code. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const paymentTreasury = platformOak.paymentTreasury( + process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +); + +const cancelReason = keccak256(toHex("terms-violation")); +const cancelTxHash = await paymentTreasury.cancelTreasury(cancelReason); +await platformOak.waitForReceipt(cancelTxHash); +console.log("Treasury permanently cancelled"); + +const isCancelled = await paymentTreasury.cancelled(); +console.log("Is cancelled:", isCancelled); // true diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md new file mode 100644 index 00000000..6e0123ce --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md @@ -0,0 +1,83 @@ +# Scenario 3: E-Commerce Payment Flow — Payment Treasury + +## The Story + +**CeloMarket** is an online marketplace where independent artisans sell handcrafted goods. Unlike the crowdfunding scenarios (Scenarios 1 and 2), CeloMarket does not run time-bound campaigns with pledges and rewards. Instead, it processes individual **e-commerce transactions** — a buyer selects a product, pays with cryptocurrency, and the platform fulfills the order. + +CeloMarket uses the **PaymentTreasury** model, which works like a traditional payment processor but entirely on-chain. Every payment is broken down into **line items** (product price, shipping, tax) and follows a two-step flow: the buyer pays, and the platform confirms after verifying the order. + +In this scenario, a buyer named **Sam** purchases a handcrafted ceramic vase for **$120** with **$15 shipping**. The payment flows through the treasury, gets confirmed by the platform, and the funds become available for withdrawal. + +## How It Unfolds + +1. **CeloMarket (Platform Admin)** connects to its deployed PaymentTreasury contract and reads back the platform configuration +2. **CeloMarket** creates a payment record for Sam's order — this includes the total amount, line items (product + shipping), external fees, and an expiration window. Batch creation is also available for high-volume platforms. +3. **Sam (Buyer)** transfers the payment on-chain by sending ERC-20 tokens to the treasury contract +4. **CeloMarket** verifies the order (inventory check, fraud review) and confirms the payment. Batch confirmation is available for multiple payments. +5. **Anyone** can read payment data and treasury balances — buyer address, amount, confirmation status, expected pending amount, and line item breakdown +6. If something goes wrong (wrong item shipped, order cancelled), a refund is issued. For off-chain payments the **platform admin** cancels and directs the refund to an address (`claimRefund`). For on-chain crypto payments the **buyer (NFT owner)** calls `claimRefundSelf` — the contract verifies NFT ownership, burns the NFT, and sends refundable line items back. +7. **Anyone** disburses accumulated protocol and platform fees +8. **CeloMarket or the Creator** withdraws confirmed funds to the campaign owner's wallet +9. For TimeConstrainedPaymentTreasury: the platform claims all remaining balances after the deadline + claim delay +10. **CeloMarket** claims non-goal line item accumulations (e.g., shipping fees) per token +11. **CeloMarket** can pause and unpause the treasury during an investigation +12. **CeloMarket or the Creator** can permanently cancel the treasury in extreme cases + +## NFT Handling in PaymentTreasury + +Unlike the AllOrNothing and KeepWhatsRaised scenarios — where the treasury contract **is** an ERC-721 itself and exposes NFT functions directly (e.g., `treasury.ownerOf(...)`, `treasury.burn(...)`) — the **PaymentTreasury does not expose any NFT methods**. NFT minting for crypto payments is delegated to the **CampaignInfo** contract via `INFO.mintNFTForPledge(...)` internally. + +This means: + +- There is no `paymentTreasury.ownerOf(...)` or `paymentTreasury.approve(...)`. +- NFT reads/writes for PaymentTreasury NFTs go through the **CampaignInfo** entity instead. +- `claimRefundSelf(paymentId)` is the only PaymentTreasury function that interacts with NFTs — it verifies the caller is the current NFT owner, sends the refundable amount to them, and burns the NFT automatically. +- `claimRefund(paymentId, refundAddress)` is for **non-NFT payments** (off-chain `createPayment` where no NFT was minted) and can only be called by the platform admin. + +## PaymentTreasury vs. TimeConstrainedPaymentTreasury + +The SDK's `oak.paymentTreasury(address)` supports **two on-chain variants** through the same interface: + +| Variant | Behavior | +| --- | --- | +| **PaymentTreasury** | Standard payment processing with no time restrictions. Payments can be created and confirmed at any time. | +| **TimeConstrainedPaymentTreasury** | Adds a **launch time** and **deadline** enforced on-chain. Payments can only be created after the launch time and before the deadline. Useful for limited-time sales, flash deals, or seasonal storefronts. Also enables `claimExpiredFunds` after deadline + claim delay. | + +From your code's perspective, there is **no difference**. You use `oak.paymentTreasury(address)` for both variants. The time constraints are enforced transparently by the contract — if you attempt to create a payment outside the allowed window on a TimeConstrainedPaymentTreasury, the transaction will revert with a typed error that you can catch using the patterns shown in [Scenario 5](../05-error-handling/). + +Which variant your platform uses depends on the treasury implementation registered and approved during [platform onboarding](../00-platform-enlistment/). + +## Files + +| Step | File | Role | Description | Required? | +| --- | --- | --- | --- | --- | +| 1 | `01-setup-treasury.ts` | Platform Admin | Connect to the PaymentTreasury and read platform config | Required | +| 2 | `02-create-payment.ts` | Platform Admin | Create a payment record with line items (single + batch) | Required | +| 3 | `03-process-crypto-payment.ts` | Buyer | Transfer ERC-20 tokens to the treasury | Required | +| 4 | `04-confirm-payment.ts` | Platform Admin | Confirm the payment after order verification (single + batch) | Required | +| 5 | `05-read-payment-data.ts` | Anyone | Read payment details and treasury dashboard | Required | +| 6 | `06-handle-refunds.ts` | Platform Admin / Buyer | Cancel a payment and claim a refund (self or admin-directed) | Required | +| 7 | `07-disburse-fees.ts` | Anyone | Disburse accumulated protocol and platform fees | Required | +| 8 | `08-withdraw-funds.ts` | Platform Admin or Creator | Withdraw confirmed funds to the campaign owner's wallet | Required | +| 9 | `09-claim-expired-funds.ts` | Platform Admin | Sweep remaining balances after deadline + claim delay (TimeConstrained only) | Required | +| 10 | `10-claim-non-goal-line-items.ts` | Platform Admin | Claim non-goal line item accumulations per token | Required | +| 11 | `11-pause-unpause-treasury.ts` | Platform Admin | Temporarily freeze and resume treasury operations | (Optional) | +| 12 | `12-cancel-treasury.ts` | Platform Admin or Creator | Permanently cancel a treasury | (Optional) | + +## Role Reference (from the Smart Contract) + +| Function | Who can call | Contract modifier | +| --- | --- | --- | +| `createPayment` / `createPaymentBatch` | Platform Admin | `onlyPlatformAdmin` | +| `processCryptoPayment` | Anyone (buyer) | (no role modifier) | +| `confirmPayment` / `confirmPaymentBatch` | Platform Admin | `onlyPlatformAdmin` | +| `cancelPayment` | Platform Admin | `onlyPlatformAdmin` | +| `claimRefundSelf(paymentId)` | NFT Owner (crypto payments only — verifies ownership, burns NFT) | (no role modifier) | +| `claimRefund(paymentId, refundAddress)` | Platform Admin (off-chain payments only — `tokenId == 0`) | `onlyPlatformAdmin` | +| `disburseFees` | Anyone | (no role modifier) | +| `withdraw` | Platform Admin or Creator | `onlyPlatformAdminOrCampaignOwner` | +| `claimExpiredFunds` | Platform Admin | `onlyPlatformAdmin` | +| `claimNonGoalLineItems` | Platform Admin | `onlyPlatformAdmin` | +| `pauseTreasury` / `unpauseTreasury` | Platform Admin | `onlyPlatformAdmin` | +| `cancelTreasury` | Platform Admin or Creator | custom check (both roles) | +| `getPaymentData`, `getRaisedAmount`, etc. | Anyone | (read-only) | From fdcef46c6817d6471655b1339df38f8edb03c861 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:43:27 +0600 Subject: [PATCH 17/86] feat: add event monitoring examples for analytics dashboard - Introduce a series of examples demonstrating event monitoring for an analytics dashboard, including fetching historical campaign events, treasury-specific events, real-time event watchers, decoding raw logs, and aggregating metrics. - Each step is documented in a README, outlining the process and providing a reference table for the implemented functionalities. --- .../04-event-monitoring/01-historical-logs.ts | 32 +++++++++++ .../04-event-monitoring/02-treasury-events.ts | 27 +++++++++ .../03-realtime-watchers.ts | 57 +++++++++++++++++++ .../04-event-monitoring/04-decode-raw-logs.ts | 38 +++++++++++++ .../05-metrics-aggregation.ts | 51 +++++++++++++++++ .../examples/04-event-monitoring/README.md | 30 ++++++++++ 6 files changed, 235 insertions(+) create mode 100644 packages/contracts/src/examples/04-event-monitoring/01-historical-logs.ts create mode 100644 packages/contracts/src/examples/04-event-monitoring/02-treasury-events.ts create mode 100644 packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts create mode 100644 packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts create mode 100644 packages/contracts/src/examples/04-event-monitoring/05-metrics-aggregation.ts create mode 100644 packages/contracts/src/examples/04-event-monitoring/README.md diff --git a/packages/contracts/src/examples/04-event-monitoring/01-historical-logs.ts b/packages/contracts/src/examples/04-event-monitoring/01-historical-logs.ts new file mode 100644 index 00000000..5022e576 --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/01-historical-logs.ts @@ -0,0 +1,32 @@ +/** + * Step 1: Fetch Historical Campaign Events (Platform) + * + * When ArtFund's dashboard loads for the first time, it needs to + * display all campaigns that have ever been created on the platform. + * This is done by querying the CampaignInfoFactory for historical + * CampaignCreated events starting from block 0. + * + * In production, you would typically store the last synced block + * number and only fetch new events on subsequent loads. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +const campaignLogs = await factory.events.getCampaignCreatedLogs({ + fromBlock: 0n, +}); + +console.log(`Found ${campaignLogs.length} campaigns`); + +for (const log of campaignLogs) { + console.log("Campaign:", log.args); +} diff --git a/packages/contracts/src/examples/04-event-monitoring/02-treasury-events.ts b/packages/contracts/src/examples/04-event-monitoring/02-treasury-events.ts new file mode 100644 index 00000000..13e4daf1 --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/02-treasury-events.ts @@ -0,0 +1,27 @@ +/** + * Step 2: Fetch Treasury-Specific Events (Platform) + * + * Each campaign has its own treasury contract, and each treasury + * emits events for every financial action: pledges, refunds, and + * withdrawals. ArtFund queries these events to build a detailed + * activity feed for each campaign on their dashboard. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = oak.allOrNothingTreasury(treasuryAddress); + +const pledgeLogs = await treasury.events.getReceiptLogs({ fromBlock: 0n }); +console.log(`${pledgeLogs.length} backers have pledged`); + +const refundLogs = await treasury.events.getRefundClaimedLogs({ fromBlock: 0n }); +console.log(`${refundLogs.length} refunds claimed`); + +const withdrawalLogs = await treasury.events.getWithdrawalSuccessfulLogs({ fromBlock: 0n }); +console.log(`${withdrawalLogs.length} withdrawals made`); diff --git a/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts b/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts new file mode 100644 index 00000000..b7ee1c1f --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts @@ -0,0 +1,57 @@ +/** + * Step 3: Watch for Real-Time Events (Platform) + * + * After loading historical data in Steps 1 and 2, ArtFund subscribes + * to live events so the dashboard updates instantly as new activity + * happens on-chain. Watchers use WebSocket connections under the hood + * and fire a callback every time a matching event is emitted. + * + * Each watcher returns an `unwatch` function that should be called + * when the component unmounts or the page navigates away, to avoid + * memory leaks and unnecessary RPC connections. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = oak.allOrNothingTreasury(treasuryAddress); + +// Watch for new campaigns +const unwatchCampaigns = factory.events.watchCampaignCreated((logs) => { + for (const log of logs) { + console.log("NEW CAMPAIGN:", log.args); + } +}); + +// Watch for new pledges +const unwatchPledges = treasury.events.watchReceipt((logs) => { + for (const log of logs) { + console.log("NEW PLEDGE:", log.args); + } +}); + +// Watch for platform-level events +const gp = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); + +const unwatchPlatforms = gp.events.watchPlatformEnlisted((logs) => { + for (const log of logs) { + console.log("NEW PLATFORM:", log.args); + } +}); + +// Clean up when the dashboard unmounts +function cleanup() { + unwatchCampaigns(); + unwatchPledges(); + unwatchPlatforms(); + console.log("All watchers stopped"); +} diff --git a/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts b/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts new file mode 100644 index 00000000..cd00b8ce --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts @@ -0,0 +1,38 @@ +/** + * Step 4: Decode Raw Logs (Developer) + * + * In some workflows, you receive raw log data from a transaction + * receipt or an external indexer (like The Graph or a custom backend). + * These logs contain encoded topics and data that are not human-readable. + * + * The SDK provides a `decodeLog` method on every entity's events object. + * Pass in the raw log and it returns a typed event with the event name + * and decoded arguments — no manual ABI parsing needed. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +// Decode logs from a transaction receipt +const someTxHash = "0x..." as `0x${string}`; +const receipt = await oak.waitForReceipt(someTxHash); + +for (const log of receipt.logs) { + const decoded = factory.events.decodeLog({ + topics: log.topics, + data: log.data, + }); + + if (decoded) { + console.log(`Event: ${decoded.eventName}`); + console.log(`Args:`, decoded.args); + } +} diff --git a/packages/contracts/src/examples/04-event-monitoring/05-metrics-aggregation.ts b/packages/contracts/src/examples/04-event-monitoring/05-metrics-aggregation.ts new file mode 100644 index 00000000..6b08afe8 --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/05-metrics-aggregation.ts @@ -0,0 +1,51 @@ +/** + * Step 5: Aggregate with the Metrics Module (Platform) + * + * For high-level dashboard statistics — total platforms, campaign + * health, treasury financials — the SDK provides a dedicated metrics + * module. Instead of manually querying events and summing values, + * you call pre-built aggregation functions that read directly from + * the contracts and return structured reports. + * + * The metrics module is imported from a separate subpath: + * `@oaknetwork/contracts-sdk/metrics` + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import { + getPlatformStats, + getCampaignSummary, + getTreasuryReport, +} from "@oaknetwork/contracts-sdk/metrics"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +// Platform overview +const platformStats = await getPlatformStats({ + globalParamsAddress: process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`, + publicClient: oak.publicClient, +}); +console.log("Total listed platforms:", platformStats.platformCount); + +// Campaign health check +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; +const campaignSummary = await getCampaignSummary({ + campaignInfoAddress, + publicClient: oak.publicClient, +}); +console.log("Total raised:", campaignSummary.totalRaised); +console.log("Goal reached:", campaignSummary.goalReached); + +// Treasury financial report +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasuryReport = await getTreasuryReport({ + treasuryAddress, + treasuryType: "all-or-nothing", + publicClient: oak.publicClient, +}); +console.log("Raised:", treasuryReport.raisedAmount); +console.log("Refunded:", treasuryReport.refundedAmount); +console.log("Fee percent:", treasuryReport.platformFeePercent); diff --git a/packages/contracts/src/examples/04-event-monitoring/README.md b/packages/contracts/src/examples/04-event-monitoring/README.md new file mode 100644 index 00000000..4e7d5681 --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/README.md @@ -0,0 +1,30 @@ +# Scenario 4: Event Monitoring — Dashboards and Analytics + +## The Story + +ArtFund's product team is building an **analytics dashboard** for their platform. They need to show platform operators and campaign creators a live view of everything happening on-chain — new campaigns launching, backers pledging, refunds being claimed, and funds being withdrawn. + +The dashboard has two layers: + +- A **historical data layer** that loads past events when the page first opens (e.g., "show me every campaign created since launch") +- A **real-time layer** that subscribes to new events as they happen on-chain and updates the UI instantly + +The SDK provides three tools for this: **event log queries** for historical data, **watchers** for real-time subscriptions, and a **metrics module** for pre-built aggregations like total raised, goal progress, and treasury reports. + +## How It Unfolds + +1. **ArtFund** fetches all historical campaign creation events from the CampaignInfoFactory to build the initial campaign list +2. **ArtFund** fetches treasury-specific events (pledges, refunds, withdrawals) for each campaign's treasury +3. **ArtFund** sets up real-time watchers that fire callbacks whenever new events are emitted on-chain +4. **Developers** decode raw transaction logs from receipts or external indexers into typed event data +5. **ArtFund** uses the metrics module to generate pre-built reports: platform stats, campaign summaries, and treasury financial reports + +## Files + +| Step | File | Role | Description | +| --- | --- | --- | --- | +| 1 | `01-historical-logs.ts` | Platform | Fetch past campaign creation events | +| 2 | `02-treasury-events.ts` | Platform | Fetch pledge, refund, and withdrawal events from a treasury | +| 3 | `03-realtime-watchers.ts` | Platform | Subscribe to live events as they happen | +| 4 | `04-decode-raw-logs.ts` | Developer | Decode raw logs from transaction receipts | +| 5 | `05-metrics-aggregation.ts` | Platform | Generate platform, campaign, and treasury reports | From da802270c2ffc39a42f8a1146ab1ed5aaed2d383 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:43:58 +0600 Subject: [PATCH 18/86] feat: add error handling and transaction safety examples - Introduce a series of examples demonstrating error handling and transaction safety in smart contract interactions. - Include steps for simulating transactions, preparing for external signing, catching typed errors, handling read-only clients, and implementing a safe transaction pattern. - Provide a convenience wrapper for simulation and error decoding in a single call. - Document the entire flow in a README, outlining the process and providing a reference table for the implemented functionalities. --- .../01-simulate-before-send.ts | 60 ++++++++++++++++++ .../02-prepare-transaction.ts | 39 ++++++++++++ .../03-catch-typed-errors.ts | 62 ++++++++++++++++++ .../05-error-handling/04-read-only-client.ts | 34 ++++++++++ .../05-safe-transaction-pattern.ts | 63 +++++++++++++++++++ .../06-simulate-with-error-decode.ts | 53 ++++++++++++++++ .../src/examples/05-error-handling/README.md | 33 ++++++++++ 7 files changed, 344 insertions(+) create mode 100644 packages/contracts/src/examples/05-error-handling/01-simulate-before-send.ts create mode 100644 packages/contracts/src/examples/05-error-handling/02-prepare-transaction.ts create mode 100644 packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts create mode 100644 packages/contracts/src/examples/05-error-handling/04-read-only-client.ts create mode 100644 packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts create mode 100644 packages/contracts/src/examples/05-error-handling/06-simulate-with-error-decode.ts create mode 100644 packages/contracts/src/examples/05-error-handling/README.md diff --git a/packages/contracts/src/examples/05-error-handling/01-simulate-before-send.ts b/packages/contracts/src/examples/05-error-handling/01-simulate-before-send.ts new file mode 100644 index 00000000..03688d8c --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/01-simulate-before-send.ts @@ -0,0 +1,60 @@ +/** + * Step 1: Simulate Before Sending + * + * Simulation calls the contract against the current chain state + * without broadcasting a transaction. If the simulation succeeds, + * the real transaction is safe to send. The simulation result + * includes the predicted return value and gas estimate. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + getCurrentTimestamp, + addDays, + CHAIN_IDS, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PRIVATE_KEY! as `0x${string}`, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("artfund")); +const identifierHash = keccak256(toHex("simulation-test-campaign")); +const now = getCurrentTimestamp(); + +const campaignParams = { + creator: process.env.CREATOR_ADDRESS! as `0x${string}`, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now + 3600n, + deadline: addDays(now, 30), + goalAmount: 1_000_000_000n, + currency: toHex("USD", { size: 32 }), + }, + nftName: "Test Campaign", + nftSymbol: "TC", + nftImageURI: "ipfs://test", + contractURI: "ipfs://test-meta", +}; + +// Simulate first +const simulation = await factory.simulate.createCampaign(campaignParams); + +// simulation.result — the return value the contract would produce +// simulation.request — { to, data, value, gas } +console.log("Simulation succeeded!"); +console.log("Estimated gas:", simulation.request.gas); + +// Safe to send the real transaction +const txHash = await factory.createCampaign(campaignParams); +await oak.waitForReceipt(txHash); +console.log("Campaign created successfully"); diff --git a/packages/contracts/src/examples/05-error-handling/02-prepare-transaction.ts b/packages/contracts/src/examples/05-error-handling/02-prepare-transaction.ts new file mode 100644 index 00000000..d95e9b8e --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/02-prepare-transaction.ts @@ -0,0 +1,39 @@ +/** + * Step 2: Prepare Transactions for External Signing + * + * For account-abstraction wallets, Safe multisig, or custom signing + * flows, use toPreparedTransaction to extract raw transaction + * parameters from a simulation result. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + CHAIN_IDS, + toPreparedTransaction, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PRIVATE_KEY! as `0x${string}`, +}); + +const gp = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); +const platformHash = keccak256(toHex("artfund")); + +const simulation = await gp.simulate.updatePlatformClaimDelay( + platformHash, + 604800n, // 7 days +); + +// Convert to raw transaction params for external signing +const preparedTx = toPreparedTransaction(simulation); + +console.log("To:", preparedTx.to); +console.log("Data:", preparedTx.data); +console.log("Value:", preparedTx.value); +console.log("Gas:", preparedTx.gas); + +// Send this to your multisig, bundler, or external signer diff --git a/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts new file mode 100644 index 00000000..7d8434cb --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts @@ -0,0 +1,62 @@ +/** + * Step 3: Catch and Parse Typed Errors + * + * When a transaction reverts, the SDK decodes the raw revert data + * into a typed error class with a human-readable recovery hint. + * + * Two patterns are shown: + * 1. Check for specific error types with instanceof + * 2. Parse unknown revert data with parseContractError + */ + +import { + createOakContractsClient, + keccak256, + toHex, + CHAIN_IDS, + parseContractError, + getRevertData, + getRecoveryHint, +} from "@oaknetwork/contracts-sdk"; + +import { + CampaignInfoUnauthorizedError, +} from "@oaknetwork/contracts-sdk/errors"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PRIVATE_KEY! as `0x${string}`, +}); + +const campaign = oak.campaignInfo(process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`); + +try { + await campaign.cancelCampaign(toHex("cancelled by user", { size: 32 })); +} catch (error) { + // Pattern 1: Check for specific error types + if (error instanceof CampaignInfoUnauthorizedError) { + console.error("You are not the campaign owner."); + console.error("Hint:", error.recoveryHint); + // return; + } + + // Pattern 2: Parse unknown revert data + const revertData = getRevertData(error); + if (revertData) { + const parsed = parseContractError(revertData); + if (parsed) { + console.error(`Contract error: ${parsed.name}`); + console.error("Arguments:", parsed.args); + + const hint = getRecoveryHint(parsed); + if (hint) { + console.error("Recovery hint:", hint); + } + // return; + } + } + + // Unknown error + console.error("Unknown error:", (error as Error).message); +} diff --git a/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts b/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts new file mode 100644 index 00000000..a23049ae --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts @@ -0,0 +1,34 @@ +/** + * Step 4: Handle Read-Only Client Restrictions + * + * When using a read-only client (no private key), write methods + * throw immediately with "No signer configured" without making + * an RPC call. Build your UI to handle this gracefully. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const readOnlyOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const campaign = readOnlyOak.campaignInfo( + process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`, +); + +// Reads work fine +const goalAmount = await campaign.getGoalAmount(); +console.log("Goal amount:", goalAmount); + +const deadline = await campaign.getDeadline(); +console.log("Deadline:", new Date(Number(deadline) * 1000).toISOString()); + +// Writes throw immediately +try { + await campaign.updateGoalAmount(2_000_000_000n); +} catch (error) { + if ((error as Error).message === "No signer configured.") { + console.error("Connect your wallet to perform this action."); + } +} diff --git a/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts b/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts new file mode 100644 index 00000000..6cbf5614 --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts @@ -0,0 +1,63 @@ +/** + * Step 5: Simulate-Then-Send Pattern for UI + * + * A reusable pattern that simulates a transaction, shows the user + * what will happen, and only sends after simulation passes. + * Reverts are caught and displayed as user-friendly error messages. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + CHAIN_IDS, + parseContractError, + getRevertData, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PRIVATE_KEY! as `0x${string}`, +}); + +async function safeTransaction( + description: string, + simulateFn: () => Promise, + executeFn: () => Promise<`0x${string}`>, +) { + console.log(`Preparing: ${description}`); + + // Step 1: Simulate + try { + await simulateFn(); + console.log("Simulation passed — transaction will succeed"); + } catch (error) { + const revertData = getRevertData(error); + const parsed = revertData ? parseContractError(revertData) : null; + + if (parsed) { + console.error(`Transaction would fail: ${parsed.name}`); + console.error(parsed.recoveryHint || "No recovery hint available"); + } else { + console.error("Transaction would fail:", (error as Error).message); + } + return null; + } + + // Step 2: Execute + const txHash = await executeFn(); + const receipt = await oak.waitForReceipt(txHash); + console.log(`Success at block ${receipt.blockNumber}`); + return receipt; +} + +// Usage: simulate then send a campaign update +const campaign = oak.campaignInfo(process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`); +const newDeadline = BigInt(Math.floor(Date.now() / 1000)) + 86400n * 45n; // 45 days from now + +await safeTransaction( + "Update campaign deadline", + () => campaign.simulate.updateDeadline(newDeadline), + () => campaign.updateDeadline(newDeadline), +); diff --git a/packages/contracts/src/examples/05-error-handling/06-simulate-with-error-decode.ts b/packages/contracts/src/examples/05-error-handling/06-simulate-with-error-decode.ts new file mode 100644 index 00000000..aefa10a8 --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/06-simulate-with-error-decode.ts @@ -0,0 +1,53 @@ +/** + * Step 6: One-Call Simulate + Error Decode + * + * `simulateWithErrorDecode` is a convenience wrapper that simulates + * a contract call and automatically decodes any revert into a typed + * error. It combines the simulation from Step 1 with the error + * parsing from Step 3 into a single function call. + * + * If the simulation succeeds, it returns the SimulationResult. + * If the simulation reverts, it throws a typed error with a + * `recoveryHint` property — no manual `getRevertData` / + * `parseContractError` needed. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + CHAIN_IDS, + simulateWithErrorDecode, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PRIVATE_KEY! as `0x${string}`, +}); + +const treasury = oak.allOrNothingTreasury( + process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`, +); + +const rewardName = keccak256(toHex("premium-tier")); + +try { + const result = await simulateWithErrorDecode( + () => treasury.simulate.removeReward(rewardName), + ); + + console.log("Simulation passed — safe to send"); + console.log("Gas estimate:", result.request.gas); + + const txHash = await treasury.removeReward(rewardName); + await oak.waitForReceipt(txHash); + console.log("Reward removed"); +} catch (error) { + // error is already a typed contract error with recoveryHint + const typedError = error as { name: string; recoveryHint?: string }; + console.error(`Would revert: ${typedError.name}`); + if (typedError.recoveryHint) { + console.error("Hint:", typedError.recoveryHint); + } +} diff --git a/packages/contracts/src/examples/05-error-handling/README.md b/packages/contracts/src/examples/05-error-handling/README.md new file mode 100644 index 00000000..9423a796 --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/README.md @@ -0,0 +1,33 @@ +# Scenario 5: Error Handling and Transaction Safety + +## The Story + +Kai is a frontend developer at ArtFund, responsible for building the campaign management interface. Before any transaction is sent to the blockchain, Kai wants to: + +1. **Preview the outcome** — simulate the transaction against the current chain state to see if it would succeed +2. **Show clear error messages** — if the transaction would fail, explain why in plain language and suggest what to do +3. **Estimate the cost** — display the gas estimate so users know what they will pay before confirming + +Kai also needs to handle edge cases that come up in production: What happens when a user without the right permissions tries to perform a restricted action? What about users browsing the app without a connected wallet? How should the UI handle a read-only session? + +These patterns are essential for any production application built on Oak Protocol. A good error handling strategy turns cryptic blockchain reverts into helpful user-facing messages. + +## How It Unfolds + +1. **Simulate before sending** — Call the contract's `simulate` method to preview a transaction. If simulation passes, the transaction is safe to broadcast +2. **Prepare for external signing** — Extract raw transaction parameters from a simulation result for use with multisig wallets, account abstraction, or custom signing flows +3. **Catch typed errors** — When a transaction reverts, decode the raw revert data into a named error class with a human-readable recovery hint +4. **Handle read-only clients** — When no wallet is connected, write methods throw immediately without making an RPC call. The UI should prompt the user to connect their wallet +5. **Complete simulate-then-send pattern** — A reusable function that combines simulation, error handling, and execution into a single safe workflow +6. **One-call simulate + error decode** — `simulateWithErrorDecode` wraps simulation and error parsing into a single convenience call + +## Files + +| Step | File | Description | +| --- | --- | --- | +| 1 | `01-simulate-before-send.ts` | Simulate a transaction and inspect the result before broadcasting | +| 2 | `02-prepare-transaction.ts` | Extract raw transaction parameters for external or multisig signing | +| 3 | `03-catch-typed-errors.ts` | Catch and decode typed revert errors with recovery hints | +| 4 | `04-read-only-client.ts` | Handle the case where no wallet is connected | +| 5 | `05-safe-transaction-pattern.ts` | A reusable simulate-then-send pattern for production UIs | +| 6 | `06-simulate-with-error-decode.ts` | One-call convenience wrapper — simulate + auto-decode reverts | From f436b9029022a52a627106fd34d951911040722c Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:44:38 +0600 Subject: [PATCH 19/86] feat: add advanced patterns examples for contract interactions - Introduce a series of examples demonstrating advanced patterns in smart contract interactions, including multicall for batch reads, per-entity and per-call signer overrides, item registration in the ItemRegistry, and protocol registry value lookups. - Implement non-blocking receipt lookups and browser wallet integrations for enhanced user experience. - Document the entire flow in a README, outlining the scenarios and providing a reference table for the implemented functionalities. --- .../06-advanced-patterns/01-multicall.ts | 31 ++++++++ .../02-per-entity-signer.ts | 39 +++++++++ .../03-per-call-signer.ts | 36 +++++++++ .../06-advanced-patterns/04-item-registry.ts | 75 ++++++++++++++++++ .../06-advanced-patterns/05-registry-keys.ts | 52 ++++++++++++ .../06-advanced-patterns/06-get-receipt.ts | 32 ++++++++ .../06-advanced-patterns/07-browser-wallet.ts | 79 +++++++++++++++++++ .../06-advanced-patterns/08-privy-wallet.ts | 70 ++++++++++++++++ .../examples/06-advanced-patterns/README.md | 34 ++++++++ 9 files changed, 448 insertions(+) create mode 100644 packages/contracts/src/examples/06-advanced-patterns/01-multicall.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/05-registry-keys.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/06-get-receipt.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/07-browser-wallet.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/08-privy-wallet.ts create mode 100644 packages/contracts/src/examples/06-advanced-patterns/README.md diff --git a/packages/contracts/src/examples/06-advanced-patterns/01-multicall.ts b/packages/contracts/src/examples/06-advanced-patterns/01-multicall.ts new file mode 100644 index 00000000..f1dbd135 --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/01-multicall.ts @@ -0,0 +1,31 @@ +/** + * Step 1: Batch Reads with Multicall + * + * Instead of making 5 separate RPC calls, batch them into one + * round-trip using oak.multicall(). Each read is wrapped in a + * lazy function so they execute together. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const gp = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); +const campaign = oak.campaignInfo(process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`); + +const [platformCount, protocolFee, goalAmount, totalRaised, deadline] = await oak.multicall([ + () => gp.getNumberOfListedPlatforms(), + () => gp.getProtocolFeePercent(), + () => campaign.getGoalAmount(), + () => campaign.getTotalRaisedAmount(), + () => campaign.getDeadline(), +]); + +console.log("Platforms:", platformCount); +console.log("Protocol fee:", protocolFee, "bps"); +console.log("Goal: $", Number(goalAmount) / 1_000_000); +console.log("Raised: $", Number(totalRaised) / 1_000_000); +console.log("Deadline:", new Date(Number(deadline) * 1000)); diff --git a/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts b/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts new file mode 100644 index 00000000..59fb4399 --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts @@ -0,0 +1,39 @@ +/** + * Step 2: Per-Entity Signer Override + * + * In a browser dApp, the signer is resolved after the user connects + * their wallet. Pass it when creating the entity — all writes on + * that entity automatically use it. + */ + +import { + createOakContractsClient, + createWallet, + CHAIN_IDS, +} from "@oaknetwork/contracts-sdk"; + +// Start with a read-only client +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +// User connects their wallet — resolve the signer +const userPrivateKey = process.env.USER_PRIVATE_KEY! as `0x${string}`; +const userSigner = createWallet(userPrivateKey, process.env.RPC_URL!, oak.config.chain); + +// Create an entity with the user's signer +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = oak.allOrNothingTreasury(treasuryAddress, { signer: userSigner }); + +// All writes automatically use userSigner +const backerAddr = process.env.USER_ADDRESS! as `0x${string}`; +const pledgeToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; + +// Reads — no signer needed +const raised = await treasury.getRaisedAmount(); +console.log("Raised:", raised); + +// Writes — automatically use userSigner +// await treasury.pledgeForAReward(backerAddr, pledgeToken, 0n, [rewardHash]); +// await treasury.claimRefund(tokenId); diff --git a/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts b/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts new file mode 100644 index 00000000..d6b912ce --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts @@ -0,0 +1,36 @@ +/** + * Step 3: Per-Call Signer Override + * + * Different operations on the same contract require different signers. + * For example, the protocol admin disburses fees but the creator + * withdraws funds. Pass the signer as the last argument on each call. + */ + +import { createOakContractsClient, createWallet, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const adminSigner = createWallet( + process.env.ADMIN_PRIVATE_KEY! as `0x${string}`, + process.env.RPC_URL!, + oak.config.chain, +); + +const creatorSigner = createWallet( + process.env.CREATOR_PRIVATE_KEY! as `0x${string}`, + process.env.RPC_URL!, + oak.config.chain, +); + +// No entity-level signer — override per call +const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const treasury = oak.allOrNothingTreasury(treasuryAddress); + +// Admin disburses fees +await treasury.disburseFees({ signer: adminSigner }); + +// Creator withdraws funds +await treasury.withdraw({ signer: creatorSigner }); diff --git a/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts b/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts new file mode 100644 index 00000000..3b8b217e --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts @@ -0,0 +1,75 @@ +/** + * Step 4: Register Physical Items in the Item Registry + * + * For campaigns that ship physical products, use the ItemRegistry + * to register item metadata (dimensions, weight, category) on-chain. + * Supports single and batch registration. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import type { Item } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PRIVATE_KEY! as `0x${string}`, +}); + +const itemRegistry = oak.itemRegistry( + process.env.ITEM_REGISTRY_ADDRESS! as `0x${string}`, +); + +// --- Single item registration --- + +const vaseItemId = keccak256(toHex("handcrafted-vase-001")); + +const vaseItem: Item = { + actualWeight: 2500n, // 2500 grams (2.5 kg) + height: 300n, // 300 mm + width: 150n, // 150 mm + length: 150n, // 150 mm + category: keccak256(toHex("ceramics")), + declaredCurrency: toHex("USD", { size: 32 }), +}; + +const txHash = await itemRegistry.addItem(vaseItemId, vaseItem); +await oak.waitForReceipt(txHash); +console.log("Vase registered in the item registry"); + +// Read back the item +const storedItem = await itemRegistry.getItem( + process.env.CREATOR_ADDRESS! as `0x${string}`, + vaseItemId, +); +console.log("Weight:", storedItem.actualWeight, "grams"); +console.log("Dimensions:", storedItem.height, "x", storedItem.width, "x", storedItem.length, "mm"); + +// --- Batch registration --- + +const item1Id = keccak256(toHex("sticker-pack-001")); +const item2Id = keccak256(toHex("signed-print-001")); + +const item1: Item = { + actualWeight: 50n, + height: 150n, + width: 100n, + length: 5n, + category: keccak256(toHex("paper-goods")), + declaredCurrency: toHex("USD", { size: 32 }), +}; + +const item2: Item = { + actualWeight: 200n, + height: 400n, + width: 300n, + length: 10n, + category: keccak256(toHex("art-prints")), + declaredCurrency: toHex("USD", { size: 32 }), +}; + +const batchTxHash = await itemRegistry.addItemsBatch( + [item1Id, item2Id], + [item1, item2], +); +await oak.waitForReceipt(batchTxHash); +console.log("2 items registered in a single transaction"); diff --git a/packages/contracts/src/examples/06-advanced-patterns/05-registry-keys.ts b/packages/contracts/src/examples/06-advanced-patterns/05-registry-keys.ts new file mode 100644 index 00000000..14c5ebbb --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/05-registry-keys.ts @@ -0,0 +1,52 @@ +/** + * Step 5: Look Up Protocol Registry Values + * + * The protocol stores configuration values (buffer times, payment + * expirations, campaign duration minimums) in a data registry. + * Values can be global or scoped to a specific platform. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + CHAIN_IDS, + DATA_REGISTRY_KEYS, + scopedToPlatform, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const gp = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); + +// --- Global registry values (not platform-specific) --- + +const bufferTime = await gp.getFromRegistry(DATA_REGISTRY_KEYS.BUFFER_TIME); +console.log("Buffer time:", bufferTime); + +const maxPaymentExpiration = await gp.getFromRegistry( + DATA_REGISTRY_KEYS.MAX_PAYMENT_EXPIRATION, +); +console.log("Max payment expiration:", maxPaymentExpiration); + +const minCampaignDuration = await gp.getFromRegistry( + DATA_REGISTRY_KEYS.MINIMUM_CAMPAIGN_DURATION, +); +console.log("Minimum campaign duration:", minCampaignDuration); + +// --- Platform-scoped registry values --- + +const platformHash = keccak256(toHex("artfund")); + +const platformBufferTime = await gp.getFromRegistry( + scopedToPlatform(DATA_REGISTRY_KEYS.BUFFER_TIME, platformHash), +); +console.log("ArtFund-specific buffer time:", platformBufferTime); + +const platformLaunchBuffer = await gp.getFromRegistry( + scopedToPlatform(DATA_REGISTRY_KEYS.CAMPAIGN_LAUNCH_BUFFER, platformHash), +); +console.log("ArtFund campaign launch buffer:", platformLaunchBuffer); diff --git a/packages/contracts/src/examples/06-advanced-patterns/06-get-receipt.ts b/packages/contracts/src/examples/06-advanced-patterns/06-get-receipt.ts new file mode 100644 index 00000000..080c94c8 --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/06-get-receipt.ts @@ -0,0 +1,32 @@ +/** + * Step 6: Non-Blocking Receipt Lookup + * + * `oak.getReceipt(txHash)` fetches the receipt for an already-mined + * transaction without blocking. Unlike `waitForReceipt` — which + * polls until the transaction is included in a block — `getReceipt` + * returns immediately with the receipt or `null` if the transaction + * has not been mined yet. + * + * Use this when you already have a transaction hash from a webhook, + * an indexer, a database, or a previous user session, and you want + * to check its status without waiting. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, +}); + +const txHash = process.env.PREVIOUS_TX_HASH! as `0x${string}`; + +const receipt = await oak.getReceipt(txHash); + +if (receipt) { + console.log("Transaction mined at block:", receipt.blockNumber); + console.log("Gas used:", receipt.gasUsed); + console.log("Log count:", receipt.logs.length); +} else { + console.log("Transaction not yet mined — try again later"); +} diff --git a/packages/contracts/src/examples/06-advanced-patterns/07-browser-wallet.ts b/packages/contracts/src/examples/06-advanced-patterns/07-browser-wallet.ts new file mode 100644 index 00000000..2e877b84 --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/07-browser-wallet.ts @@ -0,0 +1,79 @@ +/** + * Step 7: Browser Wallet Integration (MetaMask / Injected Providers) + * + * For frontend applications that use MetaMask, Coinbase Wallet, or + * any browser extension that injects `window.ethereum`, the SDK + * provides two helpers: + * + * - `createBrowserProvider(ethereum, chain)` — wraps the injected + * provider into a viem PublicClient for on-chain reads + * - `getSigner(ethereum, chain)` — requests accounts from the + * wallet (triggers the MetaMask popup) and returns a WalletClient + * with the connected account attached + * + * Two usage patterns are shown: + * + * A. Full configuration — construct the client with provider and + * signer up front, so every entity inherits the wallet + * B. Per-entity override — start with a read-only client and pass + * the signer only when creating a specific entity + * + * This file requires a browser environment with `window.ethereum`. + */ + +import { + createOakContractsClient, + createBrowserProvider, + getSigner, + getChainFromId, + CHAIN_IDS, +} from "@oaknetwork/contracts-sdk"; + +declare const window: { ethereum: Parameters[0] }; + +// ============================================================ +// A. Full Configuration — provider + signer passed to client +// ============================================================ + +async function browserWalletFullConfig(): Promise { + const chain = getChainFromId(CHAIN_IDS.CELO_TESTNET_SEPOLIA); + const provider = createBrowserProvider(window.ethereum, chain); + const signer = await getSigner(window.ethereum, chain); + + const oak = createOakContractsClient({ chain, provider, signer }); + + const gp = oak.globalParams("0x..." as `0x${string}`); + + // Reads + const admin = await gp.getProtocolAdminAddress(); + console.log("Protocol admin:", admin); + + // Writes — automatically use the browser wallet signer + // await gp.enlistPlatform(platformHash, adminAddr, fee, adapter); +} + +// ============================================================ +// B. Per-Entity Override — read-only client + signer on entity +// ============================================================ + +async function browserWalletPerEntity(): Promise { + const chain = getChainFromId(CHAIN_IDS.CELO_TESTNET_SEPOLIA); + + const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: "https://forno.celo-sepolia.celo-testnet.org", + }); + + const signer = await getSigner(window.ethereum, chain); + console.log("Connected wallet:", signer.account.address); + + const treasuryAddress = "0x..." as `0x${string}`; + const treasury = oak.allOrNothingTreasury(treasuryAddress, { signer }); + + const raised = await treasury.getRaisedAmount(); + console.log("Raised:", raised); + + // Writes use the browser wallet signer + // const txHash = await treasury.claimRefund(0n); + // await oak.waitForReceipt(txHash); +} diff --git a/packages/contracts/src/examples/06-advanced-patterns/08-privy-wallet.ts b/packages/contracts/src/examples/06-advanced-patterns/08-privy-wallet.ts new file mode 100644 index 00000000..8eae43e6 --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/08-privy-wallet.ts @@ -0,0 +1,70 @@ +/** + * Step 8: Privy Wallet Integration + * + * Privy embedded wallets expose an EIP-1193 provider. Pass that + * provider to viem's `custom` transport for both `createPublicClient` + * and `createWalletClient`, then pass `chain`, `provider`, and + * `signer` into `createOakContractsClient` — the same full-config + * pattern as the browser wallet example (Step 7, Pattern A). + * + * This snippet uses the `useWallets` hook from `@privy-io/react-auth` + * to pick a wallet. Replace that with whatever wallet selection logic + * your app uses. + * + * This file requires a React environment with Privy configured. + */ + +import { + createOakContractsClient, + createPublicClient, + createWalletClient, + custom, + getChainFromId, + CHAIN_IDS, +} from "@oaknetwork/contracts-sdk"; + +// Replace with your actual Privy wallet hook +// import { useWallets } from "@privy-io/react-auth"; + +async function connectPrivyWallet(): Promise { + // --- In a React component, you would use the hook: --- + // const { wallets } = useWallets(); + // const wallet = wallets[0]; // or select by address / connector + + // For this example, assume `wallet` is available: + const wallet = {} as { + address: string; + switchChain: (chainId: number) => Promise; + getEthereumProvider: () => Promise[0]>; + }; + + const chain = getChainFromId(CHAIN_IDS.CELO_TESTNET_SEPOLIA); + await wallet.switchChain(chain.id); + + const ethereumProvider = await wallet.getEthereumProvider(); + + const provider = createPublicClient({ + chain, + transport: custom(ethereumProvider), + }); + + const signer = createWalletClient({ + chain, + transport: custom(ethereumProvider), + account: wallet.address as `0x${string}`, + }); + + const oak = createOakContractsClient({ chain, provider, signer }); + + // From here, usage is identical to any other client + const gp = oak.globalParams("0x..." as `0x${string}`); + + const admin = await gp.getProtocolAdminAddress(); + console.log("Protocol admin:", admin); + + const fee = await gp.getProtocolFeePercent(); + console.log("Protocol fee:", Number(fee), "bps"); + + // Writes use the Privy wallet signer automatically + // await gp.enlistPlatform(platformHash, adminAddr, fee, adapter); +} diff --git a/packages/contracts/src/examples/06-advanced-patterns/README.md b/packages/contracts/src/examples/06-advanced-patterns/README.md new file mode 100644 index 00000000..61e2f8ba --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/README.md @@ -0,0 +1,34 @@ +# Scenario 6: Advanced Patterns + +## The Story + +ArtFund has grown. They now manage dozens of active campaigns, multiple treasury contracts, and a growing catalog of physical products that need to be tracked on-chain. Their engineering team needs to optimize for performance and handle complex operational requirements: + +- **Performance** — Loading a dashboard that reads data from 10+ contracts should not require 10+ separate RPC calls. They need to batch reads into a single network round-trip. +- **Multi-role operations** — Some operations on the same contract require different signers. For example, the protocol admin disburses fees, but the campaign creator withdraws funds. The SDK needs to support flexible signer resolution. +- **Physical product tracking** — Campaigns that ship physical goods need to register item metadata (dimensions, weight, category) in the ItemRegistry so logistics and customs can be automated. +- **Protocol configuration** — The platform needs to read global and platform-scoped protocol parameters like buffer times, payment expirations, and campaign duration minimums. + +## How It Unfolds + +1. **Batch multiple reads** into a single RPC call using `oak.multicall()` — read data from GlobalParams and CampaignInfo in one network request +2. **Assign a signer at entity creation time** — useful in browser dApps where the signer is resolved after wallet connection +3. **Override the signer on individual calls** — useful when different roles operate on the same contract +4. **Register physical items** in the ItemRegistry with dimensions, weight, and category — supports single and batch registration +5. **Read protocol configuration** from the data registry — global values and platform-scoped overrides +6. **Look up a transaction receipt** without blocking — useful for webhooks, indexers, and resuming past sessions +7. **Connect a browser wallet** — `createBrowserProvider` and `getSigner` for MetaMask and injected wallet integration (full-config and per-entity patterns) +8. **Connect a Privy wallet** — `createPublicClient` + `createWalletClient` + `custom` transport for Privy embedded wallets + +## Files + +| Step | File | Description | +| --- | --- | --- | +| 1 | `01-multicall.ts` | Batch multiple read operations into a single RPC call | +| 2 | `02-per-entity-signer.ts` | Assign a signer when creating an entity (browser dApp pattern) | +| 3 | `03-per-call-signer.ts` | Override the signer on individual write calls (multi-role pattern) | +| 4 | `04-item-registry.ts` | Register physical items with dimensions and weight | +| 5 | `05-registry-keys.ts` | Read global and platform-scoped protocol configuration | +| 6 | `06-get-receipt.ts` | Non-blocking receipt lookup for already-mined transactions | +| 7 | `07-browser-wallet.ts` | Browser wallet integration with MetaMask / injected providers | +| 8 | `08-privy-wallet.ts` | Privy embedded wallet integration | From 4002f7a64edc0bd974f5834b47ab4158fd92146f Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:44:52 +0600 Subject: [PATCH 20/86] feat: add shared client setup for example contracts - Introduce a new setup module for shared client configuration across examples, including environment variable requirements for RPC URL and contract addresses. - Implement functions to create a client instance and validate environment variables, enhancing the usability of example contracts. --- .../contracts/src/examples/_shared/setup.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/contracts/src/examples/_shared/setup.ts diff --git a/packages/contracts/src/examples/_shared/setup.ts b/packages/contracts/src/examples/_shared/setup.ts new file mode 100644 index 00000000..602ab0e6 --- /dev/null +++ b/packages/contracts/src/examples/_shared/setup.ts @@ -0,0 +1,41 @@ +/** + * Shared client setup used across all examples. + * + * Environment variables required: + * RPC_URL — JSON-RPC endpoint (e.g. Celo Sepolia) + * PRIVATE_KEY — 0x-prefixed private key for write operations + * GLOBAL_PARAMS_ADDRESS — GlobalParams contract address + * CAMPAIGN_INFO_FACTORY_ADDRESS — CampaignInfoFactory contract address + * TREASURY_FACTORY_ADDRESS — TreasuryFactory contract address + * ITEM_REGISTRY_ADDRESS — ItemRegistry contract address + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +export function createClient(privateKey?: `0x${string}`) { + const rpcUrl = process.env.RPC_URL; + if (!rpcUrl) throw new Error("RPC_URL environment variable is required"); + + if (privateKey) { + return createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl, + privateKey, + }); + } + + return createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl, + }); +} + +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} environment variable is required`); + return value; +} + +export function requireAddress(name: string): `0x${string}` { + return requireEnv(name) as `0x${string}`; +} From dfe5455d4973293619d4733598a8421020efae31 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:45:35 +0600 Subject: [PATCH 21/86] feat: add comprehensive README.md for examples for Oak Contracts SDK - Introduce a new README.md file containing real-world examples that demonstrate the capabilities of the Oak Contracts SDK, including platform onboarding, crowdfunding campaigns, e-commerce checkout, and more. - Each scenario is structured with a detailed explanation, folder organization, and step-by-step TypeScript files to facilitate understanding and implementation. - Provide a clear overview of roles involved in each scenario, enhancing the usability for developers and stakeholders. --- packages/contracts/src/examples/README.md | 162 ++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/contracts/src/examples/README.md diff --git a/packages/contracts/src/examples/README.md b/packages/contracts/src/examples/README.md new file mode 100644 index 00000000..880f32af --- /dev/null +++ b/packages/contracts/src/examples/README.md @@ -0,0 +1,162 @@ +# Oak Contracts SDK — Examples + +Real-world, scenario-driven examples that walk you through every capability of the Oak Contracts SDK. Each scenario tells a story — a platform onboarding, a crowdfunding campaign, an e-commerce checkout — and implements it step by step with working code. + +Whether you are a developer integrating Oak into your product or a stakeholder evaluating the protocol, these examples show exactly how the SDK works in practice. + +> **Getting started:** You need deployed contract addresses and testnet access. +> Contact **[support@oaknetwork.org](mailto:support@oaknetwork.org)** to begin your onboarding. + +--- + +## How to Read These Examples + +Each scenario folder contains: + +- A **README.md** with the story, the roles involved, and a summary of each step +- **Numbered TypeScript files** (`01-...`, `02-...`) that you can read top-to-bottom like a tutorial + +Start with **Scenario 0** if you are a new platform joining Oak Protocol. Start with **Scenario 1** or **2** if your platform is already onboarded and you want to launch a campaign. + +--- + +## Folder Structure + +``` +examples/ +├── README.md ← you are here +├── _shared/ +│ └── setup.ts ← shared client setup and env helpers +│ +├── 00-platform-enlistment/ ← Platform onboarding (Protocol Admin + Platform Admin) +│ ├── README.md +│ ├── 01-enlist-platform.ts +│ ├── 02-verify-enlistment.ts +│ ├── 03-register-treasury-implementations.ts +│ ├── 04-approve-implementations.ts +│ ├── 05-verify-setup.ts +│ └── 06-optional-configuration.ts ← line items, claim delay, data keys, adapter +│ +├── 01-campaign-all-or-nothing/ ← Crowdfunding: all-or-nothing model +│ ├── README.md +│ ├── 01-create-campaign.ts +│ ├── 02-lookup-campaign.ts +│ ├── 03-review-campaign.ts +│ ├── 04-deploy-treasury.ts +│ ├── 05-manage-rewards.ts ← add + remove rewards +│ ├── 06-backer-pledge.ts ← with or without a reward +│ ├── 07-monitor-progress.ts +│ ├── 08-disburse-fees.ts +│ ├── 09a-success-withdraw.ts +│ ├── 09b-failure-refund.ts +│ ├── 10-pause-unpause-treasury.ts +│ └── 11-cancel-treasury.ts +│ +├── 02-campaign-keep-whats-raised/ ← Crowdfunding: flexible funding model +│ ├── README.md +│ ├── 01-create-campaign.ts +│ ├── 02-deploy-treasury.ts +│ ├── 03-configure-treasury.ts ← Platform Admin +│ ├── 04-manage-rewards.ts ← add + remove rewards +│ ├── 05-backer-pledge.ts ← with/without reward, gateway fees +│ ├── 06a-partial-withdrawal.ts ← mid-campaign partial withdrawal +│ ├── 06b-final-withdrawal.ts ← post-deadline sweep +│ ├── 07-monitor-progress.ts ← full campaign dashboard +│ ├── 08-disburse-fees.ts ← must call before cancellation +│ ├── 09-claim-fund.ts ← Platform Admin +│ ├── 10-claim-tips.ts ← Platform Admin +│ ├── 11-claim-refund.ts +│ ├── 12-update-campaign.ts ← OPTIONAL +│ ├── 13-pause-unpause-treasury.ts ← OPTIONAL +│ └── 14-cancel-treasury.ts ← OPTIONAL +│ +├── 03-campaign-payment-treasury/ ← E-commerce payment processing +│ ├── README.md +│ ├── 01-setup-treasury.ts +│ ├── 02-create-payment.ts ← single + batch +│ ├── 03-process-crypto-payment.ts +│ ├── 04-confirm-payment.ts ← single + batch +│ ├── 05-read-payment-data.ts ← payment details + treasury dashboard +│ ├── 06-handle-refunds.ts ← cancel + self/admin refund +│ ├── 07-disburse-fees.ts +│ ├── 08-withdraw-funds.ts +│ ├── 09-claim-expired-funds.ts ← TimeConstrained only +│ ├── 10-claim-non-goal-line-items.ts +│ ├── 11-pause-unpause-treasury.ts ← OPTIONAL +│ └── 12-cancel-treasury.ts ← OPTIONAL +│ +├── 04-event-monitoring/ ← Dashboards, analytics, real-time feeds +│ ├── README.md +│ ├── 01-historical-logs.ts +│ ├── 02-treasury-events.ts +│ ├── 03-realtime-watchers.ts +│ ├── 04-decode-raw-logs.ts +│ └── 05-metrics-aggregation.ts +│ +├── 05-error-handling/ ← Simulation, typed errors, safe transactions +│ ├── README.md +│ ├── 01-simulate-before-send.ts +│ ├── 02-prepare-transaction.ts +│ ├── 03-catch-typed-errors.ts +│ ├── 04-read-only-client.ts +│ ├── 05-safe-transaction-pattern.ts +│ └── 06-simulate-with-error-decode.ts +│ +└── 06-advanced-patterns/ ← Multicall, signers, item registry, registry keys + ├── README.md + ├── 01-multicall.ts + ├── 02-per-entity-signer.ts + ├── 03-per-call-signer.ts + ├── 04-item-registry.ts + ├── 05-registry-keys.ts + ├── 06-get-receipt.ts + ├── 07-browser-wallet.ts + └── 08-privy-wallet.ts +``` + +--- + +## Roles + +Four roles appear throughout these examples. Understanding who does what is key to following each scenario: + +| Role | Who they are | What they do | +| --- | --- | --- | +| **Protocol Admin** | The Oak Network team | Enlists new platforms, approves treasury implementations, governs global protocol parameters | +| **Platform Admin** | The operations team running a platform (e.g., an e-commerce site or crowdfunding portal) | Registers treasury models, configures fees and line items, confirms and cancels payments | +| **Campaign Creator** | A user who launches a campaign on a platform (e.g., an artist, a startup founder) | Creates campaigns, deploys treasuries, adds reward tiers, withdraws raised funds | +| **Backer / Buyer** | A user who supports a campaign or makes a purchase | Pledges for rewards, processes crypto payments, claims refunds if eligible | + +> **Platform Onboarding** is a coordinated process between the Protocol Admin and the Platform Admin. See [`00-platform-enlistment/`](./00-platform-enlistment/) for the complete walkthrough, or contact [support@oaknetwork.org](mailto:support@oaknetwork.org) to get started. + +--- + +## Quick Start + +Every example imports from `@oaknetwork/contracts-sdk`. The `_shared/setup.ts` file shows the common client setup pattern used across all examples. + +```typescript +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL, + privateKey: process.env.PRIVATE_KEY as `0x${string}`, +}); +``` + +Then pick a scenario folder and follow the steps in order. + +--- + +## Scenario Overview + +| # | Scenario | What you will learn | +| --- | --- | --- | +| 0 | [Platform Enlistment](./00-platform-enlistment/) | How a new platform joins Oak Protocol — enlistment, treasury registration, approval, plus optional configuration (line items, claim delay, data keys, adapter) | +| 1 | [All-or-Nothing Campaign](./01-campaign-all-or-nothing/) | Full crowdfunding lifecycle with a funding goal — success path and failure path | +| 2 | [Keep-What's-Raised Campaign](./02-campaign-keep-whats-raised/) | Flexible funding with mid-campaign withdrawals, tips, and configurable fees | +| 3 | [Payment Treasury](./03-campaign-payment-treasury/) | E-commerce payment flow with line items, confirmations, and refunds | +| 4 | [Event Monitoring](./04-event-monitoring/) | Building dashboards with historical logs, real-time watchers, and metrics | +| 5 | [Error Handling](./05-error-handling/) | Simulating transactions, catching typed errors, and safe send patterns | +| 6 | [Advanced Patterns](./06-advanced-patterns/) | Multicall batching, signer overrides, item registry, and protocol configuration | From b9912c3910bdfcee24aa469ec46b7532783d4261 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 15:51:09 +0600 Subject: [PATCH 22/86] feat: enhance treasury implementation examples and documentation - Add comments for additional treasury implementations in the TypeScript example, including PaymentTreasury and TimeConstrainedPaymentTreasury, clarifying their use cases and registration process. - Update README.md to reflect the new treasury implementations, detailing their functionalities and differences, ensuring clarity for developers on the SDK interface and registration requirements. --- .../03-register-treasury-implementations.ts | 8 ++++++++ .../src/examples/00-platform-enlistment/README.md | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts b/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts index 6b0c5329..2d3a4062 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts @@ -50,7 +50,15 @@ console.log("Awaiting Protocol Admin approval before it can be used."); // const keepWhatsRaisedImpl = process.env.KEEP_WHATS_RAISED_IMPL! as `0x${string}`; // await treasuryFactory.registerTreasuryImplementation(platformHash, 1n, keepWhatsRaisedImpl); // +// // PaymentTreasury — standard, no time restrictions: // const paymentTreasuryImpl = process.env.PAYMENT_TREASURY_IMPL! as `0x${string}`; // await treasuryFactory.registerTreasuryImplementation(platformHash, 2n, paymentTreasuryImpl); // +// // TimeConstrainedPaymentTreasury — enforces launch time + deadline on-chain: +// // Use this instead of PaymentTreasury for limited-time sales, flash deals, +// // or seasonal storefronts. The SDK interface is identical for both variants; +// // time constraints are enforced transparently by the contract. +// const timeConstrainedImpl = process.env.TIME_CONSTRAINED_PAYMENT_TREASURY_IMPL! as `0x${string}`; +// await treasuryFactory.registerTreasuryImplementation(platformHash, 3n, timeConstrainedImpl); +// // Each slot requires a separate Protocol Admin approval. diff --git a/packages/contracts/src/examples/00-platform-enlistment/README.md b/packages/contracts/src/examples/00-platform-enlistment/README.md index 8b3e4b21..4d8e5bf8 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/README.md +++ b/packages/contracts/src/examples/00-platform-enlistment/README.md @@ -38,7 +38,10 @@ Each platform maintains its own mapping of implementation ID to treasury contrac | --- | --- | --- | | `0n` | AllOrNothing | Crowdfunding — backers get a full refund if the goal is not met | | `1n` | KeepWhatsRaised | Crowdfunding — the creator keeps whatever is raised, even if the goal is not met | -| `2n` | PaymentTreasury | E-commerce — structured payments with line items, confirmations, and refunds | +| `2n` | PaymentTreasury | E-commerce — structured payments with no time restrictions | +| `3n` | TimeConstrainedPaymentTreasury | E-commerce — same as PaymentTreasury but enforces launch time and deadline on-chain (flash deals, seasonal storefronts) | + +> **PaymentTreasury vs. TimeConstrainedPaymentTreasury:** Both share the same SDK interface — `oak.paymentTreasury(address)`. The only difference is at registration time: you register a different implementation contract address. The time constraints are enforced transparently by the smart contract. See the [Payment Treasury README](../03-campaign-payment-treasury/README.md) for details. ## Optional Configuration (Step 6) From 7363d30580b5a677c87a0d6c2c7b4f6fb8c99b5b Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 16:13:27 +0600 Subject: [PATCH 23/86] fix: update treasury address reference in deployment examples - Correct the property name from 'treasury' to 'treasuryAddress' in the treasury deployment logs for both all-or-nothing and keep-whats-raised examples, ensuring consistency and accuracy in the code. --- .../examples/01-campaign-all-or-nothing/04-deploy-treasury.ts | 2 +- .../02-campaign-keep-whats-raised/02-deploy-treasury.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts index 3067202c..af53486a 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts @@ -40,5 +40,5 @@ const logs = await treasuryFactory.events.getTreasuryDeployedLogs({ fromBlock: BigInt(deployReceipt.blockNumber), }); -const treasuryAddress = logs[0]?.args?.treasury; +const treasuryAddress = logs[0]?.args?.treasuryAddress; console.log("All-or-Nothing treasury deployed at:", treasuryAddress); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts index 91fd1237..6d5f0a83 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts @@ -38,5 +38,5 @@ const deployLogs = await treasuryFactory.events.getTreasuryDeployedLogs({ fromBlock: BigInt(deployReceipt.blockNumber), }); -const treasuryAddress = deployLogs[0]?.args?.treasury; +const treasuryAddress = deployLogs[0]?.args?.treasuryAddress; console.log("KWR Treasury at:", treasuryAddress); From 6316ad37697669c7f4717fa78fb499cd28cc5291 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 16:14:12 +0600 Subject: [PATCH 24/86] fix: improve error handling for wallet connection in read-only client example - Update error message check to use startsWith for better matching of "No signer configured" error, providing clearer guidance for users to connect their wallet before performing actions. --- .../src/examples/05-error-handling/04-read-only-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts b/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts index a23049ae..ba4bf2f1 100644 --- a/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts +++ b/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts @@ -28,7 +28,7 @@ console.log("Deadline:", new Date(Number(deadline) * 1000).toISOString()); try { await campaign.updateGoalAmount(2_000_000_000n); } catch (error) { - if ((error as Error).message === "No signer configured.") { + if ((error as Error).message.startsWith("No signer configured")) { console.error("Connect your wallet to perform this action."); } } From 813bf115c3bce61d7c7da78d28b84fc6b9a77160 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 16:15:42 +0600 Subject: [PATCH 25/86] fix: ensure transaction safety for fee disbursement and withdrawal in treasury example - Update the treasury example to wait for transaction mining before allowing withdrawals, preventing potential reverts if fees have not been disbursed yet. This enhances the reliability of the contract interactions. --- .../06-advanced-patterns/03-per-call-signer.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts b/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts index d6b912ce..02115bd5 100644 --- a/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts +++ b/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts @@ -29,8 +29,11 @@ const creatorSigner = createWallet( const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; const treasury = oak.allOrNothingTreasury(treasuryAddress); -// Admin disburses fees -await treasury.disburseFees({ signer: adminSigner }); +// Admin disburses fees — wait for mining before withdrawing, +// because withdraw reverts if fees have not been disbursed yet +const feeTxHash = await treasury.disburseFees({ signer: adminSigner }); +await oak.waitForReceipt(feeTxHash); -// Creator withdraws funds -await treasury.withdraw({ signer: creatorSigner }); +// Creator withdraws funds (safe now — fees are confirmed on-chain) +const withdrawTxHash = await treasury.withdraw({ signer: creatorSigner }); +await oak.waitForReceipt(withdrawTxHash); From 4806726e4aad7d398d0c0baddbc5cdcb01de4e1e Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 16:31:43 +0600 Subject: [PATCH 26/86] fix: enhance transaction handling and clarity in treasury examples --- .../03-register-treasury-implementations.ts | 1 + .../04-approve-implementations.ts | 1 + .../00-platform-enlistment/05-verify-setup.ts | 4 ++-- .../06-optional-configuration.ts | 4 +++- .../02-lookup-campaign.ts | 3 +-- .../09a-success-withdraw.ts | 4 ++-- .../09b-failure-refund.ts | 2 +- .../10-pause-unpause-treasury.ts | 2 +- .../05-backer-pledge.ts | 13 +++++++++++-- .../06a-partial-withdrawal.ts | 3 +++ .../06b-final-withdrawal.ts | 2 +- .../11-claim-refund.ts | 2 +- .../12-update-campaign.ts | 4 ++-- .../13-pause-unpause-treasury.ts | 2 +- .../01-setup-treasury.ts | 3 +-- .../02-create-payment.ts | 4 ++-- .../03-process-crypto-payment.ts | 2 +- .../08-withdraw-funds.ts | 8 ++++---- .../04-event-monitoring/03-realtime-watchers.ts | 8 ++++++-- .../04-event-monitoring/04-decode-raw-logs.ts | 12 +++++++----- .../05-error-handling/03-catch-typed-errors.ts | 17 +++++++---------- .../05-error-handling/04-read-only-client.ts | 2 +- .../05-safe-transaction-pattern.ts | 2 -- .../02-per-entity-signer.ts | 4 ---- .../06-advanced-patterns/04-item-registry.ts | 3 ++- 25 files changed, 62 insertions(+), 50 deletions(-) diff --git a/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts b/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts index 2d3a4062..604354df 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts @@ -39,6 +39,7 @@ const txHash = await treasuryFactory.registerTreasuryImplementation( 0n, // slot 0 allOrNothingImpl, ); +await oak.waitForReceipt(txHash); console.log("AllOrNothing registered at slot 0:", txHash); console.log("Awaiting Protocol Admin approval before it can be used."); diff --git a/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts b/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts index a1dab162..630649ac 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts @@ -27,6 +27,7 @@ const platformHash = keccak256(toHex("NOVAPAY")); // Approve the AllOrNothing implementation at slot 0 const txHash = await treasuryFactory.approveTreasuryImplementation(platformHash, 0n); +await oak.waitForReceipt(txHash); console.log("AllOrNothing approved (slot 0):", txHash); console.log("NovaPay can now deploy AllOrNothing treasuries."); diff --git a/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts index 9625246f..0e2d9cb1 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts @@ -52,13 +52,13 @@ console.log("Approval events:", approvalLogs.length); // 3. Confirm enlistment event was emitted const enlistmentLogs = await globalParams.events.getPlatformEnlistedLogs(); const novaPayLog = enlistmentLogs.find( - (log) => log.args?.platformHash === platformHash, + (log) => log.args?.platformBytes === platformHash, ); if (novaPayLog) { console.log("\n=== Enlistment Confirmed ==="); console.log("Event:", novaPayLog.eventName); - console.log("Platform hash:", novaPayLog.args?.platformHash); + console.log("Platform hash:", novaPayLog.args?.platformBytes); console.log("NovaPay is fully onboarded and ready to launch campaigns."); } else { console.error("Enlistment event not found — check the transaction."); diff --git a/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts index 6519e901..9f95e857 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts @@ -73,6 +73,7 @@ async function setupLineItemTypes(): Promise { true, // canRefund (must be true when countsTowardGoal is true) false, // instantTransfer (must be false when countsTowardGoal is true) ); + await oak.waitForReceipt(tx1); console.log("Line item type 'product' set:", tx1); // "shipping" line item type @@ -86,6 +87,7 @@ async function setupLineItemTypes(): Promise { false, // canRefund true, // instantTransfer ); + await oak.waitForReceipt(tx2); console.log("Line item type 'shipping' set:", tx2); // Verify @@ -275,8 +277,8 @@ async function protocolAdminExamples(): Promise { // and the protocol resolves it to a list of accepted token addresses. const usdCurrency = toHex("USD", { size: 32 }); - const cusdToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; + // const cusdToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; // const addTokenTx = await globalParams.addTokenToCurrency(usdCurrency, cusdToken); // await oak.waitForReceipt(addTokenTx); // console.log("cUSD added as accepted token for USD"); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts index c1d5ac27..c4a024e3 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts @@ -1,5 +1,5 @@ /** - * Step 2: Look Up the Campaign Address (Creator) + * Step 2: Look Up the Campaign Address (Anyone) * * After creating the campaign in Step 1, Maya needs to find the address * of the deployed CampaignInfo contract. She uses the same identifier hash @@ -15,7 +15,6 @@ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwo const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, rpcUrl: process.env.RPC_URL!, - privateKey: process.env.MAYA_PRIVATE_KEY! as `0x${string}`, }); const factory = oak.campaignInfoFactory( diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts index 221eac88..a78691f5 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09a-success-withdraw.ts @@ -29,6 +29,6 @@ const withdrawTxHash = await treasury.withdraw(); const withdrawReceipt = await oak.waitForReceipt(withdrawTxHash); console.log(`Funds withdrawn at block ${withdrawReceipt.blockNumber}`); -// Verify the treasury is empty +// Verify withdrawal is complete const remaining = await treasury.getRaisedAmount(); -console.log("Remaining in treasury:", remaining); // 0n +console.log("Raised amount (accounting total):", remaining); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts index b599d0d3..a38e58a5 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts @@ -32,7 +32,7 @@ const alexOak = createOakContractsClient({ const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; const treasury = alexOak.allOrNothingTreasury(treasuryAddress); -const tokenId = 0n; // Alex's pledge NFT token ID (received when pledging) +const tokenId = BigInt(process.env.ALEX_PLEDGE_TOKEN_ID ?? "0"); // NFT token ID from the pledge receipt event const refundTxHash = await treasury.claimRefund(tokenId); const refundReceipt = await alexOak.waitForReceipt(refundTxHash); console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts index aca8ce43..92c78976 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/10-pause-unpause-treasury.ts @@ -40,4 +40,4 @@ console.log("Treasury paused:", await treasury.paused()); // true const unpauseReason = keccak256(toHex("investigation-cleared")); const unpauseTxHash = await treasury.unpauseTreasury(unpauseReason); await platformOak.waitForReceipt(unpauseTxHash); -console.log("Treasury paused:", await treasury.paused()); // false +console.log("Treasury resumed, paused:", await treasury.paused()); // false diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts index e1b4d7f5..65ec79ac 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts @@ -48,17 +48,26 @@ await oak.waitForReceipt(pledgeTxHash); console.log("Pledged for Early Bird reward"); // --- Pledge without a reward — pure support --- +// Uses a separate client for the supporter's wallet so that +// msg.sender matches the backer address for token transfers. + +const supporterOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.SUPPORTER_PRIVATE_KEY! as `0x${string}`, +}); +const supporterTreasury = supporterOak.keepWhatsRaisedTreasury(treasuryAddress); const supportPledgeId = keccak256(toHex("pledge-002")); const supporterAddress = process.env.SUPPORTER_ADDRESS! as `0x${string}`; -const noRewardTxHash = await treasury.pledgeWithoutAReward( +const noRewardTxHash = await supporterTreasury.pledgeWithoutAReward( supportPledgeId, supporterAddress, pledgeToken, 50_000_000n, // $50 pledge amount 0n, // no tip ); -await oak.waitForReceipt(noRewardTxHash); +await supporterOak.waitForReceipt(noRewardTxHash); console.log("Pledged without reward"); // --- Set fee and pledge in one call (Platform Admin only) --- diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts index a3d5c856..a7a65e8c 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts @@ -42,6 +42,9 @@ const approvalStatus = await platformTreasury.getWithdrawalApprovalStatus(); console.log("Withdrawal approved:", approvalStatus); // true // --- Step B: Creator withdraws after the delay period --- +// The on-chain withdrawal delay (set during treasury configuration) +// must have elapsed since approval before this call can succeed. +// If the delay has not passed, the transaction will revert. const creatorOak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts index f92ddf54..88f05edf 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts @@ -17,7 +17,7 @@ * that window, `withdraw` is no longer available (use `claimFund` * instead once the withdrawal delay has fully elapsed) * - * Call `disburseFees()` (Step 7) before this step so that protocol + * Call `disburseFees()` (Step 8) before this step so that protocol * and platform fees have already been transferred out. */ diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts index cb3b1dd6..f2d4733a 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts @@ -28,7 +28,7 @@ const backerOak = createOakContractsClient({ const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; const treasury = backerOak.keepWhatsRaisedTreasury(treasuryAddress); -const tokenId = 0n; // backer's pledge NFT token ID +const tokenId = BigInt(process.env.BACKER_PLEDGE_TOKEN_ID ?? "0"); // NFT token ID from the pledge receipt event const refundTxHash = await treasury.claimRefund(tokenId); const refundReceipt = await backerOak.waitForReceipt(refundTxHash); console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts index b7df2a78..ce61ef1f 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/12-update-campaign.ts @@ -29,9 +29,9 @@ const oak = createOakContractsClient({ const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); -// Extend the deadline by 30 more days +// Extend the deadline to 90 days from now const now = getCurrentTimestamp(); -const newDeadline = addDays(now, 90); // extend to 90 days total +const newDeadline = addDays(now, 90); const deadlineTxHash = await treasury.updateDeadline(newDeadline); await oak.waitForReceipt(deadlineTxHash); console.log("Deadline extended to 90 days"); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts index eeaf15f7..cd96845a 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/13-pause-unpause-treasury.ts @@ -31,4 +31,4 @@ console.log("Treasury paused:", await treasury.paused()); // true const unpauseReason = keccak256(toHex("review-cleared")); const unpauseTxHash = await treasury.unpauseTreasury(unpauseReason); await platformOak.waitForReceipt(unpauseTxHash); -console.log("Treasury paused:", await treasury.paused()); // false +console.log("Treasury resumed, paused:", await treasury.paused()); // false diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts index e8bebb4a..cd006b13 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts @@ -1,5 +1,5 @@ /** - * Step 1: Connect to the Payment Treasury (Platform Admin) + * Step 1: Connect to the Payment Treasury (Anyone) * * CeloMarket's backend connects to its deployed PaymentTreasury contract * and reads back the platform configuration — the platform hash it belongs @@ -12,7 +12,6 @@ import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, rpcUrl: process.env.RPC_URL!, - privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, }); const paymentTreasury = oak.paymentTreasury( diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts index b1a9562e..f1f1d572 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts @@ -40,7 +40,7 @@ const expiration = BigInt(Math.floor(Date.now() / 1000)) + 86400n; // 24 hours const lineItems: LineItem[] = [ { - typeId: keccak256(toHex("pledge")), // product price + typeId: keccak256(toHex("product")), // product price amount: 120_000_000n, // $120 }, { @@ -85,7 +85,7 @@ console.log("Payment created for order #12345"); // [paymentToken, paymentToken], // [totalAmount, 85_000_000n], // [expiration, expiration], -// [lineItems, [{ typeId: keccak256(toHex("pledge")), amount: 85_000_000n }]], +// [lineItems, [{ typeId: keccak256(toHex("product")), amount: 85_000_000n }]], // [externalFees, []], // ); // await oak.waitForReceipt(batchTxHash); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts index a5c3195d..e204e102 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts @@ -33,7 +33,7 @@ const paymentToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; const totalAmount = 135_000_000n; const lineItems: LineItem[] = [ - { typeId: keccak256(toHex("pledge")), amount: 120_000_000n }, + { typeId: keccak256(toHex("product")), amount: 120_000_000n }, { typeId: keccak256(toHex("shipping")), amount: 15_000_000n }, ]; diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts index 32fecdca..a7c15066 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts @@ -2,12 +2,12 @@ * Step 8: Withdraw Confirmed Funds (Platform Admin or Creator) * * After fees have been disbursed (Step 7), the platform admin or the - * campaign owner withdraws all available confirmed funds from the + * campaign owner withdraws all remaining confirmed funds from the * treasury. The funds are transferred to the campaign owner's wallet. * - * `withdraw()` takes no parameters — it calculates protocol and - * platform fees on the available balance, deducts them, and transfers - * the remainder to the campaign owner. + * `withdraw()` takes no parameters — it transfers the entire remaining + * balance to the campaign owner. Fees must already have been disbursed + * via `disburseFees()` before calling this method. * * After withdrawal, the treasury's available balance drops to zero * (until new payments are confirmed). diff --git a/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts b/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts index b7ee1c1f..5d2cb374 100644 --- a/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts +++ b/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts @@ -48,10 +48,14 @@ const unwatchPlatforms = gp.events.watchPlatformEnlisted((logs) => { } }); -// Clean up when the dashboard unmounts -function cleanup() { +// Clean up when the dashboard unmounts — call this on page +// navigation or component teardown to stop WebSocket subscriptions +export function cleanup() { unwatchCampaigns(); unwatchPledges(); unwatchPlatforms(); console.log("All watchers stopped"); } + +// To stop watchers after a timeout (for testing): +// setTimeout(() => cleanup(), 60_000); diff --git a/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts b/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts index cd00b8ce..94b86304 100644 --- a/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts +++ b/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts @@ -26,13 +26,15 @@ const someTxHash = "0x..." as `0x${string}`; const receipt = await oak.waitForReceipt(someTxHash); for (const log of receipt.logs) { - const decoded = factory.events.decodeLog({ - topics: log.topics, - data: log.data, - }); + try { + const decoded = factory.events.decodeLog({ + topics: log.topics, + data: log.data, + }); - if (decoded) { console.log(`Event: ${decoded.eventName}`); console.log(`Args:`, decoded.args); + } catch { + // Log belongs to a different contract — skip silently } } diff --git a/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts index 7d8434cb..cca1a9f5 100644 --- a/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts +++ b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts @@ -38,13 +38,11 @@ try { if (error instanceof CampaignInfoUnauthorizedError) { console.error("You are not the campaign owner."); console.error("Hint:", error.recoveryHint); - // return; - } + } else { + // Pattern 2: Parse unknown revert data + const revertData = getRevertData(error); + const parsed = revertData ? parseContractError(revertData) : null; - // Pattern 2: Parse unknown revert data - const revertData = getRevertData(error); - if (revertData) { - const parsed = parseContractError(revertData); if (parsed) { console.error(`Contract error: ${parsed.name}`); console.error("Arguments:", parsed.args); @@ -53,10 +51,9 @@ try { if (hint) { console.error("Recovery hint:", hint); } - // return; + } else { + // Unknown error — only reached when the error is not a typed contract revert + console.error("Unknown error:", (error as Error).message); } } - - // Unknown error - console.error("Unknown error:", (error as Error).message); } diff --git a/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts b/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts index ba4bf2f1..aeaff2db 100644 --- a/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts +++ b/packages/contracts/src/examples/05-error-handling/04-read-only-client.ts @@ -6,7 +6,7 @@ * an RPC call. Build your UI to handle this gracefully. */ -import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; const readOnlyOak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, diff --git a/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts b/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts index 6cbf5614..098247fb 100644 --- a/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts +++ b/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts @@ -8,8 +8,6 @@ import { createOakContractsClient, - keccak256, - toHex, CHAIN_IDS, parseContractError, getRevertData, diff --git a/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts b/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts index 59fb4399..14979e4e 100644 --- a/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts +++ b/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts @@ -26,10 +26,6 @@ const userSigner = createWallet(userPrivateKey, process.env.RPC_URL!, oak.config const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; const treasury = oak.allOrNothingTreasury(treasuryAddress, { signer: userSigner }); -// All writes automatically use userSigner -const backerAddr = process.env.USER_ADDRESS! as `0x${string}`; -const pledgeToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; - // Reads — no signer needed const raised = await treasury.getRaisedAmount(); console.log("Raised:", raised); diff --git a/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts b/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts index 3b8b217e..2fff7017 100644 --- a/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts +++ b/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts @@ -36,7 +36,8 @@ const txHash = await itemRegistry.addItem(vaseItemId, vaseItem); await oak.waitForReceipt(txHash); console.log("Vase registered in the item registry"); -// Read back the item +// Read back the item — the owner address must match the account that +// signed the addItem call (i.e., the address derived from PRIVATE_KEY) const storedItem = await itemRegistry.getItem( process.env.CREATOR_ADDRESS! as `0x${string}`, vaseItemId, From 4bbb0f5ccb154f2bb3a8708d9b940ce3f63cb767 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 16:33:05 +0600 Subject: [PATCH 27/86] fix: update example files for clarity and organization - Comment out the platformHash definition in the optional configuration example to clarify its usage in subsequent examples. - Remove unused import of keccak256 in the error handling example to streamline the code and improve readability. --- .../00-platform-enlistment/06-optional-configuration.ts | 4 +++- .../src/examples/05-error-handling/03-catch-typed-errors.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts index 9f95e857..483d985f 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts @@ -268,7 +268,9 @@ async function protocolAdminExamples(): Promise { }); const globalParams = oak.globalParams(process.env.GLOBAL_PARAMS_ADDRESS! as `0x${string}`); - const platformHash = keccak256(toHex("NOVAPAY")); + + // platformHash is used in the commented examples below (delist, update admin, etc.) + // const platformHash = keccak256(toHex("NOVAPAY")); // --- Currency management --- // diff --git a/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts index cca1a9f5..72537ebd 100644 --- a/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts +++ b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts @@ -11,7 +11,6 @@ import { createOakContractsClient, - keccak256, toHex, CHAIN_IDS, parseContractError, From 05c14cfcc6ca9b1cffb5d9fe306255fd8a9970c7 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 19:05:44 +0600 Subject: [PATCH 28/86] fix: improve treasury address retrieval in deployment examples - Update the treasury deployment examples to decode the TreasuryDeployed event directly from the transaction receipt, ensuring accurate retrieval of the treasury address and avoiding ambiguity with multiple deploys in the same block. --- .../04-deploy-treasury.ts | 25 +++++++++++++++---- .../02-deploy-treasury.ts | 24 +++++++++++++++--- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts index af53486a..8ac45d18 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts @@ -35,10 +35,25 @@ const deployTxHash = await treasuryFactory.deploy( const deployReceipt = await oak.waitForReceipt(deployTxHash); console.log(`Treasury deployed at block ${deployReceipt.blockNumber}`); -// Get the deployed treasury address from the event -const logs = await treasuryFactory.events.getTreasuryDeployedLogs({ - fromBlock: BigInt(deployReceipt.blockNumber), -}); +// Decode the TreasuryDeployed event directly from the receipt. +// Using receipt.logs guarantees we only see events from our transaction, +// avoiding ambiguity when multiple deploys land in the same block. +let treasuryAddress: `0x${string}` | undefined; + +for (const log of deployReceipt.logs) { + try { + const decoded = treasuryFactory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + + if (decoded.eventName === "TreasuryFactoryTreasuryDeployed") { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} -const treasuryAddress = logs[0]?.args?.treasuryAddress; console.log("All-or-Nothing treasury deployed at:", treasuryAddress); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts index 6d5f0a83..d21052c6 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts @@ -34,9 +34,25 @@ const deployTxHash = await treasuryFactory.deploy( const deployReceipt = await oak.waitForReceipt(deployTxHash); -const deployLogs = await treasuryFactory.events.getTreasuryDeployedLogs({ - fromBlock: BigInt(deployReceipt.blockNumber), -}); +// Decode the TreasuryDeployed event directly from the receipt. +// Using receipt.logs guarantees we only see events from our transaction, +// avoiding ambiguity when multiple deploys land in the same block. +let treasuryAddress: `0x${string}` | undefined; + +for (const log of deployReceipt.logs) { + try { + const decoded = treasuryFactory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + + if (decoded.eventName === "TreasuryFactoryTreasuryDeployed") { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} -const treasuryAddress = deployLogs[0]?.args?.treasuryAddress; console.log("KWR Treasury at:", treasuryAddress); From 52f1f2067be3c52ee889939fa5e1ee2929128428 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Thu, 9 Apr 2026 19:07:59 +0600 Subject: [PATCH 29/86] refactor: update refund handling for NFT owners in payment treasury example - Change the refund process to allow NFT owners to claim refunds directly by burning their NFT, enhancing user experience and clarity in the refund flow. - Update comments and variable names for better understanding of the refund mechanism, distinguishing between crypto and off-chain payment scenarios. --- .../06-handle-refunds.ts | 63 ++++++++++--------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts index e0d367dd..cf7954a9 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts @@ -30,57 +30,60 @@ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; // ============================================================ -// A. Off-chain payment refund (Platform Admin) +// A. Crypto payment refund (NFT Owner / Buyer) // ============================================================ // -// For payments created via `createPayment` — no NFT was minted. -// The platform admin cancels and directs the refund. +// Steps 2–3 processed order-12345 as a crypto payment, which minted +// an NFT to Sam. To claim a refund, Sam (the NFT owner) calls +// `claimRefundSelf`. The contract verifies NFT ownership, burns the +// NFT, and sends the refundable amount back to Sam's wallet. -const oak = createOakContractsClient({ +const samOak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, rpcUrl: process.env.RPC_URL!, - privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, + privateKey: process.env.SAM_PRIVATE_KEY! as `0x${string}`, }); -const paymentTreasury = oak.paymentTreasury( +const samTreasury = samOak.paymentTreasury( process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, ); const paymentId = keccak256(toHex("order-12345")); -// Step 1: Cancel the payment -const cancelTxHash = await paymentTreasury.cancelPayment(paymentId); -await oak.waitForReceipt(cancelTxHash); -console.log("Payment cancelled"); - -// Step 2: Direct the refund to the buyer's address (platform admin only) -const refundTxHash = await paymentTreasury.claimRefund( - paymentId, - process.env.SAM_ADDRESS! as `0x${string}`, -); -await oak.waitForReceipt(refundTxHash); -console.log("Refund sent to Sam's address"); +const selfRefundTxHash = await samTreasury.claimRefundSelf(paymentId); +await samOak.waitForReceipt(selfRefundTxHash); +console.log("NFT burned + refund claimed by Sam"); // ============================================================ -// B. On-chain crypto payment refund (NFT Owner) +// B. Off-chain payment refund (Platform Admin) — Alternative // ============================================================ // -// For payments made via `processCryptoPayment` — an NFT was minted -// to the buyer. The buyer (current NFT owner) claims the refund -// themselves. The contract burns the NFT and sends tokens to the -// NFT owner. +// For payments created via `createPayment` only (no NFT minted), +// the platform admin cancels the payment and directs the refund +// to a specific address. This path does NOT apply to crypto +// payments — use `claimRefundSelf` above instead. -// const samOak = createOakContractsClient({ +// const oak = createOakContractsClient({ // chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, // rpcUrl: process.env.RPC_URL!, -// privateKey: process.env.SAM_PRIVATE_KEY! as `0x${string}`, +// privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, // }); // -// const samTreasury = samOak.paymentTreasury( +// const paymentTreasury = oak.paymentTreasury( // process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, // ); // -// const cryptoPaymentId = keccak256(toHex("crypto-order-67890")); -// const selfRefundTxHash = await samTreasury.claimRefundSelf(cryptoPaymentId); -// await samOak.waitForReceipt(selfRefundTxHash); -// console.log("NFT burned + refund claimed by Sam"); +// const offchainPaymentId = keccak256(toHex("offchain-order-67890")); +// +// // Step 1: Cancel the payment +// const cancelTxHash = await paymentTreasury.cancelPayment(offchainPaymentId); +// await oak.waitForReceipt(cancelTxHash); +// console.log("Payment cancelled"); +// +// // Step 2: Direct the refund to the buyer's address +// const refundTxHash = await paymentTreasury.claimRefund( +// offchainPaymentId, +// process.env.SAM_ADDRESS! as `0x${string}`, +// ); +// await oak.waitForReceipt(refundTxHash); +// console.log("Refund sent to Sam's address"); From a8ab3ab823493f09ee76f0bc621705b10ec80228 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 12:12:32 +0600 Subject: [PATCH 30/86] feat: add type-safe event name constants for contracts - Introduce a new events.ts file containing type-safe constants for event names emitted by various contracts, including GlobalParams, CampaignInfoFactory, CampaignInfo, TreasuryFactory, AllOrNothing, KeepWhatsRaised, PaymentTreasury, and ItemRegistry. - Update index.ts to re-export these event constants for improved accessibility. --- packages/contracts/src/constants/events.ts | 118 +++++++++++++++++++++ packages/contracts/src/constants/index.ts | 12 ++- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/constants/events.ts diff --git a/packages/contracts/src/constants/events.ts b/packages/contracts/src/constants/events.ts new file mode 100644 index 00000000..86455af3 --- /dev/null +++ b/packages/contracts/src/constants/events.ts @@ -0,0 +1,118 @@ +/** + * Type-safe event name constants for every contract in the SDK. + * + * Use these instead of string literals when filtering or comparing + * decoded event names from transaction receipts: + * + * @example + * ```typescript + * import { GLOBAL_PARAMS_EVENTS } from "@oaknetwork/contracts-sdk"; + * + * if (decoded.eventName === GLOBAL_PARAMS_EVENTS.PlatformEnlisted) { ... } + * ``` + */ + +/** Event names emitted by the GlobalParams contract. */ +export const GLOBAL_PARAMS_EVENTS = { + DataAddedToRegistry: "DataAddedToRegistry", + OwnershipTransferred: "OwnershipTransferred", + Paused: "Paused", + PlatformAdminAddressUpdated: "PlatformAdminAddressUpdated", + PlatformAdapterSet: "PlatformAdapterSet", + PlatformClaimDelayUpdated: "PlatformClaimDelayUpdated", + PlatformDataAdded: "PlatformDataAdded", + PlatformDataRemoved: "PlatformDataRemoved", + PlatformDelisted: "PlatformDelisted", + PlatformEnlisted: "PlatformEnlisted", + PlatformLineItemTypeRemoved: "PlatformLineItemTypeRemoved", + PlatformLineItemTypeSet: "PlatformLineItemTypeSet", + ProtocolAdminAddressUpdated: "ProtocolAdminAddressUpdated", + ProtocolFeePercentUpdated: "ProtocolFeePercentUpdated", + TokenAddedToCurrency: "TokenAddedToCurrency", + TokenRemovedFromCurrency: "TokenRemovedFromCurrency", + Unpaused: "Unpaused", +} as const; + +/** Event names emitted by the CampaignInfoFactory contract. */ +export const CAMPAIGN_INFO_FACTORY_EVENTS = { + CampaignCreated: "CampaignInfoFactoryCampaignCreated", + CampaignInitialized: "CampaignInfoFactoryCampaignInitialized", + OwnershipTransferred: "OwnershipTransferred", +} as const; + +/** Event names emitted by the CampaignInfo contract. */ +export const CAMPAIGN_INFO_EVENTS = { + DeadlineUpdated: "CampaignInfoDeadlineUpdated", + GoalAmountUpdated: "CampaignInfoGoalAmountUpdated", + LaunchTimeUpdated: "CampaignInfoLaunchTimeUpdated", + PlatformInfoUpdated: "CampaignInfoPlatformInfoUpdated", + SelectedPlatformUpdated: "CampaignInfoSelectedPlatformUpdated", + OwnershipTransferred: "OwnershipTransferred", + Paused: "Paused", + Unpaused: "Unpaused", +} as const; + +/** Event names emitted by the TreasuryFactory contract. */ +export const TREASURY_FACTORY_EVENTS = { + TreasuryDeployed: "TreasuryFactoryTreasuryDeployed", + ImplementationRegistered: "TreasuryImplementationRegistered", + ImplementationRemoved: "TreasuryImplementationRemoved", + ImplementationApproval: "TreasuryImplementationApproval", +} as const; + +/** Event names emitted by the AllOrNothing treasury contract. */ +export const ALL_OR_NOTHING_EVENTS = { + Approval: "Approval", + ApprovalForAll: "ApprovalForAll", + FeesDisbursed: "FeesDisbursed", + Paused: "Paused", + Receipt: "Receipt", + RefundClaimed: "RefundClaimed", + RewardsAdded: "RewardsAdded", + RewardRemoved: "RewardRemoved", + SuccessConditionNotFulfilled: "SuccessConditionNotFulfilled", + Transfer: "Transfer", + Unpaused: "Unpaused", + WithdrawalSuccessful: "WithdrawalSuccessful", +} as const; + +/** Event names emitted by the KeepWhatsRaised treasury contract. */ +export const KEEP_WHATS_RAISED_EVENTS = { + Approval: "Approval", + ApprovalForAll: "ApprovalForAll", + DeadlineUpdated: "KeepWhatsRaisedDeadlineUpdated", + FeesDisbursed: "FeesDisbursed", + FundClaimed: "FundClaimed", + GoalAmountUpdated: "KeepWhatsRaisedGoalAmountUpdated", + Paused: "Paused", + PaymentGatewayFeeSet: "KeepWhatsRaisedPaymentGatewayFeeSet", + Receipt: "Receipt", + RefundClaimed: "RefundClaimed", + RewardsAdded: "RewardsAdded", + RewardRemoved: "RewardRemoved", + TipClaimed: "TipClaimed", + Transfer: "Transfer", + TreasuryConfigured: "TreasuryConfigured", + Unpaused: "Unpaused", + WithdrawalApproved: "WithdrawalApproved", + WithdrawalWithFeeSuccessful: "WithdrawalWithFeeSuccessful", +} as const; + +/** Event names emitted by the PaymentTreasury contract. */ +export const PAYMENT_TREASURY_EVENTS = { + ExpiredFundsClaimed: "ExpiredFundsClaimed", + FeesDisbursed: "FeesDisbursed", + NonGoalLineItemsClaimed: "NonGoalLineItemsClaimed", + PaymentBatchConfirmed: "PaymentBatchConfirmed", + PaymentBatchCreated: "PaymentBatchCreated", + PaymentCancelled: "PaymentCancelled", + PaymentConfirmed: "PaymentConfirmed", + PaymentCreated: "PaymentCreated", + RefundClaimed: "RefundClaimed", + WithdrawalWithFeeSuccessful: "WithdrawalWithFeeSuccessful", +} as const; + +/** Event names emitted by the ItemRegistry contract. */ +export const ITEM_REGISTRY_EVENTS = { + ItemAdded: "ItemAdded", +} as const; diff --git a/packages/contracts/src/constants/index.ts b/packages/contracts/src/constants/index.ts index 868b4e91..d7086f3b 100644 --- a/packages/contracts/src/constants/index.ts +++ b/packages/contracts/src/constants/index.ts @@ -1,5 +1,15 @@ -/** Re-exports chain IDs, fee constants, encoding sentinels, and registry helpers. */ +/** Re-exports chain IDs, fee constants, encoding sentinels, registry helpers, and event names. */ export { CHAIN_IDS, type ChainId } from "./chains"; export { BPS_DENOMINATOR } from "./fees"; export { BYTES32_ZERO } from "./encoding"; export { DATA_REGISTRY_KEYS, scopedToPlatform, type DataRegistryKeyName } from "./registry"; +export { + GLOBAL_PARAMS_EVENTS, + CAMPAIGN_INFO_FACTORY_EVENTS, + CAMPAIGN_INFO_EVENTS, + TREASURY_FACTORY_EVENTS, + ALL_OR_NOTHING_EVENTS, + KEEP_WHATS_RAISED_EVENTS, + PAYMENT_TREASURY_EVENTS, + ITEM_REGISTRY_EVENTS, +} from "./events"; From 8f8c065ca05ff2dc3c361a7983529a11914492b3 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 12:12:57 +0600 Subject: [PATCH 31/86] feat: add error name constants for various contracts - Introduce new error name constants for GlobalParams, CampaignInfoFactory, CampaignInfo, AllOrNothing, KeepWhatsRaised, ItemRegistry, PaymentTreasury, and TreasuryFactory in index.ts. - Update exports to include these constants for improved error handling and clarity in contract interactions. --- packages/contracts/src/errors/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/errors/index.ts b/packages/contracts/src/errors/index.ts index a9cdb7bc..0a27c662 100644 --- a/packages/contracts/src/errors/index.ts +++ b/packages/contracts/src/errors/index.ts @@ -7,6 +7,7 @@ export { } from "./parse-contract-error"; export { + GlobalParamsErrorNames, GlobalParamsCurrencyHasNoTokensError, GlobalParamsCurrencyTokenLengthMismatchError, GlobalParamsInvalidInputError, @@ -24,6 +25,7 @@ export { export type { GlobalParamsError } from "./contracts/global-params"; export { + CampaignInfoFactoryErrorNames, CampaignInfoFactoryCampaignInitializationFailedError, CampaignInfoFactoryCampaignWithSameIdentifierExistsError, CampaignInfoFactoryInvalidInputError, @@ -33,6 +35,7 @@ export { export type { CampaignInfoFactoryError } from "./contracts/campaign-info-factory"; export { + CampaignInfoErrorNames, CampaignInfoInvalidInputError, CampaignInfoInvalidPlatformUpdateError, CampaignInfoIsLockedError, @@ -43,6 +46,7 @@ export { export type { CampaignInfoError } from "./contracts/campaign-info"; export { + AllOrNothingErrorNames, AllOrNothingFeeAlreadyDisbursedError, AllOrNothingFeeNotDisbursedError, AllOrNothingInvalidInputError, @@ -57,6 +61,7 @@ export { export type { AllOrNothingError } from "./contracts/all-or-nothing"; export { + KeepWhatsRaisedErrorNames, KeepWhatsRaisedAlreadyClaimedError, KeepWhatsRaisedAlreadyEnabledError, KeepWhatsRaisedAlreadyWithdrawnError, @@ -75,10 +80,14 @@ export { } from "./contracts/keep-whats-raised"; export type { KeepWhatsRaisedError } from "./contracts/keep-whats-raised"; -export { ItemRegistryMismatchedArraysLengthError } from "./contracts/item-registry"; +export { + ItemRegistryErrorNames, + ItemRegistryMismatchedArraysLengthError, +} from "./contracts/item-registry"; export type { ItemRegistryError } from "./contracts/item-registry"; export { + PaymentTreasuryErrorNames, PaymentTreasuryAlreadyWithdrawnError, PaymentTreasuryCampaignInfoIsPausedError, PaymentTreasuryClaimWindowNotReachedError, @@ -102,6 +111,7 @@ export { export type { PaymentTreasuryError } from "./contracts/payment-treasury"; export { + TreasuryFactoryErrorNames, TreasuryFactoryImplementationNotSetError, TreasuryFactoryImplementationNotSetOrApprovedError, TreasuryFactoryInvalidAddressError, @@ -114,6 +124,7 @@ export { export type { TreasuryFactoryError } from "./contracts/treasury-factory"; export { + SharedErrorNames, AccessCheckerUnauthorizedError, AdminAccessCheckerUnauthorizedError, CurrentTimeIsGreaterError, From 1954ffbb677eee7fddca62318a7ff0dd73f987b3 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 12:13:34 +0600 Subject: [PATCH 32/86] feat: add new events and update reads for GlobalParams contract - Introduce new events: PlatformLineItemTypeSet, PlatformLineItemTypeRemoved, and DataAddedToRegistry in abi.ts. - Add corresponding log fetching and watching functions in events.ts. - Implement a paused() read function in reads.ts to check contract status. - Update types.ts to include new event log functions and the paused() method for type safety. --- .../src/contracts/global-params/abi.ts | 32 +++++++++++++++++++ .../src/contracts/global-params/events.ts | 18 +++++++++++ .../src/contracts/global-params/reads.ts | 3 ++ .../src/contracts/global-params/types.ts | 14 ++++++++ 4 files changed, 67 insertions(+) diff --git a/packages/contracts/src/contracts/global-params/abi.ts b/packages/contracts/src/contracts/global-params/abi.ts index 6ee2eac1..fdd6cdbc 100644 --- a/packages/contracts/src/contracts/global-params/abi.ts +++ b/packages/contracts/src/contracts/global-params/abi.ts @@ -153,6 +153,38 @@ export const GLOBAL_PARAMS_ABI = [ name: "PlatformClaimDelayUpdated", type: "event", }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "platformBytes", type: "bytes32" }, + { indexed: true, internalType: "bytes32", name: "typeId", type: "bytes32" }, + { indexed: false, internalType: "string", name: "label", type: "string" }, + { indexed: false, internalType: "bool", name: "countsTowardGoal", type: "bool" }, + { indexed: false, internalType: "bool", name: "applyProtocolFee", type: "bool" }, + { indexed: false, internalType: "bool", name: "canRefund", type: "bool" }, + { indexed: false, internalType: "bool", name: "instantTransfer", type: "bool" }, + ], + name: "PlatformLineItemTypeSet", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "platformBytes", type: "bytes32" }, + { indexed: true, internalType: "bytes32", name: "typeId", type: "bytes32" }, + ], + name: "PlatformLineItemTypeRemoved", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "bytes32", name: "key", type: "bytes32" }, + { indexed: false, internalType: "bytes32", name: "value", type: "bytes32" }, + ], + name: "DataAddedToRegistry", + type: "event", + }, { anonymous: false, inputs: [{ indexed: false, internalType: "address", name: "account", type: "address" }], diff --git a/packages/contracts/src/contracts/global-params/events.ts b/packages/contracts/src/contracts/global-params/events.ts index c6280311..13165174 100644 --- a/packages/contracts/src/contracts/global-params/events.ts +++ b/packages/contracts/src/contracts/global-params/events.ts @@ -91,6 +91,15 @@ export function createGlobalParamsEvents( async getUnpausedLogs(options) { return fetchEventLogs(publicClient, address, "Unpaused", options); }, + async getDataAddedToRegistryLogs(options) { + return fetchEventLogs(publicClient, address, "DataAddedToRegistry", options); + }, + async getPlatformLineItemTypeSetLogs(options) { + return fetchEventLogs(publicClient, address, "PlatformLineItemTypeSet", options); + }, + async getPlatformLineItemTypeRemovedLogs(options) { + return fetchEventLogs(publicClient, address, "PlatformLineItemTypeRemoved", options); + }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); }, @@ -136,5 +145,14 @@ export function createGlobalParamsEvents( watchUnpaused(onLogs) { return createWatcher(publicClient, address, "Unpaused", onLogs); }, + watchDataAddedToRegistry(onLogs) { + return createWatcher(publicClient, address, "DataAddedToRegistry", onLogs); + }, + watchPlatformLineItemTypeSet(onLogs) { + return createWatcher(publicClient, address, "PlatformLineItemTypeSet", onLogs); + }, + watchPlatformLineItemTypeRemoved(onLogs) { + return createWatcher(publicClient, address, "PlatformLineItemTypeRemoved", onLogs); + }, }; } diff --git a/packages/contracts/src/contracts/global-params/reads.ts b/packages/contracts/src/contracts/global-params/reads.ts index ec2fa5ff..9af07f67 100644 --- a/packages/contracts/src/contracts/global-params/reads.ts +++ b/packages/contracts/src/contracts/global-params/reads.ts @@ -56,6 +56,9 @@ export function createGlobalParamsReads( async getFromRegistry(key: Hex) { return publicClient.readContract({ ...contract, functionName: "getFromRegistry", args: [key] }); }, + async paused() { + return publicClient.readContract({ ...contract, functionName: "paused" }); + }, async owner() { return publicClient.readContract({ ...contract, functionName: "owner" }); }, diff --git a/packages/contracts/src/contracts/global-params/types.ts b/packages/contracts/src/contracts/global-params/types.ts index 4cde2327..3356c274 100644 --- a/packages/contracts/src/contracts/global-params/types.ts +++ b/packages/contracts/src/contracts/global-params/types.ts @@ -31,6 +31,8 @@ export interface GlobalParamsReads { getTokensForCurrency(currency: Hex): Promise; /** Returns a value from the global data registry by key. */ getFromRegistry(key: Hex): Promise; + /** Returns true if the contract is currently paused. */ + paused(): Promise; /** Returns the contract owner address. */ owner(): Promise
; } @@ -137,6 +139,12 @@ export interface GlobalParamsEvents { getPausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Unpaused event logs. */ getUnpausedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded DataAddedToRegistry event logs. */ + getDataAddedToRegistryLogs(options?: EventFilterOptions): Promise; + /** Returns decoded PlatformLineItemTypeSet event logs. */ + getPlatformLineItemTypeSetLogs(options?: EventFilterOptions): Promise; + /** Returns decoded PlatformLineItemTypeRemoved event logs. */ + getPlatformLineItemTypeRemovedLogs(options?: EventFilterOptions): Promise; /** Decodes a raw log entry against all known GlobalParams events. */ decodeLog(log: RawLog): DecodedEventLog; /** Watches for PlatformEnlisted events in real time. Returns an unwatch function. */ @@ -167,6 +175,12 @@ export interface GlobalParamsEvents { watchPaused(onLogs: EventWatchHandler): () => void; /** Watches for Unpaused events in real time. Returns an unwatch function. */ watchUnpaused(onLogs: EventWatchHandler): () => void; + /** Watches for DataAddedToRegistry events in real time. Returns an unwatch function. */ + watchDataAddedToRegistry(onLogs: EventWatchHandler): () => void; + /** Watches for PlatformLineItemTypeSet events in real time. Returns an unwatch function. */ + watchPlatformLineItemTypeSet(onLogs: EventWatchHandler): () => void; + /** Watches for PlatformLineItemTypeRemoved events in real time. Returns an unwatch function. */ + watchPlatformLineItemTypeRemoved(onLogs: EventWatchHandler): () => void; } /** Full GlobalParams entity combining reads, writes, simulate, and events. */ From b0b6f3c5787f22657688c1ce727605406e73c26e Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 12:16:12 +0600 Subject: [PATCH 33/86] test: add unit tests for new GlobalParams contract events and methods - Introduce tests for the paused() method and new event log functions: getDataAddedToRegistryLogs, getPlatformLineItemTypeSetLogs, and getPlatformLineItemTypeRemovedLogs. - Add corresponding watch functions for the new events in the GlobalParams entity tests. --- .../contracts/__tests__/unit/contract-entities.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/contracts/__tests__/unit/contract-entities.test.ts b/packages/contracts/__tests__/unit/contract-entities.test.ts index a9228588..a8480828 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -59,6 +59,7 @@ describe("GlobalParams entity", () => { it("getPlatformLineItemType", async () => { await entity.getPlatformLineItemType(B32, B32); }); it("getTokensForCurrency", async () => { await entity.getTokensForCurrency(B32); }); it("getFromRegistry", async () => { await entity.getFromRegistry(B32); }); + it("paused", async () => { await entity.paused(); }); it("owner", async () => { await entity.owner(); }); }); @@ -121,6 +122,9 @@ describe("GlobalParams entity", () => { it("getOwnershipTransferredLogs", async () => { await entity.events.getOwnershipTransferredLogs(); }); it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); + it("getDataAddedToRegistryLogs", async () => { await entity.events.getDataAddedToRegistryLogs(); }); + it("getPlatformLineItemTypeSetLogs", async () => { await entity.events.getPlatformLineItemTypeSetLogs(); }); + it("getPlatformLineItemTypeRemovedLogs", async () => { await entity.events.getPlatformLineItemTypeRemovedLogs(); }); it("watchPlatformEnlisted", () => { entity.events.watchPlatformEnlisted(() => {}); expect(pub.watchContractEvent).toHaveBeenCalled(); }); it("watchPlatformDelisted", () => { entity.events.watchPlatformDelisted(() => {}); }); it("watchPlatformAdminAddressUpdated", () => { entity.events.watchPlatformAdminAddressUpdated(() => {}); }); @@ -135,6 +139,9 @@ describe("GlobalParams entity", () => { it("watchOwnershipTransferred", () => { entity.events.watchOwnershipTransferred(() => {}); }); it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); + it("watchDataAddedToRegistry", () => { entity.events.watchDataAddedToRegistry(() => {}); }); + it("watchPlatformLineItemTypeSet", () => { entity.events.watchPlatformLineItemTypeSet(() => {}); }); + it("watchPlatformLineItemTypeRemoved", () => { entity.events.watchPlatformLineItemTypeRemoved(() => {}); }); it("decodeLog decodes a Paused event", () => { const pausedSig = keccak256(toHex("Paused(address)")); const result = entity.events.decodeLog({ From cb8e8eb014f7e01a13f5ea401171b4c986019df0 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 13:39:39 +0600 Subject: [PATCH 34/86] feat: add paused and unpaused events to PaymentTreasury contract - Introduce new events: Paused and Unpaused in abi.ts. - Add log fetching and watching functions for these events in events.ts. - Implement a paused() read function in reads.ts to check contract status. - Update types.ts to include new event log functions and the paused() method for type safety. --- packages/contracts/src/constants/events.ts | 2 ++ .../src/contracts/payment-treasury/abi.ts | 25 +++++++++++++++++++ .../src/contracts/payment-treasury/events.ts | 12 +++++++++ .../src/contracts/payment-treasury/reads.ts | 3 +++ .../src/contracts/payment-treasury/types.ts | 10 ++++++++ 5 files changed, 52 insertions(+) diff --git a/packages/contracts/src/constants/events.ts b/packages/contracts/src/constants/events.ts index 86455af3..0f131302 100644 --- a/packages/contracts/src/constants/events.ts +++ b/packages/contracts/src/constants/events.ts @@ -103,12 +103,14 @@ export const PAYMENT_TREASURY_EVENTS = { ExpiredFundsClaimed: "ExpiredFundsClaimed", FeesDisbursed: "FeesDisbursed", NonGoalLineItemsClaimed: "NonGoalLineItemsClaimed", + Paused: "Paused", PaymentBatchConfirmed: "PaymentBatchConfirmed", PaymentBatchCreated: "PaymentBatchCreated", PaymentCancelled: "PaymentCancelled", PaymentConfirmed: "PaymentConfirmed", PaymentCreated: "PaymentCreated", RefundClaimed: "RefundClaimed", + Unpaused: "Unpaused", WithdrawalWithFeeSuccessful: "WithdrawalWithFeeSuccessful", } as const; diff --git a/packages/contracts/src/contracts/payment-treasury/abi.ts b/packages/contracts/src/contracts/payment-treasury/abi.ts index d127b5b8..bffcef58 100644 --- a/packages/contracts/src/contracts/payment-treasury/abi.ts +++ b/packages/contracts/src/contracts/payment-treasury/abi.ts @@ -452,6 +452,31 @@ export const PAYMENT_TREASURY_ABI = [ stateMutability: "nonpayable", type: "function", }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "message", type: "bytes32" }, + ], + name: "Paused", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "message", type: "bytes32" }, + ], + name: "Unpaused", + type: "event", + }, + { + inputs: [], + name: "paused", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, { inputs: [{ internalType: "bytes32", name: "message", type: "bytes32" }], name: "cancelTreasury", diff --git a/packages/contracts/src/contracts/payment-treasury/events.ts b/packages/contracts/src/contracts/payment-treasury/events.ts index 7d438cf0..aeaeccd1 100644 --- a/packages/contracts/src/contracts/payment-treasury/events.ts +++ b/packages/contracts/src/contracts/payment-treasury/events.ts @@ -79,6 +79,12 @@ export function createPaymentTreasuryEvents( async getExpiredFundsClaimedLogs(options) { return fetchEventLogs(publicClient, address, "ExpiredFundsClaimed", options); }, + async getPausedLogs(options) { + return fetchEventLogs(publicClient, address, "Paused", options); + }, + async getUnpausedLogs(options) { + return fetchEventLogs(publicClient, address, "Unpaused", options); + }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); }, @@ -112,5 +118,11 @@ export function createPaymentTreasuryEvents( watchExpiredFundsClaimed(onLogs) { return createWatcher(publicClient, address, "ExpiredFundsClaimed", onLogs); }, + watchPaused(onLogs) { + return createWatcher(publicClient, address, "Paused", onLogs); + }, + watchUnpaused(onLogs) { + return createWatcher(publicClient, address, "Unpaused", onLogs); + }, }; } diff --git a/packages/contracts/src/contracts/payment-treasury/reads.ts b/packages/contracts/src/contracts/payment-treasury/reads.ts index 72b2e278..1c230d29 100644 --- a/packages/contracts/src/contracts/payment-treasury/reads.ts +++ b/packages/contracts/src/contracts/payment-treasury/reads.ts @@ -48,5 +48,8 @@ export function createPaymentTreasuryReads( async cancelled() { return publicClient.readContract({ ...contract, functionName: "cancelled" }); }, + async paused() { + return publicClient.readContract({ ...contract, functionName: "paused" }); + }, }; } diff --git a/packages/contracts/src/contracts/payment-treasury/types.ts b/packages/contracts/src/contracts/payment-treasury/types.ts index 71d25843..d84a687f 100644 --- a/packages/contracts/src/contracts/payment-treasury/types.ts +++ b/packages/contracts/src/contracts/payment-treasury/types.ts @@ -23,6 +23,8 @@ export interface PaymentTreasuryReads { getPaymentData(paymentId: Hex): Promise; /** Returns true if the treasury has been cancelled. */ cancelled(): Promise; + /** Returns true if the treasury is currently paused. */ + paused(): Promise; } /** Write methods for PaymentTreasury. */ @@ -173,6 +175,10 @@ export interface PaymentTreasuryEvents { getNonGoalLineItemsClaimedLogs(options?: EventFilterOptions): Promise; /** Returns decoded ExpiredFundsClaimed event logs. */ getExpiredFundsClaimedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded Paused event logs. */ + getPausedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded Unpaused event logs. */ + getUnpausedLogs(options?: EventFilterOptions): Promise; /** Decodes a raw log entry against all known PaymentTreasury events. */ decodeLog(log: RawLog): DecodedEventLog; /** Watches for PaymentCreated events in real time. Returns an unwatch function. */ @@ -195,6 +201,10 @@ export interface PaymentTreasuryEvents { watchNonGoalLineItemsClaimed(onLogs: EventWatchHandler): () => void; /** Watches for ExpiredFundsClaimed events in real time. Returns an unwatch function. */ watchExpiredFundsClaimed(onLogs: EventWatchHandler): () => void; + /** Watches for Paused events in real time. Returns an unwatch function. */ + watchPaused(onLogs: EventWatchHandler): () => void; + /** Watches for Unpaused events in real time. Returns an unwatch function. */ + watchUnpaused(onLogs: EventWatchHandler): () => void; } /** From 646a629ddb3e57fc613802967c8fd19f68a42672 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 13:39:52 +0600 Subject: [PATCH 35/86] test: add unit tests for paused and unpaused events in PaymentTreasury entity - Introduce tests for the new paused() method and event log functions: getPausedLogs and getUnpausedLogs. - Add corresponding watch functions for the paused and unpaused events in the PaymentTreasury entity tests. --- packages/contracts/__tests__/unit/contract-entities.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/contracts/__tests__/unit/contract-entities.test.ts b/packages/contracts/__tests__/unit/contract-entities.test.ts index a8480828..651d8929 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -439,6 +439,7 @@ describe("PaymentTreasury entity", () => { it("getExpectedAmount", async () => { await entity.getExpectedAmount(); }); it("getPaymentData", async () => { await entity.getPaymentData(B32); }); it("cancelled", async () => { await entity.cancelled(); }); + it("paused", async () => { await entity.paused(); }); }); describe("writes", () => { @@ -488,6 +489,8 @@ describe("PaymentTreasury entity", () => { it("getRefundClaimedLogs", async () => { await entity.events.getRefundClaimedLogs(); }); it("getNonGoalLineItemsClaimedLogs", async () => { await entity.events.getNonGoalLineItemsClaimedLogs(); }); it("getExpiredFundsClaimedLogs", async () => { await entity.events.getExpiredFundsClaimedLogs(); }); + it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); + it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); it("watchPaymentCreated", () => { entity.events.watchPaymentCreated(() => {}); expect(pub.watchContractEvent).toHaveBeenCalled(); }); it("watchPaymentConfirmed", () => { entity.events.watchPaymentConfirmed(() => {}); }); it("watchPaymentCancelled", () => { entity.events.watchPaymentCancelled(() => {}); }); @@ -498,6 +501,8 @@ describe("PaymentTreasury entity", () => { it("watchWithdrawalWithFeeSuccessful", () => { entity.events.watchWithdrawalWithFeeSuccessful(() => {}); }); it("watchNonGoalLineItemsClaimed", () => { entity.events.watchNonGoalLineItemsClaimed(() => {}); }); it("watchExpiredFundsClaimed", () => { entity.events.watchExpiredFundsClaimed(() => {}); }); + it("watchPaused", () => { entity.events.watchPaused(() => {}); }); + it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); it("decodeLog decodes a PaymentCancelled event", () => { const sig = keccak256(toHex("PaymentCancelled(bytes32)")); const result = entity.events.decodeLog({ topics: [sig, B32], data: "0x" as `0x${string}` }); From a3daf39da5627888bad402f9303674b7541ce79f Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 14:09:39 +0600 Subject: [PATCH 36/86] feat: add PausableCancellable and PledgeNFT related missing events, errors and read methods --- packages/contracts/src/constants/events.ts | 11 +- .../src/contracts/all-or-nothing/abi.ts | 14 ++ .../src/contracts/all-or-nothing/events.ts | 6 + .../src/contracts/all-or-nothing/types.ts | 4 + .../src/contracts/campaign-info/abi.ts | 124 ++++++++++++++++++ .../src/contracts/campaign-info/events.ts | 24 ++++ .../src/contracts/campaign-info/reads.ts | 33 ++++- .../src/contracts/campaign-info/types.ts | 38 +++++- .../src/contracts/keep-whats-raised/abi.ts | 14 ++ .../src/contracts/keep-whats-raised/events.ts | 6 + .../src/contracts/keep-whats-raised/types.ts | 4 + .../src/contracts/payment-treasury/abi.ts | 14 ++ .../src/contracts/payment-treasury/events.ts | 6 + .../src/contracts/payment-treasury/types.ts | 4 + .../contracts/src/errors/contracts/shared.ts | 98 ++++++++++++++ packages/contracts/src/errors/index.ts | 7 + packages/contracts/src/errors/parse/shared.ts | 21 +++ packages/contracts/src/types/structs.ts | 18 +++ 18 files changed, 442 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/constants/events.ts b/packages/contracts/src/constants/events.ts index 0f131302..47d37232 100644 --- a/packages/contracts/src/constants/events.ts +++ b/packages/contracts/src/constants/events.ts @@ -42,13 +42,17 @@ export const CAMPAIGN_INFO_FACTORY_EVENTS = { /** Event names emitted by the CampaignInfo contract. */ export const CAMPAIGN_INFO_EVENTS = { + Cancelled: "Cancelled", + ContractURIUpdated: "ContractURIUpdated", DeadlineUpdated: "CampaignInfoDeadlineUpdated", GoalAmountUpdated: "CampaignInfoGoalAmountUpdated", + ImageURIUpdated: "ImageURIUpdated", LaunchTimeUpdated: "CampaignInfoLaunchTimeUpdated", - PlatformInfoUpdated: "CampaignInfoPlatformInfoUpdated", - SelectedPlatformUpdated: "CampaignInfoSelectedPlatformUpdated", OwnershipTransferred: "OwnershipTransferred", Paused: "Paused", + PlatformInfoUpdated: "CampaignInfoPlatformInfoUpdated", + PledgeNFTMinted: "PledgeNFTMinted", + SelectedPlatformUpdated: "CampaignInfoSelectedPlatformUpdated", Unpaused: "Unpaused", } as const; @@ -64,6 +68,7 @@ export const TREASURY_FACTORY_EVENTS = { export const ALL_OR_NOTHING_EVENTS = { Approval: "Approval", ApprovalForAll: "ApprovalForAll", + Cancelled: "Cancelled", FeesDisbursed: "FeesDisbursed", Paused: "Paused", Receipt: "Receipt", @@ -80,6 +85,7 @@ export const ALL_OR_NOTHING_EVENTS = { export const KEEP_WHATS_RAISED_EVENTS = { Approval: "Approval", ApprovalForAll: "ApprovalForAll", + Cancelled: "Cancelled", DeadlineUpdated: "KeepWhatsRaisedDeadlineUpdated", FeesDisbursed: "FeesDisbursed", FundClaimed: "FundClaimed", @@ -100,6 +106,7 @@ export const KEEP_WHATS_RAISED_EVENTS = { /** Event names emitted by the PaymentTreasury contract. */ export const PAYMENT_TREASURY_EVENTS = { + Cancelled: "Cancelled", ExpiredFundsClaimed: "ExpiredFundsClaimed", FeesDisbursed: "FeesDisbursed", NonGoalLineItemsClaimed: "NonGoalLineItemsClaimed", diff --git a/packages/contracts/src/contracts/all-or-nothing/abi.ts b/packages/contracts/src/contracts/all-or-nothing/abi.ts index 5861c7e2..b632e029 100644 --- a/packages/contracts/src/contracts/all-or-nothing/abi.ts +++ b/packages/contracts/src/contracts/all-or-nothing/abi.ts @@ -157,6 +157,20 @@ export const ALL_OR_NOTHING_ABI = [ name: "Unpaused", type: "event", }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "reason", type: "bytes32" }, + ], + name: "Cancelled", + type: "event", + }, + { inputs: [], name: "PausedError", type: "error" }, + { inputs: [], name: "NotPausedError", type: "error" }, + { inputs: [], name: "CancelledError", type: "error" }, + { inputs: [], name: "NotCancelledError", type: "error" }, + { inputs: [], name: "CannotCancel", type: "error" }, { anonymous: false, inputs: [ diff --git a/packages/contracts/src/contracts/all-or-nothing/events.ts b/packages/contracts/src/contracts/all-or-nothing/events.ts index 87f690d5..542dff4b 100644 --- a/packages/contracts/src/contracts/all-or-nothing/events.ts +++ b/packages/contracts/src/contracts/all-or-nothing/events.ts @@ -106,6 +106,9 @@ export function createAllOrNothingEvents( async getUnpausedLogs(options) { return fetchEventLogs(publicClient, address, "Unpaused", options); }, + async getCancelledLogs(options) { + return fetchEventLogs(publicClient, address, "Cancelled", options); + }, async getTransferLogs(options) { return fetchEventLogs(publicClient, address, "Transfer", options); }, @@ -145,6 +148,9 @@ export function createAllOrNothingEvents( watchUnpaused(onLogs) { return createWatcher(publicClient, address, "Unpaused", onLogs); }, + watchCancelled(onLogs) { + return createWatcher(publicClient, address, "Cancelled", onLogs); + }, watchTransfer(onLogs) { return createWatcher(publicClient, address, "Transfer", onLogs); }, diff --git a/packages/contracts/src/contracts/all-or-nothing/types.ts b/packages/contracts/src/contracts/all-or-nothing/types.ts index d30fbdc0..58670b9e 100644 --- a/packages/contracts/src/contracts/all-or-nothing/types.ts +++ b/packages/contracts/src/contracts/all-or-nothing/types.ts @@ -125,6 +125,8 @@ export interface AllOrNothingEvents { getPausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Unpaused event logs. */ getUnpausedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded Cancelled event logs. */ + getCancelledLogs(options?: EventFilterOptions): Promise; /** Returns decoded Transfer event logs. */ getTransferLogs(options?: EventFilterOptions): Promise; /** Returns decoded SuccessConditionNotFulfilled event logs. */ @@ -151,6 +153,8 @@ export interface AllOrNothingEvents { watchPaused(onLogs: EventWatchHandler): () => void; /** Watches for Unpaused events in real time. Returns an unwatch function. */ watchUnpaused(onLogs: EventWatchHandler): () => void; + /** Watches for Cancelled events in real time. Returns an unwatch function. */ + watchCancelled(onLogs: EventWatchHandler): () => void; /** Watches for Transfer events in real time. Returns an unwatch function. */ watchTransfer(onLogs: EventWatchHandler): () => void; /** Watches for SuccessConditionNotFulfilled events in real time. Returns an unwatch function. */ diff --git a/packages/contracts/src/contracts/campaign-info/abi.ts b/packages/contracts/src/contracts/campaign-info/abi.ts index c79aa1dd..6e26f0d2 100644 --- a/packages/contracts/src/contracts/campaign-info/abi.ts +++ b/packages/contracts/src/contracts/campaign-info/abi.ts @@ -115,6 +115,130 @@ export const CAMPAIGN_INFO_ABI = [ name: "Unpaused", type: "event", }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "reason", type: "bytes32" }, + ], + name: "Cancelled", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, + { indexed: true, internalType: "address", name: "backer", type: "address" }, + { indexed: true, internalType: "address", name: "treasury", type: "address" }, + { indexed: false, internalType: "bytes32", name: "reward", type: "bytes32" }, + ], + name: "PledgeNFTMinted", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "string", name: "newImageURI", type: "string" }], + name: "ImageURIUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "string", name: "newContractURI", type: "string" }], + name: "ContractURIUpdated", + type: "event", + }, + { inputs: [], name: "PausedError", type: "error" }, + { inputs: [], name: "NotPausedError", type: "error" }, + { inputs: [], name: "CancelledError", type: "error" }, + { inputs: [], name: "NotCancelledError", type: "error" }, + { inputs: [], name: "CannotCancel", type: "error" }, + { inputs: [], name: "PledgeNFTUnAuthorized", type: "error" }, + { inputs: [], name: "PledgeNFTInvalidJsonString", type: "error" }, + { + inputs: [], + name: "getPledgeCount", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "getPledgeData", + outputs: [ + { + components: [ + { internalType: "address", name: "backer", type: "address" }, + { internalType: "bytes32", name: "reward", type: "bytes32" }, + { internalType: "address", name: "treasury", type: "address" }, + { internalType: "address", name: "tokenAddress", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "uint256", name: "shippingFee", type: "uint256" }, + { internalType: "uint256", name: "tipAmount", type: "uint256" }, + ], + internalType: "struct PledgeNFT.PledgeData", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getImageURI", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "contractURI", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "name", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "symbol", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "tokenURI", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "ownerOf", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "bytes4", name: "interfaceId", type: "bytes4" }], + name: "supportsInterface", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, { inputs: [{ internalType: "bytes32", name: "message", type: "bytes32" }], name: "_cancelCampaign", diff --git a/packages/contracts/src/contracts/campaign-info/events.ts b/packages/contracts/src/contracts/campaign-info/events.ts index 8620bbb9..e41d63c2 100644 --- a/packages/contracts/src/contracts/campaign-info/events.ts +++ b/packages/contracts/src/contracts/campaign-info/events.ts @@ -73,6 +73,18 @@ export function createCampaignInfoEvents( async getUnpausedLogs(options) { return fetchEventLogs(publicClient, address, "Unpaused", options); }, + async getCancelledLogs(options) { + return fetchEventLogs(publicClient, address, "Cancelled", options); + }, + async getPledgeNFTMintedLogs(options) { + return fetchEventLogs(publicClient, address, "PledgeNFTMinted", options); + }, + async getImageURIUpdatedLogs(options) { + return fetchEventLogs(publicClient, address, "ImageURIUpdated", options); + }, + async getContractURIUpdatedLogs(options) { + return fetchEventLogs(publicClient, address, "ContractURIUpdated", options); + }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); }, @@ -100,5 +112,17 @@ export function createCampaignInfoEvents( watchUnpaused(onLogs) { return createWatcher(publicClient, address, "Unpaused", onLogs); }, + watchCancelled(onLogs) { + return createWatcher(publicClient, address, "Cancelled", onLogs); + }, + watchPledgeNFTMinted(onLogs) { + return createWatcher(publicClient, address, "PledgeNFTMinted", onLogs); + }, + watchImageURIUpdated(onLogs) { + return createWatcher(publicClient, address, "ImageURIUpdated", onLogs); + }, + watchContractURIUpdated(onLogs) { + return createWatcher(publicClient, address, "ContractURIUpdated", onLogs); + }, }; } diff --git a/packages/contracts/src/contracts/campaign-info/reads.ts b/packages/contracts/src/contracts/campaign-info/reads.ts index 6a87f159..c9d5f0c5 100644 --- a/packages/contracts/src/contracts/campaign-info/reads.ts +++ b/packages/contracts/src/contracts/campaign-info/reads.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient } from "../../lib"; import { CAMPAIGN_INFO_ABI } from "./abi"; import type { CampaignInfoReads } from "./types"; -import type { LineItemTypeInfo, CampaignConfig } from "../../types/structs"; +import type { LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; /** * Builds read methods for a CampaignInfo contract instance. @@ -108,5 +108,36 @@ export function createCampaignInfoReads( async paused() { return publicClient.readContract({ ...contract, functionName: "paused" }); }, + async getPledgeCount() { + return publicClient.readContract({ ...contract, functionName: "getPledgeCount" }); + }, + async getPledgeData(tokenId: bigint): Promise { + const result = await publicClient.readContract({ ...contract, functionName: "getPledgeData", args: [tokenId] }); + return result as unknown as PledgeData; + }, + async getImageURI() { + return publicClient.readContract({ ...contract, functionName: "getImageURI" }); + }, + async contractURI() { + return publicClient.readContract({ ...contract, functionName: "contractURI" }); + }, + async name() { + return publicClient.readContract({ ...contract, functionName: "name" }); + }, + async symbol() { + return publicClient.readContract({ ...contract, functionName: "symbol" }); + }, + async tokenURI(tokenId: bigint) { + return publicClient.readContract({ ...contract, functionName: "tokenURI", args: [tokenId] }); + }, + async ownerOf(tokenId: bigint) { + return publicClient.readContract({ ...contract, functionName: "ownerOf", args: [tokenId] }); + }, + async balanceOf(owner: Address) { + return publicClient.readContract({ ...contract, functionName: "balanceOf", args: [owner] }); + }, + async supportsInterface(interfaceId: Hex) { + return publicClient.readContract({ ...contract, functionName: "supportsInterface", args: [interfaceId] }); + }, }; } diff --git a/packages/contracts/src/contracts/campaign-info/types.ts b/packages/contracts/src/contracts/campaign-info/types.ts index ee2da5e6..7f0c7d08 100644 --- a/packages/contracts/src/contracts/campaign-info/types.ts +++ b/packages/contracts/src/contracts/campaign-info/types.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from "../../lib"; -import type { LineItemTypeInfo, CampaignConfig } from "../../types/structs"; +import type { LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; @@ -65,6 +65,26 @@ export interface CampaignInfoReads { owner(): Promise
; /** Returns true if the campaign is paused. */ paused(): Promise; + /** Returns the current total number of pledge NFTs minted. */ + getPledgeCount(): Promise; + /** Returns the pledge data struct for a given token ID. */ + getPledgeData(tokenId: bigint): Promise; + /** Returns the NFT image URI. */ + getImageURI(): Promise; + /** Returns the contract-level metadata URI. */ + contractURI(): Promise; + /** Returns the NFT collection name. */ + name(): Promise; + /** Returns the NFT collection symbol. */ + symbol(): Promise; + /** Returns the token URI with on-chain metadata for a given token ID. */ + tokenURI(tokenId: bigint): Promise; + /** Returns the owner address of a given token ID. */ + ownerOf(tokenId: bigint): Promise
; + /** Returns the number of tokens held by an owner. */ + balanceOf(owner: Address): Promise; + /** Returns true if the contract supports the given ERC-165 interface ID. */ + supportsInterface(interfaceId: Hex): Promise; } /** Write methods for a CampaignInfo contract instance. */ @@ -149,6 +169,14 @@ export interface CampaignInfoEvents { getPausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Unpaused event logs. */ getUnpausedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded Cancelled event logs. */ + getCancelledLogs(options?: EventFilterOptions): Promise; + /** Returns decoded PledgeNFTMinted event logs. */ + getPledgeNFTMintedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded ImageURIUpdated event logs. */ + getImageURIUpdatedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded ContractURIUpdated event logs. */ + getContractURIUpdatedLogs(options?: EventFilterOptions): Promise; /** Decodes a raw log entry against all known CampaignInfo events. */ decodeLog(log: RawLog): DecodedEventLog; /** Watches for CampaignInfoDeadlineUpdated events in real time. Returns an unwatch function. */ @@ -167,6 +195,14 @@ export interface CampaignInfoEvents { watchPaused(onLogs: EventWatchHandler): () => void; /** Watches for Unpaused events in real time. Returns an unwatch function. */ watchUnpaused(onLogs: EventWatchHandler): () => void; + /** Watches for Cancelled events in real time. Returns an unwatch function. */ + watchCancelled(onLogs: EventWatchHandler): () => void; + /** Watches for PledgeNFTMinted events in real time. Returns an unwatch function. */ + watchPledgeNFTMinted(onLogs: EventWatchHandler): () => void; + /** Watches for ImageURIUpdated events in real time. Returns an unwatch function. */ + watchImageURIUpdated(onLogs: EventWatchHandler): () => void; + /** Watches for ContractURIUpdated events in real time. Returns an unwatch function. */ + watchContractURIUpdated(onLogs: EventWatchHandler): () => void; } /** Full CampaignInfo entity combining reads, writes, simulate, and events. */ diff --git a/packages/contracts/src/contracts/keep-whats-raised/abi.ts b/packages/contracts/src/contracts/keep-whats-raised/abi.ts index 99394ef5..791a1449 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/abi.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/abi.ts @@ -274,6 +274,20 @@ export const KEEP_WHATS_RAISED_ABI = [ name: "Unpaused", type: "event", }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "reason", type: "bytes32" }, + ], + name: "Cancelled", + type: "event", + }, + { inputs: [], name: "PausedError", type: "error" }, + { inputs: [], name: "NotPausedError", type: "error" }, + { inputs: [], name: "CancelledError", type: "error" }, + { inputs: [], name: "NotCancelledError", type: "error" }, + { inputs: [], name: "CannotCancel", type: "error" }, { inputs: [ { internalType: "bytes32", name: "_platformHash", type: "bytes32" }, diff --git a/packages/contracts/src/contracts/keep-whats-raised/events.ts b/packages/contracts/src/contracts/keep-whats-raised/events.ts index 5b65a491..936f78a1 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/events.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/events.ts @@ -94,6 +94,9 @@ export function createKeepWhatsRaisedEvents( async getUnpausedLogs(options) { return fetchEventLogs(publicClient, address, "Unpaused", options); }, + async getCancelledLogs(options) { + return fetchEventLogs(publicClient, address, "Cancelled", options); + }, async getTransferLogs(options) { return fetchEventLogs(publicClient, address, "Transfer", options); }, @@ -151,6 +154,9 @@ export function createKeepWhatsRaisedEvents( watchUnpaused(onLogs) { return createWatcher(publicClient, address, "Unpaused", onLogs); }, + watchCancelled(onLogs) { + return createWatcher(publicClient, address, "Cancelled", onLogs); + }, watchTransfer(onLogs) { return createWatcher(publicClient, address, "Transfer", onLogs); }, diff --git a/packages/contracts/src/contracts/keep-whats-raised/types.ts b/packages/contracts/src/contracts/keep-whats-raised/types.ts index d0a557b6..84929305 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/types.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/types.ts @@ -242,6 +242,8 @@ export interface KeepWhatsRaisedEvents { getPausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Unpaused event logs. */ getUnpausedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded Cancelled event logs. */ + getCancelledLogs(options?: EventFilterOptions): Promise; /** Returns decoded Transfer event logs. */ getTransferLogs(options?: EventFilterOptions): Promise; /** Returns decoded Approval event logs. */ @@ -280,6 +282,8 @@ export interface KeepWhatsRaisedEvents { watchPaused(onLogs: EventWatchHandler): () => void; /** Watches for Unpaused events in real time. Returns an unwatch function. */ watchUnpaused(onLogs: EventWatchHandler): () => void; + /** Watches for Cancelled events in real time. Returns an unwatch function. */ + watchCancelled(onLogs: EventWatchHandler): () => void; /** Watches for Transfer events in real time. Returns an unwatch function. */ watchTransfer(onLogs: EventWatchHandler): () => void; /** Watches for Approval events in real time. Returns an unwatch function. */ diff --git a/packages/contracts/src/contracts/payment-treasury/abi.ts b/packages/contracts/src/contracts/payment-treasury/abi.ts index bffcef58..3a089860 100644 --- a/packages/contracts/src/contracts/payment-treasury/abi.ts +++ b/packages/contracts/src/contracts/payment-treasury/abi.ts @@ -470,6 +470,20 @@ export const PAYMENT_TREASURY_ABI = [ name: "Unpaused", type: "event", }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "reason", type: "bytes32" }, + ], + name: "Cancelled", + type: "event", + }, + { inputs: [], name: "PausedError", type: "error" }, + { inputs: [], name: "NotPausedError", type: "error" }, + { inputs: [], name: "CancelledError", type: "error" }, + { inputs: [], name: "NotCancelledError", type: "error" }, + { inputs: [], name: "CannotCancel", type: "error" }, { inputs: [], name: "paused", diff --git a/packages/contracts/src/contracts/payment-treasury/events.ts b/packages/contracts/src/contracts/payment-treasury/events.ts index aeaeccd1..6bf62ec2 100644 --- a/packages/contracts/src/contracts/payment-treasury/events.ts +++ b/packages/contracts/src/contracts/payment-treasury/events.ts @@ -85,6 +85,9 @@ export function createPaymentTreasuryEvents( async getUnpausedLogs(options) { return fetchEventLogs(publicClient, address, "Unpaused", options); }, + async getCancelledLogs(options) { + return fetchEventLogs(publicClient, address, "Cancelled", options); + }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); }, @@ -124,5 +127,8 @@ export function createPaymentTreasuryEvents( watchUnpaused(onLogs) { return createWatcher(publicClient, address, "Unpaused", onLogs); }, + watchCancelled(onLogs) { + return createWatcher(publicClient, address, "Cancelled", onLogs); + }, }; } diff --git a/packages/contracts/src/contracts/payment-treasury/types.ts b/packages/contracts/src/contracts/payment-treasury/types.ts index d84a687f..d1d5ea16 100644 --- a/packages/contracts/src/contracts/payment-treasury/types.ts +++ b/packages/contracts/src/contracts/payment-treasury/types.ts @@ -179,6 +179,8 @@ export interface PaymentTreasuryEvents { getPausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Unpaused event logs. */ getUnpausedLogs(options?: EventFilterOptions): Promise; + /** Returns decoded Cancelled event logs. */ + getCancelledLogs(options?: EventFilterOptions): Promise; /** Decodes a raw log entry against all known PaymentTreasury events. */ decodeLog(log: RawLog): DecodedEventLog; /** Watches for PaymentCreated events in real time. Returns an unwatch function. */ @@ -205,6 +207,8 @@ export interface PaymentTreasuryEvents { watchPaused(onLogs: EventWatchHandler): () => void; /** Watches for Unpaused events in real time. Returns an unwatch function. */ watchUnpaused(onLogs: EventWatchHandler): () => void; + /** Watches for Cancelled events in real time. Returns an unwatch function. */ + watchCancelled(onLogs: EventWatchHandler): () => void; } /** diff --git a/packages/contracts/src/errors/contracts/shared.ts b/packages/contracts/src/errors/contracts/shared.ts index 063c2545..40fc61b3 100644 --- a/packages/contracts/src/errors/contracts/shared.ts +++ b/packages/contracts/src/errors/contracts/shared.ts @@ -4,9 +4,16 @@ import type { ContractErrorBase } from "../base"; export const SharedErrorNames = { AccessCheckerUnauthorized: "AccessCheckerUnauthorized", AdminAccessCheckerUnauthorized: "AdminAccessCheckerUnauthorized", + CannotCancel: "CannotCancel", + CancelledError: "CancelledError", CurrentTimeIsGreater: "CurrentTimeIsGreater", CurrentTimeIsLess: "CurrentTimeIsLess", CurrentTimeIsNotWithinRange: "CurrentTimeIsNotWithinRange", + NotCancelledError: "NotCancelledError", + NotPausedError: "NotPausedError", + PausedError: "PausedError", + PledgeNFTInvalidJsonString: "PledgeNFTInvalidJsonString", + PledgeNFTUnAuthorized: "PledgeNFTUnAuthorized", TreasuryCampaignInfoIsPaused: "TreasuryCampaignInfoIsPaused", TreasuryFeeNotDisbursed: "TreasuryFeeNotDisbursed", TreasuryTransferFailed: "TreasuryTransferFailed", @@ -114,13 +121,104 @@ export class TreasuryTransferFailedError extends Error implements ContractErrorB } } +/** Thrown when an operation is attempted while the contract is paused. */ +export class PausedErrorError extends Error implements ContractErrorBase { + readonly name = SharedErrorNames.PausedError; + readonly args: Record = {}; + readonly recoveryHint = "The contract is currently paused. Wait for it to be unpaused."; + + constructor() { + super(`${SharedErrorNames.PausedError}()`); + Object.setPrototypeOf(this, PausedErrorError.prototype); + } +} + +/** Thrown when an operation requires the contract to be paused but it is not. */ +export class NotPausedErrorError extends Error implements ContractErrorBase { + readonly name = SharedErrorNames.NotPausedError; + readonly args: Record = {}; + readonly recoveryHint = "The contract is not paused. This operation can only be performed when paused."; + + constructor() { + super(`${SharedErrorNames.NotPausedError}()`); + Object.setPrototypeOf(this, NotPausedErrorError.prototype); + } +} + +/** Thrown when an operation is attempted after the contract has been cancelled. */ +export class CancelledErrorError extends Error implements ContractErrorBase { + readonly name = SharedErrorNames.CancelledError; + readonly args: Record = {}; + readonly recoveryHint = "The contract has been cancelled. This operation is no longer available."; + + constructor() { + super(`${SharedErrorNames.CancelledError}()`); + Object.setPrototypeOf(this, CancelledErrorError.prototype); + } +} + +/** Thrown when an operation requires the contract to be cancelled but it is not. */ +export class NotCancelledErrorError extends Error implements ContractErrorBase { + readonly name = SharedErrorNames.NotCancelledError; + readonly args: Record = {}; + readonly recoveryHint = "The contract has not been cancelled. This operation requires cancellation first."; + + constructor() { + super(`${SharedErrorNames.NotCancelledError}()`); + Object.setPrototypeOf(this, NotCancelledErrorError.prototype); + } +} + +/** Thrown when attempting to cancel a contract that is already cancelled. */ +export class CannotCancelError extends Error implements ContractErrorBase { + readonly name = SharedErrorNames.CannotCancel; + readonly args: Record = {}; + readonly recoveryHint = "The contract is already cancelled and cannot be cancelled again."; + + constructor() { + super(`${SharedErrorNames.CannotCancel}()`); + Object.setPrototypeOf(this, CannotCancelError.prototype); + } +} + +/** Thrown when an unauthorized PledgeNFT operation is attempted. */ +export class PledgeNFTUnAuthorizedError extends Error implements ContractErrorBase { + readonly name = SharedErrorNames.PledgeNFTUnAuthorized; + readonly args: Record = {}; + readonly recoveryHint = "Caller is not authorized for this PledgeNFT operation."; + + constructor() { + super(`${SharedErrorNames.PledgeNFTUnAuthorized}()`); + Object.setPrototypeOf(this, PledgeNFTUnAuthorizedError.prototype); + } +} + +/** Thrown when a string contains invalid characters for on-chain JSON embedding. */ +export class PledgeNFTInvalidJsonStringError extends Error implements ContractErrorBase { + readonly name = SharedErrorNames.PledgeNFTInvalidJsonString; + readonly args: Record = {}; + readonly recoveryHint = "The string contains invalid characters (quotes, backslashes, control characters, or non-ASCII). Use only printable ASCII."; + + constructor() { + super(`${SharedErrorNames.PledgeNFTInvalidJsonString}()`); + Object.setPrototypeOf(this, PledgeNFTInvalidJsonStringError.prototype); + } +} + /** Union of all typed errors shared across multiple contract types. */ export type SharedError = | AccessCheckerUnauthorizedError | AdminAccessCheckerUnauthorizedError + | CannotCancelError + | CancelledErrorError | CurrentTimeIsGreaterError | CurrentTimeIsLessError | CurrentTimeIsNotWithinRangeError + | NotCancelledErrorError + | NotPausedErrorError + | PausedErrorError + | PledgeNFTInvalidJsonStringError + | PledgeNFTUnAuthorizedError | TreasuryCampaignInfoIsPausedError | TreasuryFeeNotDisbursedError | TreasuryTransferFailedError; diff --git a/packages/contracts/src/errors/index.ts b/packages/contracts/src/errors/index.ts index 0a27c662..1f540d42 100644 --- a/packages/contracts/src/errors/index.ts +++ b/packages/contracts/src/errors/index.ts @@ -127,9 +127,16 @@ export { SharedErrorNames, AccessCheckerUnauthorizedError, AdminAccessCheckerUnauthorizedError, + CannotCancelError, + CancelledErrorError, CurrentTimeIsGreaterError, CurrentTimeIsLessError, CurrentTimeIsNotWithinRangeError, + NotCancelledErrorError, + NotPausedErrorError, + PausedErrorError, + PledgeNFTInvalidJsonStringError, + PledgeNFTUnAuthorizedError, TreasuryCampaignInfoIsPausedError, TreasuryFeeNotDisbursedError, TreasuryTransferFailedError, diff --git a/packages/contracts/src/errors/parse/shared.ts b/packages/contracts/src/errors/parse/shared.ts index 6b101a07..a13ccc20 100644 --- a/packages/contracts/src/errors/parse/shared.ts +++ b/packages/contracts/src/errors/parse/shared.ts @@ -8,9 +8,16 @@ import type { ContractErrorBase } from "../base"; import { AccessCheckerUnauthorizedError, AdminAccessCheckerUnauthorizedError, + CannotCancelError, + CancelledErrorError, CurrentTimeIsGreaterError, CurrentTimeIsLessError, CurrentTimeIsNotWithinRangeError, + NotCancelledErrorError, + NotPausedErrorError, + PausedErrorError, + PledgeNFTInvalidJsonStringError, + PledgeNFTUnAuthorizedError, SharedErrorNames, TreasuryCampaignInfoIsPausedError, TreasuryFeeNotDisbursedError, @@ -86,6 +93,20 @@ export function toSharedContractError( return new TreasuryFeeNotDisbursedError(); case SharedErrorNames.TreasuryTransferFailed: return new TreasuryTransferFailedError(); + case SharedErrorNames.PausedError: + return new PausedErrorError(); + case SharedErrorNames.NotPausedError: + return new NotPausedErrorError(); + case SharedErrorNames.CancelledError: + return new CancelledErrorError(); + case SharedErrorNames.NotCancelledError: + return new NotCancelledErrorError(); + case SharedErrorNames.CannotCancel: + return new CannotCancelError(); + case SharedErrorNames.PledgeNFTUnAuthorized: + return new PledgeNFTUnAuthorizedError(); + case SharedErrorNames.PledgeNFTInvalidJsonString: + return new PledgeNFTInvalidJsonStringError(); default: return null; } diff --git a/packages/contracts/src/types/structs.ts b/packages/contracts/src/types/structs.ts index 939bf383..9b8ff643 100644 --- a/packages/contracts/src/types/structs.ts +++ b/packages/contracts/src/types/structs.ts @@ -152,6 +152,24 @@ export interface LineItemTypeInfo { instantTransfer: boolean; } +/** PledgeNFT.PledgeData — on-chain pledge metadata stored per token ID. */ +export interface PledgeData { + /** Backer wallet address. */ + backer: Address; + /** bytes32 reward identifier (ZERO_BYTES for no-reward pledges). */ + reward: Hex; + /** Treasury contract that minted this NFT. */ + treasury: Address; + /** ERC-20 token address used for the pledge. */ + tokenAddress: Address; + /** Pledge amount in token units. */ + amount: bigint; + /** Shipping fee in token units. */ + shippingFee: bigint; + /** Tip amount in token units. */ + tipAmount: bigint; +} + /** Return type for CampaignInfo.getCampaignConfig. */ export interface CampaignConfig { /** Address of the TreasuryFactory used to deploy treasuries. */ From b0cd33bae9dec559eeda78c09c668e7bd629f3b6 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 14:10:02 +0600 Subject: [PATCH 37/86] test: enhance unit tests for contract entities with new methods and error mappings - Add tests for new read methods in CampaignInfo, PaymentTreasury, AllOrNothing, and KeepWhatsRaised entities. - Introduce tests for error mappings related to paused, cancelled, and pledge NFT errors in error-parsing and errors test files. - Ensure comprehensive coverage for new functionalities and error handling improvements. --- .../__tests__/unit/contract-entities.test.ts | 24 +++++++++++ .../__tests__/unit/error-parsing.test.ts | 35 ++++++++++++++++ .../contracts/__tests__/unit/errors.test.ts | 42 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/packages/contracts/__tests__/unit/contract-entities.test.ts b/packages/contracts/__tests__/unit/contract-entities.test.ts index 651d8929..e1343d20 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -335,6 +335,16 @@ describe("CampaignInfo entity", () => { it("cancelled", async () => { await entity.cancelled(); }); it("owner", async () => { await entity.owner(); }); it("paused", async () => { await entity.paused(); }); + it("getPledgeCount", async () => { await entity.getPledgeCount(); }); + it("getPledgeData", async () => { await entity.getPledgeData(0n); }); + it("getImageURI", async () => { await entity.getImageURI(); }); + it("contractURI", async () => { await entity.contractURI(); }); + it("name", async () => { await entity.name(); }); + it("symbol", async () => { await entity.symbol(); }); + it("tokenURI", async () => { await entity.tokenURI(0n); }); + it("ownerOf", async () => { await entity.ownerOf(0n); }); + it("balanceOf", async () => { await entity.balanceOf(ADDR); }); + it("supportsInterface", async () => { await entity.supportsInterface(B32.slice(0, 10) as `0x${string}`); }); }); describe("writes", () => { @@ -380,6 +390,10 @@ describe("CampaignInfo entity", () => { it("getOwnershipTransferredLogs", async () => { await entity.events.getOwnershipTransferredLogs(); }); it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); + it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); + it("getPledgeNFTMintedLogs", async () => { await entity.events.getPledgeNFTMintedLogs(); }); + it("getImageURIUpdatedLogs", async () => { await entity.events.getImageURIUpdatedLogs(); }); + it("getContractURIUpdatedLogs", async () => { await entity.events.getContractURIUpdatedLogs(); }); it("watchDeadlineUpdated", () => { entity.events.watchDeadlineUpdated(() => {}); expect(pub.watchContractEvent).toHaveBeenCalled(); }); it("watchGoalAmountUpdated", () => { entity.events.watchGoalAmountUpdated(() => {}); }); it("watchLaunchTimeUpdated", () => { entity.events.watchLaunchTimeUpdated(() => {}); }); @@ -388,6 +402,10 @@ describe("CampaignInfo entity", () => { it("watchOwnershipTransferred", () => { entity.events.watchOwnershipTransferred(() => {}); }); it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); + it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); + it("watchPledgeNFTMinted", () => { entity.events.watchPledgeNFTMinted(() => {}); }); + it("watchImageURIUpdated", () => { entity.events.watchImageURIUpdated(() => {}); }); + it("watchContractURIUpdated", () => { entity.events.watchContractURIUpdated(() => {}); }); it("decodeLog decodes a CampaignInfoDeadlineUpdated event", () => { const sig = keccak256(toHex("CampaignInfoDeadlineUpdated(uint256)")); const data = ("0x" + "0".repeat(63) + "1") as `0x${string}`; @@ -491,6 +509,7 @@ describe("PaymentTreasury entity", () => { it("getExpiredFundsClaimedLogs", async () => { await entity.events.getExpiredFundsClaimedLogs(); }); it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); + it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); it("watchPaymentCreated", () => { entity.events.watchPaymentCreated(() => {}); expect(pub.watchContractEvent).toHaveBeenCalled(); }); it("watchPaymentConfirmed", () => { entity.events.watchPaymentConfirmed(() => {}); }); it("watchPaymentCancelled", () => { entity.events.watchPaymentCancelled(() => {}); }); @@ -503,6 +522,7 @@ describe("PaymentTreasury entity", () => { it("watchExpiredFundsClaimed", () => { entity.events.watchExpiredFundsClaimed(() => {}); }); it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); + it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); it("decodeLog decodes a PaymentCancelled event", () => { const sig = keccak256(toHex("PaymentCancelled(bytes32)")); const result = entity.events.decodeLog({ topics: [sig, B32], data: "0x" as `0x${string}` }); @@ -605,6 +625,7 @@ describe("AllOrNothing entity", () => { it("getRewardRemovedLogs", async () => { await entity.events.getRewardRemovedLogs(); }); it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); + it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); it("getTransferLogs", async () => { await entity.events.getTransferLogs(); }); it("getSuccessConditionNotFulfilledLogs", async () => { await entity.events.getSuccessConditionNotFulfilledLogs(); }); it("getApprovalLogs", async () => { await entity.events.getApprovalLogs(); }); @@ -617,6 +638,7 @@ describe("AllOrNothing entity", () => { it("watchRewardRemoved", () => { entity.events.watchRewardRemoved(() => {}); }); it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); + it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); it("watchTransfer", () => { entity.events.watchTransfer(() => {}); }); it("watchSuccessConditionNotFulfilled", () => { entity.events.watchSuccessConditionNotFulfilled(() => {}); }); it("watchApproval", () => { entity.events.watchApproval(() => {}); }); @@ -765,6 +787,7 @@ describe("KeepWhatsRaised entity", () => { it("getPaymentGatewayFeeSetLogs", async () => { await entity.events.getPaymentGatewayFeeSetLogs(); }); it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); + it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); it("getTransferLogs", async () => { await entity.events.getTransferLogs(); }); it("getApprovalLogs", async () => { await entity.events.getApprovalLogs(); }); it("getApprovalForAllLogs", async () => { await entity.events.getApprovalForAllLogs(); }); @@ -783,6 +806,7 @@ describe("KeepWhatsRaised entity", () => { it("watchPaymentGatewayFeeSet", () => { entity.events.watchPaymentGatewayFeeSet(() => {}); }); it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); + it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); it("watchTransfer", () => { entity.events.watchTransfer(() => {}); }); it("watchApproval", () => { entity.events.watchApproval(() => {}); }); it("watchApprovalForAll", () => { entity.events.watchApprovalForAll(() => {}); }); diff --git a/packages/contracts/__tests__/unit/error-parsing.test.ts b/packages/contracts/__tests__/unit/error-parsing.test.ts index e7735978..6696a08b 100644 --- a/packages/contracts/__tests__/unit/error-parsing.test.ts +++ b/packages/contracts/__tests__/unit/error-parsing.test.ts @@ -102,6 +102,41 @@ describe("toSharedContractError", () => { expect(e!.name).toBe("TreasuryTransferFailed"); }); + it("maps PausedError", () => { + const e = toSharedContractError("PausedError", {}); + expect(e!.name).toBe("PausedError"); + }); + + it("maps NotPausedError", () => { + const e = toSharedContractError("NotPausedError", {}); + expect(e!.name).toBe("NotPausedError"); + }); + + it("maps CancelledError", () => { + const e = toSharedContractError("CancelledError", {}); + expect(e!.name).toBe("CancelledError"); + }); + + it("maps NotCancelledError", () => { + const e = toSharedContractError("NotCancelledError", {}); + expect(e!.name).toBe("NotCancelledError"); + }); + + it("maps CannotCancel", () => { + const e = toSharedContractError("CannotCancel", {}); + expect(e!.name).toBe("CannotCancel"); + }); + + it("maps PledgeNFTUnAuthorized", () => { + const e = toSharedContractError("PledgeNFTUnAuthorized", {}); + expect(e!.name).toBe("PledgeNFTUnAuthorized"); + }); + + it("maps PledgeNFTInvalidJsonString", () => { + const e = toSharedContractError("PledgeNFTInvalidJsonString", {}); + expect(e!.name).toBe("PledgeNFTInvalidJsonString"); + }); + it("returns null for unknown error names", () => { expect(toSharedContractError("SomethingElse", {})).toBeNull(); }); diff --git a/packages/contracts/__tests__/unit/errors.test.ts b/packages/contracts/__tests__/unit/errors.test.ts index f5ea9fdd..bdf9b179 100644 --- a/packages/contracts/__tests__/unit/errors.test.ts +++ b/packages/contracts/__tests__/unit/errors.test.ts @@ -100,9 +100,16 @@ import { import { AccessCheckerUnauthorizedError, AdminAccessCheckerUnauthorizedError, + CannotCancelError, + CancelledErrorError, CurrentTimeIsGreaterError, CurrentTimeIsLessError, CurrentTimeIsNotWithinRangeError, + NotCancelledErrorError, + NotPausedErrorError, + PausedErrorError, + PledgeNFTInvalidJsonStringError, + PledgeNFTUnAuthorizedError, TreasuryCampaignInfoIsPausedError, TreasuryFeeNotDisbursedError, TreasuryTransferFailedError, @@ -166,6 +173,41 @@ describe("Shared errors", () => { const e = new TreasuryTransferFailedError(); assertError(e, "TreasuryTransferFailed"); }); + + it("PausedErrorError", () => { + const e = new PausedErrorError(); + assertError(e, "PausedError"); + }); + + it("NotPausedErrorError", () => { + const e = new NotPausedErrorError(); + assertError(e, "NotPausedError"); + }); + + it("CancelledErrorError", () => { + const e = new CancelledErrorError(); + assertError(e, "CancelledError"); + }); + + it("NotCancelledErrorError", () => { + const e = new NotCancelledErrorError(); + assertError(e, "NotCancelledError"); + }); + + it("CannotCancelError", () => { + const e = new CannotCancelError(); + assertError(e, "CannotCancel"); + }); + + it("PledgeNFTUnAuthorizedError", () => { + const e = new PledgeNFTUnAuthorizedError(); + assertError(e, "PledgeNFTUnAuthorized"); + }); + + it("PledgeNFTInvalidJsonStringError", () => { + const e = new PledgeNFTInvalidJsonStringError(); + assertError(e, "PledgeNFTInvalidJsonString"); + }); }); describe("GlobalParams errors", () => { From c97cd7c4949571d2c5ea46992fbd152e5e1314d7 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 15:33:06 +0600 Subject: [PATCH 38/86] refactor: enhance error handling in global params, item registry, and payment treasury - Update error mapping functions to utilize a shared contract error handler for improved consistency and maintainability. - Implement fallback logic to handle unrecognized error names more gracefully across multiple error parsing files. --- .../src/errors/parse/global-params.ts | 19 ++++++++++++------- .../src/errors/parse/item-registry.ts | 19 ++++++++++++------- .../src/errors/parse/payment-treasury.ts | 19 ++++++++++++------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/contracts/src/errors/parse/global-params.ts b/packages/contracts/src/errors/parse/global-params.ts index f07cad8c..ec98891b 100644 --- a/packages/contracts/src/errors/parse/global-params.ts +++ b/packages/contracts/src/errors/parse/global-params.ts @@ -18,7 +18,7 @@ import { GlobalParamsUnauthorizedError, } from "../contracts/global-params"; import type { ErrorAbiEntry } from "./shared"; -import { tryDecodeContractError } from "./shared"; +import { toSharedContractError, tryDecodeContractError } from "./shared"; /** * Maps a decoded GlobalParams error name and args to a typed SDK error instance. @@ -70,12 +70,17 @@ function toGlobalParamsError(name: string, args: Record): Contr platformHash: args["platformHash"] as string, typeId: args["typeId"] as string, }); - /* istanbul ignore next -- defensive fallback; all ABI errors are handled above */ - default: - return new (class extends Error implements ContractErrorBase { - readonly name = name; - readonly args = args; - })(`${name}(${JSON.stringify(args)})`); + /* istanbul ignore next -- defensive fallback; GlobalParams ABI has no shared error selectors */ + default: { + const shared = toSharedContractError(name, args); + if (!shared) { + return new (class extends Error implements ContractErrorBase { + readonly name = name; + readonly args = args; + })(`${name}(${JSON.stringify(args)})`); + } + return shared; + } } } diff --git a/packages/contracts/src/errors/parse/item-registry.ts b/packages/contracts/src/errors/parse/item-registry.ts index 90d4fe13..76f141a6 100644 --- a/packages/contracts/src/errors/parse/item-registry.ts +++ b/packages/contracts/src/errors/parse/item-registry.ts @@ -6,7 +6,7 @@ import { ItemRegistryMismatchedArraysLengthError, } from "../contracts/item-registry"; import type { ErrorAbiEntry } from "./shared"; -import { tryDecodeContractError } from "./shared"; +import { toSharedContractError, tryDecodeContractError } from "./shared"; /** * Maps a decoded ItemRegistry error name and args to a typed SDK error instance. @@ -18,12 +18,17 @@ function toItemRegistryError(name: string, args: Record): Contr switch (name) { case ItemRegistryErrorNames.MismatchedArraysLength: return new ItemRegistryMismatchedArraysLengthError(); - /* istanbul ignore next -- defensive fallback; all ABI errors are handled above */ - default: - return new (class extends Error implements ContractErrorBase { - readonly name = name; - readonly args = args; - })(`${name}(${JSON.stringify(args)})`); + /* istanbul ignore next -- defensive fallback; ItemRegistry ABI has no shared error selectors */ + default: { + const shared = toSharedContractError(name, args); + if (!shared) { + return new (class extends Error implements ContractErrorBase { + readonly name = name; + readonly args = args; + })(`${name}(${JSON.stringify(args)})`); + } + return shared; + } } } diff --git a/packages/contracts/src/errors/parse/payment-treasury.ts b/packages/contracts/src/errors/parse/payment-treasury.ts index 137036a6..45835f5e 100644 --- a/packages/contracts/src/errors/parse/payment-treasury.ts +++ b/packages/contracts/src/errors/parse/payment-treasury.ts @@ -24,7 +24,7 @@ import { PaymentTreasuryUnAuthorizedError, } from "../contracts/payment-treasury"; import type { ErrorAbiEntry } from "./shared"; -import { tryDecodeContractError } from "./shared"; +import { toSharedContractError, tryDecodeContractError } from "./shared"; /** * Maps a decoded PaymentTreasury error name and args to a typed SDK error instance. @@ -97,12 +97,17 @@ function toPaymentTreasuryError(name: string, args: Record): Co }); case PaymentTreasuryErrorNames.NoFundsToClaim: return new PaymentTreasuryNoFundsToClaimError(); - /* istanbul ignore next -- defensive fallback; all ABI errors are handled above */ - default: - return new (class extends Error implements ContractErrorBase { - readonly name = name; - readonly args = args; - })(`${name}(${JSON.stringify(args)})`); + default: { + const shared = toSharedContractError(name, args); + /* istanbul ignore next -- defensive fallback; all shared errors are recognised */ + if (!shared) { + return new (class extends Error implements ContractErrorBase { + readonly name = name; + readonly args = args; + })(`${name}(${JSON.stringify(args)})`); + } + return shared; + } } } From 6c014dd0f2ce9ce619bfd3fabce58035f9d6af2c Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 15:33:18 +0600 Subject: [PATCH 39/86] test: add unit tests for additional error cases in parsePaymentTreasuryError - Introduce tests for handling PausedError, CancelledError, and CannotCancel in the parsePaymentTreasuryError function. - Ensure that the function correctly falls through to shared error handling for these cases, enhancing overall error coverage. --- .../__tests__/unit/error-parsing.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/contracts/__tests__/unit/error-parsing.test.ts b/packages/contracts/__tests__/unit/error-parsing.test.ts index 6696a08b..22c12360 100644 --- a/packages/contracts/__tests__/unit/error-parsing.test.ts +++ b/packages/contracts/__tests__/unit/error-parsing.test.ts @@ -676,6 +676,27 @@ describe("parsePaymentTreasuryError", () => { expect(parsePaymentTreasuryError(data)!.name).toBe("PaymentTreasuryClaimWindowNotReached"); }); + it("falls through to shared error for PausedError", () => { + const data = encode("PausedError"); + const err = parsePaymentTreasuryError(data); + expect(err).not.toBeNull(); + expect(err!.name).toBe("PausedError"); + }); + + it("falls through to shared error for CancelledError", () => { + const data = encode("CancelledError"); + const err = parsePaymentTreasuryError(data); + expect(err).not.toBeNull(); + expect(err!.name).toBe("CancelledError"); + }); + + it("falls through to shared error for CannotCancel", () => { + const data = encode("CannotCancel"); + const err = parsePaymentTreasuryError(data); + expect(err).not.toBeNull(); + expect(err!.name).toBe("CannotCancel"); + }); + it("returns null for unrecognized data", () => { expect(parsePaymentTreasuryError("0x12345678")).toBeNull(); }); From 50e1d9b2cf7351dfa89cc61ee6ed465861d23ee0 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 16:24:59 +0600 Subject: [PATCH 40/86] refactor: update event handling in treasury deployment examples - Replace hardcoded event name "TreasuryFactoryTreasuryDeployed" with the constant TREASURY_FACTORY_EVENTS.TreasuryDeployed in both all-or-nothing and keep-whats-raised treasury deployment scripts. - Enhance code maintainability and clarity by utilizing centralized event constants. --- .../examples/01-campaign-all-or-nothing/04-deploy-treasury.ts | 4 ++-- .../02-campaign-keep-whats-raised/02-deploy-treasury.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts index 8ac45d18..1969ef86 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts @@ -10,7 +10,7 @@ * Maya reads this event to discover the address of her new treasury. */ -import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS, TREASURY_FACTORY_EVENTS } from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -47,7 +47,7 @@ for (const log of deployReceipt.logs) { data: log.data as `0x${string}`, }); - if (decoded.eventName === "TreasuryFactoryTreasuryDeployed") { + if (decoded.eventName === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; break; } diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts index d21052c6..43714ca6 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts @@ -10,7 +10,7 @@ * discover the treasury contract address. */ -import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS, TREASURY_FACTORY_EVENTS } from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -46,7 +46,7 @@ for (const log of deployReceipt.logs) { data: log.data as `0x${string}`, }); - if (decoded.eventName === "TreasuryFactoryTreasuryDeployed") { + if (decoded.eventName === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; break; } From 3c66e74aaa35a36ee373c4d3effdf267c7e8700b Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 10 Apr 2026 16:25:23 +0600 Subject: [PATCH 41/86] refactor: enhance error handling in catch-typed-errors example - Update error handling patterns to include matching by error name using type-safe constants. - Introduce a fallback mechanism for unknown contract errors, improving clarity and maintainability of error messages. - Expand documentation to reflect the new error handling patterns. --- .../03-catch-typed-errors.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts index 72537ebd..e9a21871 100644 --- a/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts +++ b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts @@ -4,9 +4,10 @@ * When a transaction reverts, the SDK decodes the raw revert data * into a typed error class with a human-readable recovery hint. * - * Two patterns are shown: + * Three patterns are shown: * 1. Check for specific error types with instanceof - * 2. Parse unknown revert data with parseContractError + * 2. Match by error name using type-safe constants + * 3. Parse unknown revert data with parseContractError */ import { @@ -20,6 +21,8 @@ import { import { CampaignInfoUnauthorizedError, + CampaignInfoErrorNames, + SharedErrorNames, } from "@oaknetwork/contracts-sdk/errors"; const oak = createOakContractsClient({ @@ -33,25 +36,37 @@ const campaign = oak.campaignInfo(process.env.CAMPAIGN_INFO_ADDRESS! as `0x${str try { await campaign.cancelCampaign(toHex("cancelled by user", { size: 32 })); } catch (error) { - // Pattern 1: Check for specific error types + // Pattern 1: Check for specific error types with instanceof if (error instanceof CampaignInfoUnauthorizedError) { console.error("You are not the campaign owner."); console.error("Hint:", error.recoveryHint); } else { - // Pattern 2: Parse unknown revert data + // Pattern 2: Parse revert data and match by error name constant const revertData = getRevertData(error); const parsed = revertData ? parseContractError(revertData) : null; if (parsed) { - console.error(`Contract error: ${parsed.name}`); - console.error("Arguments:", parsed.args); + switch (parsed.name) { + case CampaignInfoErrorNames.IsLocked: + console.error("Campaign is locked — no further modifications allowed."); + break; + case SharedErrorNames.PausedError: + console.error("Campaign is currently paused. Wait for it to be unpaused."); + break; + case SharedErrorNames.CancelledError: + console.error("Campaign has already been cancelled."); + break; + default: + // Pattern 3: Generic fallback for any other contract error + console.error(`Contract error: ${parsed.name}`); + console.error("Arguments:", parsed.args); + } const hint = getRecoveryHint(parsed); if (hint) { console.error("Recovery hint:", hint); } } else { - // Unknown error — only reached when the error is not a typed contract revert console.error("Unknown error:", (error as Error).message); } } From 9b9c37445b6c0293bcdd52d16dd48e545e20c49d Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 14:26:34 +0600 Subject: [PATCH 42/86] fix: improve error handling in createOakContractsClient function - Update the error handling logic to specifically catch TransactionReceiptNotFoundError and return null, while rethrowing other unexpected errors for better debugging and maintainability. --- packages/contracts/src/client/create.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/client/create.ts b/packages/contracts/src/client/create.ts index 1b34ee58..0607006b 100644 --- a/packages/contracts/src/client/create.ts +++ b/packages/contracts/src/client/create.ts @@ -69,8 +69,11 @@ export function createOakContractsClient( data: log.data, })), }; - } catch { - return null; + } catch (error: unknown) { + if (error instanceof Error && error.name === "TransactionReceiptNotFoundError") { + return null; + } + throw error; } } From 78f4e0ab643c9e18dd67c95be77afc12d0ac9946 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 14:26:53 +0600 Subject: [PATCH 43/86] test: enhance unit tests for getReceipt error handling in createOakContractsClient - Add a test to verify that non-receipt errors, such as network failures, are correctly re-thrown by the getReceipt method. - Update the existing test to use a specific TransactionReceiptNotFoundError for improved clarity in error handling. --- .../contracts/__tests__/unit/client.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/contracts/__tests__/unit/client.test.ts b/packages/contracts/__tests__/unit/client.test.ts index e3e864b2..81d6cb96 100644 --- a/packages/contracts/__tests__/unit/client.test.ts +++ b/packages/contracts/__tests__/unit/client.test.ts @@ -165,14 +165,30 @@ describe("createOakContractsClient", () => { rpcUrl: RPC, privateKey: PK, }); + const notFoundError = new Error("Transaction receipt not found"); + notFoundError.name = "TransactionReceiptNotFoundError"; ( client.publicClient as unknown as { getTransactionReceipt: jest.Mock } - ).getTransactionReceipt = jest.fn().mockRejectedValue(new Error("not found")); + ).getTransactionReceipt = jest.fn().mockRejectedValue(notFoundError); const receipt = await client.getReceipt("0xdeadbeef"); expect(receipt).toBeNull(); }); + it("getReceipt re-throws non-receipt errors (e.g. network failures)", async () => { + const client = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: RPC, + privateKey: PK, + }); + const networkError = new Error("RPC timeout"); + ( + client.publicClient as unknown as { getTransactionReceipt: jest.Mock } + ).getTransactionReceipt = jest.fn().mockRejectedValue(networkError); + + await expect(client.getReceipt("0xdeadbeef")).rejects.toThrow("RPC timeout"); + }); + it("waitForReceipt calls publicClient.waitForTransactionReceipt", async () => { const client = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, From 6a51433945d51fb1e530fd0855b4af493db63266 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 14:32:23 +0600 Subject: [PATCH 44/86] refactor: update toSimulationResult function to use ViemSimulateRequest interface - Modify the toSimulationResult function to accept a more structured ViemSimulateRequest type for improved clarity and type safety. - Simplify the extraction of request parameters by destructuring the ViemSimulateRequest interface. --- .../src/errors/parse-contract-error.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/contracts/src/errors/parse-contract-error.ts b/packages/contracts/src/errors/parse-contract-error.ts index 246d8c09..63702c55 100644 --- a/packages/contracts/src/errors/parse-contract-error.ts +++ b/packages/contracts/src/errors/parse-contract-error.ts @@ -96,6 +96,16 @@ export async function simulateWithErrorDecode(operation: () => Prom } } +/** Shape of the `request` object returned by viem's `simulateContract`. */ +interface ViemSimulateRequest { + address: Address; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + value?: bigint; + gas?: bigint; +} + /** * Converts the raw viem simulateContract response into the SDK's SimulationResult shape. * @@ -106,11 +116,8 @@ export async function simulateWithErrorDecode(operation: () => Prom * @param response - Raw response from publicClient.simulateContract * @returns SimulationResult with the contract return value and prepared transaction params */ -export function toSimulationResult(response: { result: T; request: Record }): SimulationResult { - const req = response.request; - const abi = req["abi"] as readonly unknown[]; - const functionName = req["functionName"] as string; - const args = req["args"] as readonly unknown[] | undefined; +export function toSimulationResult(response: { result: T; request: ViemSimulateRequest }): SimulationResult { + const { address, abi, functionName, args, value, gas } = response.request; const data = encodeFunctionData({ abi, @@ -121,10 +128,10 @@ export function toSimulationResult(response: { result: T; request: Record Date: Mon, 13 Apr 2026 14:32:33 +0600 Subject: [PATCH 45/86] test: update error-parsing unit tests to use type assertions for address and args - Modify the unit tests for the toSimulationResult function to include type assertions for the address and args properties, enhancing type safety and clarity in the test cases. --- packages/contracts/__tests__/unit/error-parsing.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts/__tests__/unit/error-parsing.test.ts b/packages/contracts/__tests__/unit/error-parsing.test.ts index 22c12360..b5150ba3 100644 --- a/packages/contracts/__tests__/unit/error-parsing.test.ts +++ b/packages/contracts/__tests__/unit/error-parsing.test.ts @@ -224,10 +224,10 @@ describe("toSimulationResult", () => { const response = { result: true, request: { - address: "0x0000000000000000000000000000000000000001", + address: "0x0000000000000000000000000000000000000001" as `0x${string}`, abi: TEST_ABI, functionName: "transfer", - args: ["0x0000000000000000000000000000000000000002", 100n], + args: ["0x0000000000000000000000000000000000000002", 100n] as const, value: 0n, gas: 21000n, }, @@ -245,10 +245,10 @@ describe("toSimulationResult", () => { const response = { result: undefined, request: { - address: "0x0000000000000000000000000000000000000001", + address: "0x0000000000000000000000000000000000000001" as `0x${string}`, abi: TEST_ABI, functionName: "transfer", - args: ["0x0000000000000000000000000000000000000002", 1n], + args: ["0x0000000000000000000000000000000000000002", 1n] as const, }, }; const mapped = toSimulationResult(response); From d8c3c7f12448b0eda9a0eaf1ccb92972fda31475 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 14:43:32 +0600 Subject: [PATCH 46/86] test: refactor and add unit tests for prepareContractWrite and toPreparedTransaction - Move tests for prepareContractWrite and toPreparedTransaction from metrics.test.ts to a new prepare.test.ts file for better organization. - Ensure tests cover encoding calldata, gas estimation, and value handling for contract write operations. - Validate extraction of PreparedTransaction from SimulationResult with appropriate assertions. --- .../contracts/__tests__/unit/metrics.test.ts | 99 +------------------ .../contracts/__tests__/unit/prepare.test.ts | 97 ++++++++++++++++++ 2 files changed, 98 insertions(+), 98 deletions(-) create mode 100644 packages/contracts/__tests__/unit/prepare.test.ts diff --git a/packages/contracts/__tests__/unit/metrics.test.ts b/packages/contracts/__tests__/unit/metrics.test.ts index 79207e7a..df7eb585 100644 --- a/packages/contracts/__tests__/unit/metrics.test.ts +++ b/packages/contracts/__tests__/unit/metrics.test.ts @@ -1,11 +1,9 @@ -import type { Address, PublicClient, Chain } from "../../src/lib"; +import type { Address, PublicClient } from "../../src/lib"; import { getPlatformStats } from "../../src/metrics/platform"; import { getCampaignSummary } from "../../src/metrics/campaign"; import { getTreasuryReport } from "../../src/metrics/treasury"; import { multicall } from "../../src/utils/multicall"; -import { prepareContractWrite, toPreparedTransaction } from "../../src/utils/prepare"; import type { TreasuryType } from "../../src/metrics/types"; -import type { SimulationResult } from "../../src/types/events"; const ADDR = "0x0000000000000000000000000000000000000001" as Address; @@ -207,98 +205,3 @@ describe("getTreasuryReport", () => { }); }); -// ────────────────────────────────────────────────────────────────────────────── -// prepareContractWrite + toPreparedTransaction -// ────────────────────────────────────────────────────────────────────────────── - -const TEST_ABI = [ - { - type: "function" as const, - name: "transfer", - stateMutability: "nonpayable" as const, - inputs: [ - { name: "to", type: "address" }, - { name: "amount", type: "uint256" }, - ], - outputs: [{ name: "", type: "bool" }], - }, -] as const; - -describe("prepareContractWrite", () => { - it("encodes calldata and estimates gas", async () => { - const pub = { - estimateContractGas: jest.fn().mockResolvedValue(50000n), - } as unknown as PublicClient; - - const mockChain = { id: 1, name: "test" } as Chain; - - const result = await prepareContractWrite(pub, { - address: ADDR, - abi: TEST_ABI, - functionName: "transfer", - args: [ADDR, 100n], - account: ADDR, - chain: mockChain, - }); - - expect(result.to).toBe(ADDR); - expect(result.data).toMatch(/^0x/); - expect(result.value).toBe(0n); - expect(result.gas).toBe(50000n); - expect(pub.estimateContractGas).toHaveBeenCalled(); - }); - - it("passes value through when provided", async () => { - const pub = { - estimateContractGas: jest.fn().mockResolvedValue(21000n), - } as unknown as PublicClient; - - const mockChain = { id: 1, name: "test" } as Chain; - - const result = await prepareContractWrite(pub, { - address: ADDR, - abi: TEST_ABI, - functionName: "transfer", - args: [ADDR, 100n], - account: ADDR, - chain: mockChain, - value: 500n, - }); - - expect(result.value).toBe(500n); - }); -}); - -describe("toPreparedTransaction", () => { - it("extracts PreparedTransaction from SimulationResult", () => { - const simResult: SimulationResult = { - result: undefined, - request: { - to: ADDR, - data: "0xdeadbeef" as `0x${string}`, - value: 100n, - gas: 21000n, - }, - }; - - const prepared = toPreparedTransaction(simResult); - expect(prepared.to).toBe(ADDR); - expect(prepared.data).toBe("0xdeadbeef"); - expect(prepared.value).toBe(100n); - expect(prepared.gas).toBe(21000n); - }); - - it("defaults value to 0n and preserves undefined gas", () => { - const simResult: SimulationResult = { - result: undefined, - request: { - to: ADDR, - data: "0x00" as `0x${string}`, - }, - }; - - const prepared = toPreparedTransaction(simResult); - expect(prepared.value).toBe(0n); - expect(prepared.gas).toBeUndefined(); - }); -}); diff --git a/packages/contracts/__tests__/unit/prepare.test.ts b/packages/contracts/__tests__/unit/prepare.test.ts new file mode 100644 index 00000000..022d7241 --- /dev/null +++ b/packages/contracts/__tests__/unit/prepare.test.ts @@ -0,0 +1,97 @@ +import type { Address, PublicClient, Chain } from "../../src/lib"; +import { prepareContractWrite, toPreparedTransaction } from "../../src/utils/prepare"; +import type { SimulationResult } from "../../src/types/events"; + +const ADDR = "0x0000000000000000000000000000000000000001" as Address; + +const TEST_ABI = [ + { + type: "function" as const, + name: "transfer", + stateMutability: "nonpayable" as const, + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + +describe("prepareContractWrite", () => { + it("encodes calldata and estimates gas", async () => { + const pub = { + estimateContractGas: jest.fn().mockResolvedValue(50000n), + } as unknown as PublicClient; + + const mockChain = { id: 1, name: "test" } as Chain; + + const result = await prepareContractWrite(pub, { + address: ADDR, + abi: TEST_ABI, + functionName: "transfer", + args: [ADDR, 100n], + account: ADDR, + chain: mockChain, + }); + + expect(result.to).toBe(ADDR); + expect(result.data).toMatch(/^0x/); + expect(result.value).toBe(0n); + expect(result.gas).toBe(50000n); + expect(pub.estimateContractGas).toHaveBeenCalled(); + }); + + it("passes value through when provided", async () => { + const pub = { + estimateContractGas: jest.fn().mockResolvedValue(21000n), + } as unknown as PublicClient; + + const mockChain = { id: 1, name: "test" } as Chain; + + const result = await prepareContractWrite(pub, { + address: ADDR, + abi: TEST_ABI, + functionName: "transfer", + args: [ADDR, 100n], + account: ADDR, + chain: mockChain, + value: 500n, + }); + + expect(result.value).toBe(500n); + }); +}); + +describe("toPreparedTransaction", () => { + it("extracts PreparedTransaction from SimulationResult", () => { + const simResult: SimulationResult = { + result: undefined, + request: { + to: ADDR, + data: "0xdeadbeef" as `0x${string}`, + value: 100n, + gas: 21000n, + }, + }; + + const prepared = toPreparedTransaction(simResult); + expect(prepared.to).toBe(ADDR); + expect(prepared.data).toBe("0xdeadbeef"); + expect(prepared.value).toBe(100n); + expect(prepared.gas).toBe(21000n); + }); + + it("defaults value to 0n and preserves undefined gas", () => { + const simResult: SimulationResult = { + result: undefined, + request: { + to: ADDR, + data: "0x00" as `0x${string}`, + }, + }; + + const prepared = toPreparedTransaction(simResult); + expect(prepared.value).toBe(0n); + expect(prepared.gas).toBeUndefined(); + }); +}); From 6ee94ca77e3f400b002355b42daee558d7d70db4 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 14:49:12 +0600 Subject: [PATCH 47/86] refactor: enhance type safety for contract write options in prepare.ts - Introduce a new type, AbiWriteFunctionName, to extract function names from typed ABI arrays, improving type safety for the functionName property in PrepareWriteOptions. - Update the functionName property to ensure it matches a function in the provided ABI, enhancing clarity and reducing potential errors during contract interactions. --- packages/contracts/src/utils/prepare.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/utils/prepare.ts b/packages/contracts/src/utils/prepare.ts index fb65f3bb..e16d5f3b 100644 --- a/packages/contracts/src/utils/prepare.ts +++ b/packages/contracts/src/utils/prepare.ts @@ -2,6 +2,12 @@ import type { Address, Hex, PublicClient, Chain } from "../lib"; import { encodeFunctionData } from "../lib"; import type { SimulationResult } from "../types/events"; +/** Extracts function names from a typed ABI array; falls back to `string` for untyped ABIs. */ +type AbiWriteFunctionName = + Extract extends never + ? string + : Extract["name"]; + /** * Options for preparing a contract write transaction. * @@ -12,8 +18,8 @@ export interface PrepareWriteOptions; /** Arguments to pass to the contract function. */ args?: readonly unknown[]; /** Native token value to send (wei). Defaults to 0. */ From 3e8c2c7285d62bf82a8ff82882bb31d5791e6d01 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 14:51:58 +0600 Subject: [PATCH 48/86] test: add unit test for gas estimation error handling in prepareContractWrite - Introduce a new test case to verify that an error is correctly propagated when gas estimation fails due to insufficient balance. - Enhance the robustness of the prepareContractWrite function by ensuring it handles rejection scenarios appropriately. --- .../contracts/__tests__/unit/prepare.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/contracts/__tests__/unit/prepare.test.ts b/packages/contracts/__tests__/unit/prepare.test.ts index 022d7241..fd22da69 100644 --- a/packages/contracts/__tests__/unit/prepare.test.ts +++ b/packages/contracts/__tests__/unit/prepare.test.ts @@ -41,6 +41,26 @@ describe("prepareContractWrite", () => { expect(pub.estimateContractGas).toHaveBeenCalled(); }); + it("propagates error when gas estimation fails", async () => { + const revertError = new Error("execution reverted: insufficient balance"); + const pub = { + estimateContractGas: jest.fn().mockRejectedValue(revertError), + } as unknown as PublicClient; + + const mockChain = { id: 1, name: "test" } as Chain; + + await expect( + prepareContractWrite(pub, { + address: ADDR, + abi: TEST_ABI, + functionName: "transfer", + args: [ADDR, 100n], + account: ADDR, + chain: mockChain, + }), + ).rejects.toThrow("execution reverted: insufficient balance"); + }); + it("passes value through when provided", async () => { const pub = { estimateContractGas: jest.fn().mockResolvedValue(21000n), From 2d61dc65db4aec6c345453f1d98a9c79830827a9 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 14:59:57 +0600 Subject: [PATCH 49/86] refactor: update interfaceId type to Bytes4 for supportsInterface methods - Change the type of interfaceId in supportsInterface methods across AllOrNothing, CampaignInfo, and KeepWhatsRaised contracts to Bytes4 for improved type safety. - Introduce Bytes4 type definition to ensure only valid 4-byte hex strings are used for ERC-165 interface IDs. - Update related utility functions to validate Bytes4 type, enhancing overall type consistency in contract interactions. --- .../src/contracts/all-or-nothing/reads.ts | 4 ++-- .../src/contracts/all-or-nothing/types.ts | 4 ++-- .../src/contracts/campaign-info/reads.ts | 4 ++-- .../src/contracts/campaign-info/types.ts | 4 ++-- .../src/contracts/keep-whats-raised/reads.ts | 4 ++-- .../src/contracts/keep-whats-raised/types.ts | 4 ++-- packages/contracts/src/types/structs.ts | 10 +++++++++ packages/contracts/src/utils/hex.ts | 21 +++++++++++++++++++ packages/contracts/src/utils/index.ts | 2 +- 9 files changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/contracts/src/contracts/all-or-nothing/reads.ts b/packages/contracts/src/contracts/all-or-nothing/reads.ts index 9e7acfa8..71c4926d 100644 --- a/packages/contracts/src/contracts/all-or-nothing/reads.ts +++ b/packages/contracts/src/contracts/all-or-nothing/reads.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient } from "../../lib"; import { ALL_OR_NOTHING_ABI } from "./abi"; import type { AllOrNothingReads } from "./types"; -import type { TieredReward } from "../../types/structs"; +import type { Bytes4, TieredReward } from "../../types/structs"; /** * Builds read methods for an AllOrNothing treasury contract instance. @@ -62,7 +62,7 @@ export function createAllOrNothingReads( async isApprovedForAll(owner: Address, operator: Address) { return publicClient.readContract({ ...contract, functionName: "isApprovedForAll", args: [owner, operator] }); }, - async supportsInterface(interfaceId: Hex) { + async supportsInterface(interfaceId: Bytes4) { return publicClient.readContract({ ...contract, functionName: "supportsInterface", args: [interfaceId] }); }, }; diff --git a/packages/contracts/src/contracts/all-or-nothing/types.ts b/packages/contracts/src/contracts/all-or-nothing/types.ts index 58670b9e..c04e9643 100644 --- a/packages/contracts/src/contracts/all-or-nothing/types.ts +++ b/packages/contracts/src/contracts/all-or-nothing/types.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from "../../lib"; -import type { TieredReward } from "../../types/structs"; +import type { Bytes4, TieredReward } from "../../types/structs"; import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; @@ -36,7 +36,7 @@ export interface AllOrNothingReads { /** Returns true if operator is approved for all tokens of owner. */ isApprovedForAll(owner: Address, operator: Address): Promise; /** Returns true if the contract implements the given ERC-165 interface. */ - supportsInterface(interfaceId: Hex): Promise; + supportsInterface(interfaceId: Bytes4): Promise; } /** Write methods for an AllOrNothing treasury contract instance. */ diff --git a/packages/contracts/src/contracts/campaign-info/reads.ts b/packages/contracts/src/contracts/campaign-info/reads.ts index c9d5f0c5..1f934074 100644 --- a/packages/contracts/src/contracts/campaign-info/reads.ts +++ b/packages/contracts/src/contracts/campaign-info/reads.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient } from "../../lib"; import { CAMPAIGN_INFO_ABI } from "./abi"; import type { CampaignInfoReads } from "./types"; -import type { LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; +import type { Bytes4, LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; /** * Builds read methods for a CampaignInfo contract instance. @@ -136,7 +136,7 @@ export function createCampaignInfoReads( async balanceOf(owner: Address) { return publicClient.readContract({ ...contract, functionName: "balanceOf", args: [owner] }); }, - async supportsInterface(interfaceId: Hex) { + async supportsInterface(interfaceId: Bytes4) { return publicClient.readContract({ ...contract, functionName: "supportsInterface", args: [interfaceId] }); }, }; diff --git a/packages/contracts/src/contracts/campaign-info/types.ts b/packages/contracts/src/contracts/campaign-info/types.ts index 7f0c7d08..4f459974 100644 --- a/packages/contracts/src/contracts/campaign-info/types.ts +++ b/packages/contracts/src/contracts/campaign-info/types.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from "../../lib"; -import type { LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; +import type { Bytes4, LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; @@ -84,7 +84,7 @@ export interface CampaignInfoReads { /** Returns the number of tokens held by an owner. */ balanceOf(owner: Address): Promise; /** Returns true if the contract supports the given ERC-165 interface ID. */ - supportsInterface(interfaceId: Hex): Promise; + supportsInterface(interfaceId: Bytes4): Promise; } /** Write methods for a CampaignInfo contract instance. */ diff --git a/packages/contracts/src/contracts/keep-whats-raised/reads.ts b/packages/contracts/src/contracts/keep-whats-raised/reads.ts index 34e97bb5..0f326912 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/reads.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/reads.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient } from "../../lib"; import { KEEP_WHATS_RAISED_ABI } from "./abi"; import type { KeepWhatsRaisedReads } from "./types"; -import type { TieredReward } from "../../types/structs"; +import type { Bytes4, TieredReward } from "../../types/structs"; /** * Builds read methods for a KeepWhatsRaised treasury contract instance. @@ -115,7 +115,7 @@ export function createKeepWhatsRaisedReads( args: [owner, operator], }); }, - async supportsInterface(interfaceId: Hex) { + async supportsInterface(interfaceId: Bytes4) { return publicClient.readContract({ ...contract, functionName: "supportsInterface", diff --git a/packages/contracts/src/contracts/keep-whats-raised/types.ts b/packages/contracts/src/contracts/keep-whats-raised/types.ts index 84929305..8d215022 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/types.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/types.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from "../../lib"; -import type { TieredReward, CampaignData } from "../../types/structs"; +import type { Bytes4, TieredReward, CampaignData } from "../../types/structs"; import type { KeepWhatsRaisedConfig, KeepWhatsRaisedFeeKeys, KeepWhatsRaisedFeeValues } from "../../types/params"; import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; @@ -51,7 +51,7 @@ export interface KeepWhatsRaisedReads { /** Returns true if the operator is approved to manage all tokens of the given owner. */ isApprovedForAll(owner: Address, operator: Address): Promise; /** Returns true if the contract implements the given ERC-165 interface ID. */ - supportsInterface(interfaceId: Hex): Promise; + supportsInterface(interfaceId: Bytes4): Promise; } /** Write methods for KeepWhatsRaised treasury. */ diff --git a/packages/contracts/src/types/structs.ts b/packages/contracts/src/types/structs.ts index 9b8ff643..d7d7babc 100644 --- a/packages/contracts/src/types/structs.ts +++ b/packages/contracts/src/types/structs.ts @@ -1,5 +1,15 @@ import type { Address, Hex } from "../lib"; +/** + * A 4-byte hex string (`0x` + 8 hex chars), e.g. `"0x01ffc9a7"`. + * Used for ERC-165 interface IDs passed to `supportsInterface`. + * + * Branded to prevent accidentally passing a full-length hash or + * arbitrary Hex value where only 4 bytes are valid on-chain. + * Use {@link isBytes4} to validate and narrow at runtime. + */ +export type Bytes4 = `0x${string}` & { readonly __bytes4: unique symbol }; + /** ICampaignData.CampaignData — used by CampaignInfo and CampaignInfoFactory. */ export interface CampaignData { /** Unix timestamp (seconds) when the campaign launches. */ diff --git a/packages/contracts/src/utils/hex.ts b/packages/contracts/src/utils/hex.ts index abe1affe..5b1610da 100644 --- a/packages/contracts/src/utils/hex.ts +++ b/packages/contracts/src/utils/hex.ts @@ -1,4 +1,5 @@ import { toHex as viemToHex } from "../lib"; +import type { Bytes4 } from "../types/structs"; /** * Type guard for 0x-prefixed hex strings. @@ -9,6 +10,26 @@ export function isHex(data: string): data is `0x${string}` { return typeof data === "string" && data.startsWith("0x") && /^0x[0-9a-fA-F]*$/.test(data); } +/** + * Type guard that validates a string is a 4-byte hex value (`0x` + exactly 8 hex chars). + * Use this to narrow an unknown string to {@link Bytes4} before passing it to + * `supportsInterface` or any ERC-165 method. + * + * @param data - Value to check + * @returns True if the value is a valid 4-byte hex string + * + * @example + * ```typescript + * const id = "0x01ffc9a7"; + * if (isBytes4(id)) { + * const supported = await entity.supportsInterface(id); + * } + * ``` + */ +export function isBytes4(data: string): data is Bytes4 { + return /^0x[0-9a-fA-F]{8}$/.test(data); +} + /** * Encodes a string, number, bigint, boolean, or byte array as a 0x-prefixed hex string. * Thin re-export of viem's toHex via the lib/ boundary. diff --git a/packages/contracts/src/utils/index.ts b/packages/contracts/src/utils/index.ts index 444c57ab..9c03b256 100644 --- a/packages/contracts/src/utils/index.ts +++ b/packages/contracts/src/utils/index.ts @@ -3,7 +3,7 @@ * All external imports are routed through lib/. */ export { requireSigner, requireAccount } from "./account"; -export { isHex, toHex } from "./hex"; +export { isHex, isBytes4, toHex } from "./hex"; export { keccak256, id } from "./hash"; export { getCurrentTimestamp, addDays } from "./time"; export { getChainFromId } from "./chain"; From 69144a52362ddb8bc35251e8201ed7fb9d37c3e1 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 15:00:09 +0600 Subject: [PATCH 50/86] test: add unit tests for isBytes4 utility function - Introduce unit tests for the isBytes4 function to validate its behavior with various 4-byte hex string inputs, including valid, invalid, and edge cases. - Enhance type safety and ensure correct functionality for hex string validation in the context of contract interactions. --- .../__tests__/unit/contract-entities.test.ts | 7 +++-- .../contracts/__tests__/unit/utils.test.ts | 28 ++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/contracts/__tests__/unit/contract-entities.test.ts b/packages/contracts/__tests__/unit/contract-entities.test.ts index e1343d20..e77180c7 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -5,6 +5,7 @@ */ import type { Address, PublicClient, WalletClient, Chain } from "../../src/lib"; +import type { Bytes4 } from "../../src/types/structs"; import { keccak256, toHex } from "viem"; const ADDR = "0x0000000000000000000000000000000000000001" as Address; @@ -344,7 +345,7 @@ describe("CampaignInfo entity", () => { it("tokenURI", async () => { await entity.tokenURI(0n); }); it("ownerOf", async () => { await entity.ownerOf(0n); }); it("balanceOf", async () => { await entity.balanceOf(ADDR); }); - it("supportsInterface", async () => { await entity.supportsInterface(B32.slice(0, 10) as `0x${string}`); }); + it("supportsInterface", async () => { await entity.supportsInterface(B32.slice(0, 10) as Bytes4); }); }); describe("writes", () => { @@ -577,7 +578,7 @@ describe("AllOrNothing entity", () => { it("symbol", async () => { await entity.symbol(); }); it("getApproved", async () => { await entity.getApproved(0n); }); it("isApprovedForAll", async () => { await entity.isApprovedForAll(ADDR, ADDR); }); - it("supportsInterface", async () => { await entity.supportsInterface("0x80ac58cd"); }); + it("supportsInterface", async () => { await entity.supportsInterface("0x80ac58cd" as Bytes4); }); }); describe("writes", () => { @@ -704,7 +705,7 @@ describe("KeepWhatsRaised entity", () => { it("symbol", async () => { await entity.symbol(); }); it("getApproved", async () => { await entity.getApproved(0n); }); it("isApprovedForAll", async () => { await entity.isApprovedForAll(ADDR, ADDR); }); - it("supportsInterface", async () => { await entity.supportsInterface("0x80ac58cd"); }); + it("supportsInterface", async () => { await entity.supportsInterface("0x80ac58cd" as Bytes4); }); }); describe("writes", () => { diff --git a/packages/contracts/__tests__/unit/utils.test.ts b/packages/contracts/__tests__/unit/utils.test.ts index 97fa47c8..241e75a2 100644 --- a/packages/contracts/__tests__/unit/utils.test.ts +++ b/packages/contracts/__tests__/unit/utils.test.ts @@ -1,7 +1,7 @@ import { requireAccount, requireSigner } from "../../src/utils/account"; import { getChainFromId } from "../../src/utils/chain"; import { keccak256, id } from "../../src/utils/hash"; -import { isHex, toHex } from "../../src/utils/hex"; +import { isHex, isBytes4, toHex } from "../../src/utils/hex"; import { getCurrentTimestamp, addDays } from "../../src/utils/time"; import type { WalletClient } from "../../src/lib"; @@ -95,6 +95,32 @@ describe("isHex", () => { }); }); +describe("isBytes4", () => { + it("returns true for a valid 4-byte hex string", () => { + expect(isBytes4("0x01ffc9a7")).toBe(true); + expect(isBytes4("0x80ac58cd")).toBe(true); + expect(isBytes4("0xFFFFFFFF")).toBe(true); + }); + + it("returns false for hex strings shorter than 4 bytes", () => { + expect(isBytes4("0x01ffc9")).toBe(false); + expect(isBytes4("0x")).toBe(false); + }); + + it("returns false for hex strings longer than 4 bytes", () => { + expect(isBytes4("0x01ffc9a7ff")).toBe(false); + expect(isBytes4("0x0000000000000000000000000000000000000000000000000000000000000001")).toBe(false); + }); + + it("returns false for non-hex characters", () => { + expect(isBytes4("0xZZZZZZZZ")).toBe(false); + }); + + it("returns false for missing 0x prefix", () => { + expect(isBytes4("01ffc9a7")).toBe(false); + }); +}); + describe("toHex", () => { it("encodes a number", () => { expect(toHex(255)).toMatch(/^0x/); From d7c953f5a6e4e72310481f25f393aefbae87034f Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 15:05:17 +0600 Subject: [PATCH 51/86] docs: enhance simulation method documentation in GlobalParamsSimulate interface - Update comments for simulation methods in the GlobalParamsSimulate interface to clarify that they return a SimulationResult on success and throw a typed error on revert. - Improve clarity and understanding of the expected behavior for each simulation method, aiding developers in contract interactions. --- .../src/contracts/global-params/types.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/contracts/src/contracts/global-params/types.ts b/packages/contracts/src/contracts/global-params/types.ts index 3356c274..aecc9ef2 100644 --- a/packages/contracts/src/contracts/global-params/types.ts +++ b/packages/contracts/src/contracts/global-params/types.ts @@ -75,37 +75,37 @@ export interface GlobalParamsWrites { /** Simulate counterparts for GlobalParams write methods. */ export interface GlobalParamsSimulate { - /** Simulates enlistPlatform; returns a SimulationResult. */ + /** Simulates enlistPlatform; returns a SimulationResult on success, throws a typed error on revert. */ enlistPlatform(platformHash: Hex, platformAdminAddress: Address, platformFeePercent: bigint, platformAdapter: Address, options?: CallSignerOptions): Promise; - /** Simulates delistPlatform; returns a SimulationResult. */ + /** Simulates delistPlatform; returns a SimulationResult on success, throws a typed error on revert. */ delistPlatform(platformBytes: Hex, options?: CallSignerOptions): Promise; - /** Simulates updatePlatformAdminAddress; returns a SimulationResult. */ + /** Simulates updatePlatformAdminAddress; returns a SimulationResult on success, throws a typed error on revert. */ updatePlatformAdminAddress(platformBytes: Hex, platformAdminAddress: Address, options?: CallSignerOptions): Promise; - /** Simulates updatePlatformClaimDelay; returns a SimulationResult. */ + /** Simulates updatePlatformClaimDelay; returns a SimulationResult on success, throws a typed error on revert. */ updatePlatformClaimDelay(platformBytes: Hex, claimDelay: bigint, options?: CallSignerOptions): Promise; - /** Simulates updateProtocolAdminAddress; returns a SimulationResult. */ + /** Simulates updateProtocolAdminAddress; returns a SimulationResult on success, throws a typed error on revert. */ updateProtocolAdminAddress(protocolAdminAddress: Address, options?: CallSignerOptions): Promise; - /** Simulates updateProtocolFeePercent; returns a SimulationResult. */ + /** Simulates updateProtocolFeePercent; returns a SimulationResult on success, throws a typed error on revert. */ updateProtocolFeePercent(protocolFeePercent: bigint, options?: CallSignerOptions): Promise; - /** Simulates setPlatformAdapter; returns a SimulationResult. */ + /** Simulates setPlatformAdapter; returns a SimulationResult on success, throws a typed error on revert. */ setPlatformAdapter(platformBytes: Hex, platformAdapter: Address, options?: CallSignerOptions): Promise; - /** Simulates setPlatformLineItemType; returns a SimulationResult. */ + /** Simulates setPlatformLineItemType; returns a SimulationResult on success, throws a typed error on revert. */ setPlatformLineItemType(platformHash: Hex, typeId: Hex, label: string, countsTowardGoal: boolean, applyProtocolFee: boolean, canRefund: boolean, instantTransfer: boolean, options?: CallSignerOptions): Promise; - /** Simulates removePlatformLineItemType; returns a SimulationResult. */ + /** Simulates removePlatformLineItemType; returns a SimulationResult on success, throws a typed error on revert. */ removePlatformLineItemType(platformHash: Hex, typeId: Hex, options?: CallSignerOptions): Promise; - /** Simulates addTokenToCurrency; returns a SimulationResult. */ + /** Simulates addTokenToCurrency; returns a SimulationResult on success, throws a typed error on revert. */ addTokenToCurrency(currency: Hex, token: Address, options?: CallSignerOptions): Promise; - /** Simulates removeTokenFromCurrency; returns a SimulationResult. */ + /** Simulates removeTokenFromCurrency; returns a SimulationResult on success, throws a typed error on revert. */ removeTokenFromCurrency(currency: Hex, token: Address, options?: CallSignerOptions): Promise; - /** Simulates addPlatformData; returns a SimulationResult. */ + /** Simulates addPlatformData; returns a SimulationResult on success, throws a typed error on revert. */ addPlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions): Promise; - /** Simulates removePlatformData; returns a SimulationResult. */ + /** Simulates removePlatformData; returns a SimulationResult on success, throws a typed error on revert. */ removePlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions): Promise; - /** Simulates addToRegistry; returns a SimulationResult. */ + /** Simulates addToRegistry; returns a SimulationResult on success, throws a typed error on revert. */ addToRegistry(key: Hex, value: Hex, options?: CallSignerOptions): Promise; - /** Simulates transferOwnership; returns a SimulationResult. */ + /** Simulates transferOwnership; returns a SimulationResult on success, throws a typed error on revert. */ transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; - /** Simulates renounceOwnership; returns a SimulationResult. */ + /** Simulates renounceOwnership; returns a SimulationResult on success, throws a typed error on revert. */ renounceOwnership(options?: CallSignerOptions): Promise; } From d06f512bb445f586577f9ff5ce8250b83304ced1 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 15:05:34 +0600 Subject: [PATCH 52/86] docs: clarify on-chain callable functions in CAMPAIGN_INFO_ABI - Add comments to explain the purpose of underscore-prefixed functions in the CAMPAIGN_INFO_ABI, highlighting their public accessibility despite naming conventions. - Detail the inheritance from PausableCancellable and the SDK's wrapping of these functions with cleaner names for better developer understanding. --- packages/contracts/src/contracts/campaign-info/abi.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/contracts/src/contracts/campaign-info/abi.ts b/packages/contracts/src/contracts/campaign-info/abi.ts index 6e26f0d2..f8b48565 100644 --- a/packages/contracts/src/contracts/campaign-info/abi.ts +++ b/packages/contracts/src/contracts/campaign-info/abi.ts @@ -239,6 +239,11 @@ export const CAMPAIGN_INFO_ABI = [ stateMutability: "view", type: "function", }, + // The underscore-prefixed functions below are publicly callable on-chain despite + // the naming convention. The Solidity contract inherits from PausableCancellable + // which exposes them as external functions; the underscore distinguishes them from + // the parent's internal helpers. The SDK wraps them with clean names (e.g. + // cancelCampaign, pauseCampaign) in writes.ts and simulate.ts. { inputs: [{ internalType: "bytes32", name: "message", type: "bytes32" }], name: "_cancelCampaign", From 299437ef4b39a58d5f01c42c7747fe1bde7a16c6 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 15:06:07 +0600 Subject: [PATCH 53/86] docs: add comments to clarify type casting for contract read functions - Introduce comments in various contract read functions to explain the necessity of double-casting results from viem to match SDK interfaces. - Ensure clarity on how the ABI field names and types align with the target interfaces, enhancing developer understanding of type safety in contract interactions. --- packages/contracts/src/contracts/all-or-nothing/reads.ts | 3 +++ packages/contracts/src/contracts/campaign-info/reads.ts | 3 +++ packages/contracts/src/contracts/global-params/reads.ts | 3 +++ packages/contracts/src/contracts/item-registry/reads.ts | 3 +++ packages/contracts/src/contracts/keep-whats-raised/reads.ts | 3 +++ packages/contracts/src/contracts/payment-treasury/reads.ts | 3 +++ 6 files changed, 18 insertions(+) diff --git a/packages/contracts/src/contracts/all-or-nothing/reads.ts b/packages/contracts/src/contracts/all-or-nothing/reads.ts index 71c4926d..6dcb0810 100644 --- a/packages/contracts/src/contracts/all-or-nothing/reads.ts +++ b/packages/contracts/src/contracts/all-or-nothing/reads.ts @@ -27,6 +27,9 @@ export function createAllOrNothingReads( }, async getReward(rewardName: Hex): Promise { const result = await publicClient.readContract({ ...contract, functionName: "getReward", args: [rewardName] }); + // viem returns Solidity structs as readonly tuple objects whose type doesn't + // unify with the SDK's named interface; the double-cast bridges the gap safely + // because the ABI field names and types are identical to TieredReward. return result as unknown as TieredReward; }, async getPlatformHash() { diff --git a/packages/contracts/src/contracts/campaign-info/reads.ts b/packages/contracts/src/contracts/campaign-info/reads.ts index 1f934074..6fe3de22 100644 --- a/packages/contracts/src/contracts/campaign-info/reads.ts +++ b/packages/contracts/src/contracts/campaign-info/reads.ts @@ -87,6 +87,9 @@ export function createCampaignInfoReads( }, async getLineItemType(platformHash: Hex, typeId: Hex): Promise { const result = await publicClient.readContract({ ...contract, functionName: "getLineItemType", args: [platformHash, typeId] }); + // viem returns Solidity structs as readonly tuple objects whose type doesn't + // unify with the SDK's named interface; the double-cast bridges the gap safely + // because the ABI field names and types are identical to the target interface. return result as unknown as LineItemTypeInfo; }, async getCampaignConfig(): Promise { diff --git a/packages/contracts/src/contracts/global-params/reads.ts b/packages/contracts/src/contracts/global-params/reads.ts index 9af07f67..2653f280 100644 --- a/packages/contracts/src/contracts/global-params/reads.ts +++ b/packages/contracts/src/contracts/global-params/reads.ts @@ -48,6 +48,9 @@ export function createGlobalParamsReads( }, async getPlatformLineItemType(platformHash: Hex, typeId: Hex): Promise { const result = await publicClient.readContract({ ...contract, functionName: "getPlatformLineItemType", args: [platformHash, typeId] }); + // viem returns Solidity structs as readonly tuple objects whose type doesn't + // unify with the SDK's named interface; the double-cast bridges the gap safely + // because the ABI field names and types are identical to LineItemTypeInfo. return result as unknown as LineItemTypeInfo; }, async getTokensForCurrency(currency: Hex) { diff --git a/packages/contracts/src/contracts/item-registry/reads.ts b/packages/contracts/src/contracts/item-registry/reads.ts index cbbe73c8..2686802a 100644 --- a/packages/contracts/src/contracts/item-registry/reads.ts +++ b/packages/contracts/src/contracts/item-registry/reads.ts @@ -22,6 +22,9 @@ export function createItemRegistryReads( functionName: "getItem", args: [owner, itemId], }); + // viem returns Solidity structs as readonly tuple objects whose type doesn't + // unify with the SDK's named interface; the double-cast bridges the gap safely + // because the ABI field names and types are identical to Item. return result as unknown as Item; }, }; diff --git a/packages/contracts/src/contracts/keep-whats-raised/reads.ts b/packages/contracts/src/contracts/keep-whats-raised/reads.ts index 0f326912..b04bef99 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/reads.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/reads.ts @@ -34,6 +34,9 @@ export function createKeepWhatsRaisedReads( functionName: "getReward", args: [rewardName], }); + // viem returns Solidity structs as readonly tuple objects whose type doesn't + // unify with the SDK's named interface; the double-cast bridges the gap safely + // because the ABI field names and types are identical to TieredReward. return result as unknown as TieredReward; }, async getPlatformHash() { diff --git a/packages/contracts/src/contracts/payment-treasury/reads.ts b/packages/contracts/src/contracts/payment-treasury/reads.ts index 1c230d29..5379f2ba 100644 --- a/packages/contracts/src/contracts/payment-treasury/reads.ts +++ b/packages/contracts/src/contracts/payment-treasury/reads.ts @@ -43,6 +43,9 @@ export function createPaymentTreasuryReads( functionName: "getPaymentData", args: [paymentId], }); + // viem returns Solidity structs as readonly tuple objects whose type doesn't + // unify with the SDK's named interface; the double-cast bridges the gap safely + // because the ABI field names and types are identical to PaymentData. return result as unknown as PaymentData; }, async cancelled() { From 9d02df7cebe5c323b68e064d2d5b54959726b388 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Mon, 13 Apr 2026 15:06:19 +0600 Subject: [PATCH 54/86] docs: enhance client configuration section in README - Expand the client configuration section to detail five supported config/signer patterns, providing clear descriptions for each pattern. - Improve developer understanding of how to mix and match configurations for various use cases in contract interactions. --- packages/contracts/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 351154b2..b2455763 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -58,7 +58,13 @@ See the full [Quickstart](https://oaknetwork.org/docs/contracts-sdk/quickstart) ## Client Configuration -Five config/signer patterns are supported. Mix and match as needed. +Five config/signer patterns are supported — mix and match as needed: + +1. **Simple** — `chainId` + `rpcUrl` + `privateKey` (full read/write) +2. **Read-only** — `chainId` + `rpcUrl`, no private key (reads only, writes throw) +3. **Per-entity signer** — attach a signer when creating an entity +4. **Per-call signer** — pass a signer to individual write/simulate calls +5. **Full (BYO clients)** — pass pre-built viem `PublicClient` / `WalletClient` ### Pattern 1 — Simple (chainId + rpcUrl + privateKey) From f9e2b130c3e47dc886f57e03e7e9f581e54216f6 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 15 Apr 2026 12:47:30 +0600 Subject: [PATCH 55/86] docs: enhance examples with multi-token ERC-20 support details - Added comprehensive documentation on multi-token support in the README files across various examples. - Updated code comments to clarify the handling of multiple ERC-20 tokens in campaign configurations and treasury operations. - Adjusted example scripts to reflect the use of USDC instead of cUSD for consistency in token representation. --- .../06-optional-configuration.ts | 26 ++++++++++++------- .../examples/00-platform-enlistment/README.md | 6 +++++ .../01-create-campaign.ts | 4 +++ .../06-backer-pledge.ts | 5 +++- .../01-campaign-all-or-nothing/README.md | 4 +++ .../01-create-campaign.ts | 4 +++ .../05-backer-pledge.ts | 5 +++- .../06a-partial-withdrawal.ts | 2 +- .../06b-final-withdrawal.ts | 2 +- .../02-campaign-keep-whats-raised/README.md | 4 +++ .../02-create-payment.ts | 6 ++++- .../03-process-crypto-payment.ts | 7 +++-- .../10-claim-non-goal-line-items.ts | 4 +-- .../03-campaign-payment-treasury/README.md | 4 +++ packages/contracts/src/examples/README.md | 8 ++++++ 15 files changed, 73 insertions(+), 18 deletions(-) diff --git a/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts index 483d985f..750f882a 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts @@ -27,7 +27,9 @@ * across all treasury types * * E. Protocol Admin Functions (Protocol Admin only) - * — Currency/token management, global data registry, delisting, + * — Currency/token management (map a currency bytes32 to one or more + * ERC-20s via `addTokenToCurrency` / `removeTokenFromCurrency` so + * campaigns accept multiple tokens), global data registry, delisting, * admin address updates, fee updates. Listed for completeness; * platforms coordinate with Oak support for these. */ @@ -274,23 +276,29 @@ async function protocolAdminExamples(): Promise { // --- Currency management --- // - // The Protocol Admin manages which ERC-20 tokens are accepted - // for each currency. Campaigns specify a currency (e.g., "USD"), - // and the protocol resolves it to a list of accepted token addresses. + // GlobalParams stores currency → token[] in storage. The mapping is + // first populated in contract initialize(currencies, tokensPerCurrency). + // After deploy, only the owner adds/removes tokens: + // addTokenToCurrency(currency, token) — push to the array + // removeTokenFromCurrency(currency, token) — swap-and-pop + // getTokensForCurrency(currency) — read the full list (view) + // CampaignInfoFactory.createCampaign reads getTokensForCurrency for the + // campaign currency and caches the result on CampaignInfo; treasuries + // then use CampaignInfo.isTokenAccepted(paymentToken | pledgeToken). const usdCurrency = toHex("USD", { size: 32 }); - // const cusdToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; - // const addTokenTx = await globalParams.addTokenToCurrency(usdCurrency, cusdToken); + // const usdcToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; + // const addTokenTx = await globalParams.addTokenToCurrency(usdCurrency, usdcToken); // await oak.waitForReceipt(addTokenTx); - // console.log("cUSD added as accepted token for USD"); + // console.log("USDC added as accepted token for USD"); const usdTokens = await globalParams.getTokensForCurrency(usdCurrency); console.log("USD accepted tokens:", usdTokens); - // const removeTokenTx = await globalParams.removeTokenFromCurrency(usdCurrency, cusdToken); + // const removeTokenTx = await globalParams.removeTokenFromCurrency(usdCurrency, usdcToken); // await oak.waitForReceipt(removeTokenTx); - // console.log("cUSD removed from USD currency"); + // console.log("USDC removed from USD currency"); // --- Global data registry --- // diff --git a/packages/contracts/src/examples/00-platform-enlistment/README.md b/packages/contracts/src/examples/00-platform-enlistment/README.md index 4d8e5bf8..b8cf0c0a 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/README.md +++ b/packages/contracts/src/examples/00-platform-enlistment/README.md @@ -43,6 +43,12 @@ Each platform maintains its own mapping of implementation ID to treasury contrac > **PaymentTreasury vs. TimeConstrainedPaymentTreasury:** Both share the same SDK interface — `oak.paymentTreasury(address)`. The only difference is at registration time: you register a different implementation contract address. The time constraints are enforced transparently by the smart contract. See the [Payment Treasury README](../03-campaign-payment-treasury/README.md) for details. +## Multi-token currencies (ERC-20) + +Campaigns are not limited to a single asset. **`GlobalParams`** holds **`currencyToTokens`**: the list is **bootstrapped in `initialize`** with parallel **`currencies[]`** and **`tokensPerCurrency[][]`**, then the protocol owner (**`onlyOwner`**) can **`addTokenToCurrency`** / **`removeTokenFromCurrency`**; anyone can read **`getTokensForCurrency(currency)`**. Emitted events: **`TokenAddedToCurrency`**, **`TokenRemovedFromCurrency`**. + +When **`CampaignInfoFactory`** creates a campaign, it reads **`getTokensForCurrency(campaignData.currency)`** and stores that snapshot on **`CampaignInfo`** (`getAcceptedTokens` / `isTokenAccepted`). Treasuries validate every **`paymentToken`** / **`pledgeToken`** against that campaign cache. Step 6’s optional configuration shows **`getTokensForCurrency`** and commented **`addTokenToCurrency`** / **`removeTokenFromCurrency`** calls. + ## Optional Configuration (Step 6) After the core onboarding, a platform can configure additional features. These are all optional and independent — skip any you don't need. They are documented in `06-optional-configuration.ts`: diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts index 745882b2..1119a1fb 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts @@ -10,6 +10,10 @@ * - ArtFund as the selected platform (identified by its platform hash) * - NFT metadata so each backer receives a collectible receipt * + * Multi-token: the campaign `currency` resolves to one or more accepted + * ERC-20 addresses on-chain; later pledges must use `pledgeToken` in that + * whitelist (`CampaignInfo.isTokenAccepted`). This example uses one token. + * * The factory assigns a unique contract address to the campaign, which * Maya will look up in the next step. */ diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts index 5fe1e5e0..7c36c9a2 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts @@ -17,6 +17,9 @@ * * Prerequisite: the backer must have already approved the treasury * contract to spend their ERC-20 tokens. + * + * Multi-token: `pledgeToken` must be accepted for the campaign; backers + * can use different whitelisted tokens across pledges (each tracked separately). */ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; @@ -34,7 +37,7 @@ const alexOak = createOakContractsClient({ const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; const alexTreasury = alexOak.allOrNothingTreasury(treasuryAddress); -const pledgeToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const pledgeToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; const shippingFee = 5_000_000n; // $5 shipping const printReward = keccak256(toHex("signed-print")); diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md b/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md index ad84c9ba..f30938fc 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md @@ -8,6 +8,10 @@ Maya chooses the **All-or-Nothing** funding model. This means every dollar pledg This model builds trust with backers because their funds are protected by the smart contract. Maya cannot access the money unless the community collectively meets the target. +## Multi-token support + +Maya’s campaign accepts whatever **ERC-20s** the platform mapped to her campaign **currency** at creation time. Each pledge passes **`pledgeToken`**; the All-or-Nothing treasury checks **`CampaignInfo.isTokenAccepted`**. Raised totals aggregate across accepted tokens (normalized on-chain); refunds return **the same token** the backer used. The TypeScript steps use **one token address** as a stand-in—replace it with any **whitelisted** token for your deployment. + ## How It Unfolds 1. **Maya (Creator)** creates the campaign through the CampaignInfoFactory, setting the funding goal, deadline, platform, and NFT metadata for backer receipts diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts index 3ac552f3..f795db92 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts @@ -8,6 +8,10 @@ * After creation, they immediately look up the deployed CampaignInfo * contract address using the identifier hash — this address is needed * for all subsequent steps (deploying the treasury, adding rewards, etc.). + * + * Multi-token: the campaign `currency` resolves to accepted ERC-20 + * addresses; pledges and `withdraw(token, amount)` use tokens from that + * whitelist only. This example uses one token for simplicity. */ import { diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts index 65ec79ac..aaa2858b 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts @@ -17,6 +17,9 @@ * * Every pledge requires a unique `pledgeId` (a bytes32 value) and * supports an optional `tip` that goes directly to the platform. + * + * Multi-token: each pledge names `pledgeToken`; only campaign-accepted + * ERC-20s are allowed; partial/final withdrawals specify the token explicitly. */ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; @@ -30,7 +33,7 @@ const oak = createOakContractsClient({ const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); -const pledgeToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const pledgeToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; const backerAddress = process.env.BACKER_ADDRESS! as `0x${string}`; const earlyBirdReward = keccak256(toHex("early-bird")); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts index a7a65e8c..eedf14f6 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts @@ -54,7 +54,7 @@ const creatorOak = createOakContractsClient({ const creatorTreasury = creatorOak.keepWhatsRaisedTreasury(treasuryAddress); -const withdrawToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const withdrawToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; const withdrawAmount = 2_000_000_000n; // $2,000 const withdrawTxHash = await creatorTreasury.withdraw(withdrawToken, withdrawAmount); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts index 88f05edf..736a6793 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts @@ -32,7 +32,7 @@ const oak = createOakContractsClient({ const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; const treasury = oak.keepWhatsRaisedTreasury(treasuryAddress); -const withdrawToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const withdrawToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; // For a final withdrawal the contract ignores the amount parameter // and uses the full available balance — pass 0n or any value diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md index c75beeee..bf426cda 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md @@ -13,6 +13,10 @@ TechForge chooses the **Keep-What's-Raised** funding model on the **ArtFund** pl - **Refund delays** — A configurable waiting period after the deadline before backers can claim refunds - **Updatable parameters** — The creator or platform admin can extend the deadline or adjust the funding goal (before the config lock period) +## Multi-token support + +Same model as Scenario 1: the campaign whitelists **multiple ERC-20s** per **currency**; each pledge names **`pledgeToken`**; **`withdraw(token, amount)`** and fee paths are **per token**. Example files use a single stablecoin for clarity—use any accepted token from your campaign’s list in real integrations. + ## How It Unfolds 1. **TechForge (Creator)** creates the campaign with a $10,000 goal and a 60-day deadline diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts index f1f1d572..464362cb 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts @@ -16,6 +16,10 @@ * * For high-volume platforms, `createPaymentBatch` is available to * create multiple payment records in a single transaction. + * + * Multi-token: `paymentToken` must be on the campaign’s accepted-token + * list (`CampaignInfo.isTokenAccepted`). Balances and refunds are tracked + * per ERC-20. See Scenario 0 for currency ↔ token mapping in GlobalParams. */ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; @@ -34,7 +38,7 @@ const paymentTreasury = oak.paymentTreasury( const paymentId = keccak256(toHex("order-12345")); const buyerId = keccak256(toHex("sam-user-id")); const itemId = keccak256(toHex("handcrafted-vase-001")); -const paymentToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const paymentToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; const totalAmount = 135_000_000n; // $135 total (product + shipping) const expiration = BigInt(Math.floor(Date.now() / 1000)) + 86400n; // 24 hours diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts index e204e102..0dfdf3cd 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts @@ -1,7 +1,7 @@ /** * Step 3: Process the Crypto Payment (Buyer) * - * Sam completes the purchase by transferring ERC-20 tokens (e.g., cUSD) + * Sam completes the purchase by transferring ERC-20 tokens (e.g., USDC) * from his wallet to the treasury contract. This is the moment funds * actually move on-chain. * @@ -12,6 +12,9 @@ * Prerequisite: Sam must have already approved the treasury contract * to spend his ERC-20 tokens before calling this method. This is a * standard ERC-20 approval, not specific to Oak Protocol. + * + * Multi-token: `paymentToken` here must match Step 2 and be accepted for + * the campaign; use a separate approval per token if you support several. */ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; @@ -29,7 +32,7 @@ const paymentTreasury = samOak.paymentTreasury( const paymentId = keccak256(toHex("order-12345")); const itemId = keccak256(toHex("handcrafted-vase-001")); -const paymentToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const paymentToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; const totalAmount = 135_000_000n; const lineItems: LineItem[] = [ diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts index e9dfe51a..6dcc2ff3 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts @@ -25,8 +25,8 @@ const paymentTreasury = platformOak.paymentTreasury( process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, ); -const cusdToken = process.env.CUSD_TOKEN_ADDRESS! as `0x${string}`; +const usdcToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; -const claimTxHash = await paymentTreasury.claimNonGoalLineItems(cusdToken); +const claimTxHash = await paymentTreasury.claimNonGoalLineItems(usdcToken); const receipt = await platformOak.waitForReceipt(claimTxHash); console.log(`Non-goal line items claimed at block ${receipt.blockNumber}`); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md index 6e0123ce..4c508706 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md @@ -8,6 +8,10 @@ CeloMarket uses the **PaymentTreasury** model, which works like a traditional pa In this scenario, a buyer named **Sam** purchases a handcrafted ceramic vase for **$120** with **$15 shipping**. The payment flows through the treasury, gets confirmed by the platform, and the funds become available for withdrawal. +## Multi-token support + +Every payment record includes **`paymentToken`**. The treasury only accepts tokens that **`CampaignInfo.isTokenAccepted`** allows for that campaign. Pending, confirmed, fee, and refund accounting is **per ERC-20 contract** (amounts in that token’s decimals). The walkthrough uses **one stablecoin**; batch and single-payment APIs work the same way for **each additional accepted token** you configure at the protocol/campaign level. + ## How It Unfolds 1. **CeloMarket (Platform Admin)** connects to its deployed PaymentTreasury contract and reads back the platform configuration diff --git a/packages/contracts/src/examples/README.md b/packages/contracts/src/examples/README.md index 880f32af..3c1bd432 100644 --- a/packages/contracts/src/examples/README.md +++ b/packages/contracts/src/examples/README.md @@ -20,6 +20,14 @@ Start with **Scenario 0** if you are a new platform joining Oak Protocol. Start --- +## Multi-token ERC-20 support + +Oak is **multi-token**: **`GlobalParams`** stores **`currencyToTokens`** — seeded in **`initialize(currencies, tokensPerCurrency)`**, then maintained by the protocol owner with **`addTokenToCurrency`** / **`removeTokenFromCurrency`**, readable via **`getTokensForCurrency(currency)`**. Campaign creation copies that list onto **`CampaignInfo`**; treasuries check **`isTokenAccepted`** on every **`pledgeToken` / `paymentToken`**. In code, use **`globalParams.getTokensForCurrency(...)`** or **`campaign.getAcceptedTokens()`** to populate wallet UIs or validation. Balances, fees, refunds, and raised-amount reads are **per token address**, in **native decimals** (normalized where the protocol aggregates across tokens). + +The numbered examples use **one stablecoin** (e.g. USDC) so the files stay easy to read. In production, swap in **any address** from your campaign’s accepted-token list and match **decimals** when you build amounts. + +--- + ## Folder Structure ``` From 04e9a4481031a5ec3d9848631441791c06b4abeb Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 15 Apr 2026 12:49:36 +0600 Subject: [PATCH 56/86] docs: add use-case-driven integration guides for Oak Contracts SDK - Introduced comprehensive documentation for various use cases, including Escrow, Marketplace, Prepayment, Flexible Funding, and Crowdfunding. - Each guide outlines the business context, integration flow, and relevant Oak contracts, enhancing clarity for developers. --- packages/contracts/src/use-cases/README.md | 135 ++++++ .../crowdfunding/creative-campaign.md | 446 ++++++++++++++++++ .../src/use-cases/escrow/healthcare-escrow.md | 358 ++++++++++++++ .../flexible-funding/community-project.md | 395 ++++++++++++++++ .../marketplace/ecommerce-marketplace.md | 377 +++++++++++++++ .../prepayment/automotive-prepayment.md | 317 +++++++++++++ 6 files changed, 2028 insertions(+) create mode 100644 packages/contracts/src/use-cases/README.md create mode 100644 packages/contracts/src/use-cases/crowdfunding/creative-campaign.md create mode 100644 packages/contracts/src/use-cases/escrow/healthcare-escrow.md create mode 100644 packages/contracts/src/use-cases/flexible-funding/community-project.md create mode 100644 packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md create mode 100644 packages/contracts/src/use-cases/prepayment/automotive-prepayment.md diff --git a/packages/contracts/src/use-cases/README.md b/packages/contracts/src/use-cases/README.md new file mode 100644 index 00000000..7df98181 --- /dev/null +++ b/packages/contracts/src/use-cases/README.md @@ -0,0 +1,135 @@ +# Oak Contracts SDK — Use Cases + +This folder contains **use-case-driven integration guides** that show how real businesses would integrate with the Oak protocol using the Contracts SDK. Each guide tells a complete business story — from the problem to the on-chain solution — with illustrative code snippets. + +> **These are documentation guides, not runnable scripts.** For executable API-reference examples, see [`examples/`](../examples/). + +## Multi-token ERC-20 support + +Campaigns are **not** tied to a single asset like USDC or USDT. **`GlobalParams`** owns the canonical **currency → ERC-20[]** mapping: **`initialize`** seeds `currencies` and `tokensPerCurrency` at deploy, and the protocol admin can later **`addTokenToCurrency`** / **`removeTokenFromCurrency`** (emitting **`TokenAddedToCurrency`** / **`TokenRemovedFromCurrency`**). **`getTokensForCurrency(currency)`** returns the full address list for a currency key. + +When **`CampaignInfoFactory.createCampaign`** runs, it resolves the campaign’s **`campaignData.currency`** to that list and stores a **cached copy** on **`CampaignInfo`** (with **`isTokenAccepted`** for O(1) checks). In the SDK you can read the live list with **`campaign.getAcceptedTokens()`** or cross-check **`globalParams.getTokensForCurrency(currency)`** against what you passed at creation. + +Every pledge or payment specifies **`pledgeToken` / `paymentToken`**; treasuries revert if the token is not accepted. Balances, fees, refunds, and raised-amount aggregates are **per token address**, in **each token’s native decimals** (normalized where the protocol compares across tokens). The stories below use USDC or USDT as **examples**; in production, use any address from your campaign’s accepted-token list. + +## Use Cases + +| Use Case | Demo | Contract(s) Used | Business Story | +|----------|------|-------------------|----------------| +| **Escrow** | [Healthcare Escrow](escrow/healthcare-escrow.md) | PaymentTreasury | MedConnect holds patient payments until a doctor confirms service delivery | +| **Marketplace** | [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) | PaymentTreasury + ItemRegistry | CeloMarket locks buyer funds until seller ships; physical items tracked on-chain | +| **Prepayment** | [Automotive Prepayment](prepayment/automotive-prepayment.md) | TimeConstrainedPaymentTreasury | Karma Automotive holds vehicle deposits with automatic expiry protection | +| **Flexible Funding** | [Community Project](flexible-funding/community-project.md) | CampaignInfoFactory + KeepWhatsRaised | TechForge runs keep-what's-raised campaigns with partial withdrawals, tips, and gateway fees | +| **Crowdfunding** | [Creative Campaign](crowdfunding/creative-campaign.md) | CampaignInfoFactory + AllOrNothing | ArtFund runs all-or-nothing campaigns with NFT-backed pledges and reward tiers | + +## How to Read These Demos + +Each guide follows the same structure: + +1. **The Business** — who is the company and what do they do? +2. **Why Oak?** — what specific problems does Oak solve for them? +3. **Contracts Used** — which Oak smart contracts power the solution +4. **Roles** — who are the actors (platform, buyer, seller, backer)? +5. **Integration Flow** — step-by-step walkthrough with code snippets +6. **Architecture Diagram** — visual flow of interactions +7. **Key Takeaways** — lessons and patterns to apply to your own integration + +## Contract-to-Use-Case Mapping + +Understanding which Oak contract to use for your business: + +### PaymentTreasury + +Best for: **escrow**, **marketplace**, **service payments** + +Funds are held until the platform confirms delivery/service. Supports line items, external fees, batch operations, and refund flows. + +- [Healthcare Escrow](escrow/healthcare-escrow.md) — service escrow +- [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) — product escrow with ItemRegistry + +### TimeConstrainedPaymentTreasury + +Best for: **prepayments**, **deposits**, **time-bound commitments** + +Same interface as PaymentTreasury, but with on-chain time windows. After the campaign deadline plus the platform claim delay, the platform admin can call `claimExpiredFunds()` to sweep idle balances on-chain (recipients are defined by the contract); align end-customer refunds with your product policy. + +- [Automotive Prepayment](prepayment/automotive-prepayment.md) — vehicle deposit with 6-month delivery window + +### CampaignInfoFactory + KeepWhatsRaised + +Best for: **flexible funding**, **hardware startups**, **ongoing projects** + +Like AllOrNothing, creates a campaign with goals and deadlines, but the creator keeps whatever is raised. Supports partial withdrawals (with platform approval), tips, payment gateway fees, and configurable refund delays. + +- [Community Project](flexible-funding/community-project.md) — hardware startup with partial withdrawals and tips + +### CampaignInfoFactory + AllOrNothing + +Best for: **crowdfunding**, **fundraising**, **community-driven projects** + +Creates a campaign with a goal and deadline. Pledges mint NFTs. If the goal is met, the creator withdraws. If not, backers get full refunds. Supports reward tiers with physical/digital items. + +- [Creative Campaign](crowdfunding/creative-campaign.md) — indie film funding with reward tiers + +### ItemRegistry + +Best for: **physical goods**, **product catalogs**, **shipping/compliance** + +Stores physical item attributes (weight, dimensions, category) on-chain. Useful for dispute resolution, customs declarations, and shipping calculations. + +- [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) — product registration alongside PaymentTreasury + +## Common Patterns Across All Demos + +### Simulate Before Send + +Every write operation should be simulated first to catch errors without spending gas: + +```typescript +await entity.simulate.someMethod(args); // dry run — reverts throw typed errors +const txHash = await entity.someMethod(args); // actual transaction +await oak.waitForReceipt(txHash); +``` + +### Multicall for Dashboard Reads + +Batch multiple reads into a single RPC call: + +```typescript +const [raised, available, refunded] = await oak.multicall([ + () => treasury.getRaisedAmount(), + () => treasury.getAvailableRaisedAmount(), + () => treasury.getRefundedAmount(), +]); +``` + +### Fee Lifecycle + +Fees are always disbursed before withdrawal: + +```typescript +await treasury.disburseFees(); // protocol + platform fees distributed +await treasury.withdraw(); // remaining funds to the campaign owner/seller +``` + +### Signer Flexibility + +The SDK supports three levels of signer configuration for different architectures: + +```typescript +// Client-level (most common for backends) +const oak = createOakContractsClient({ privateKey: PLATFORM_KEY, ... }); + +// Per-entity (useful for dApps after wallet connect) +const treasury = oak.paymentTreasury(address, { signer: walletClient }); + +// Per-call (multi-role systems) +await treasury.confirmPayment(id, buyer, { signer: adminWalletClient }); +``` + +## Related Resources + +- [API Reference Examples](../examples/) — executable TypeScript examples organized by contract entity +- [SDK README](../../README.md) — installation, quick start, and full API reference +- [Error Handling Guide](../examples/05-error-handling/) — simulation, typed errors, and safe transaction patterns +- [Advanced Patterns](../examples/06-advanced-patterns/) — multicall, signers, browser wallets, Privy diff --git a/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md new file mode 100644 index 00000000..09b9e7dc --- /dev/null +++ b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md @@ -0,0 +1,446 @@ +# Crowdfunding Campaign — ArtFund + +## The Business + +**ArtFund** is a creative crowdfunding platform where filmmakers, musicians, and artists raise funds for their projects. Campaigns have a funding goal and a deadline. If the goal is met, the creator receives the funds. If not, every backer gets a full refund. Backers can select reward tiers (digital downloads, signed merchandise, premiere tickets) when pledging. + +## Why Oak? + +ArtFund needs: + +- **All-or-nothing funding** — the creator only gets funds if the goal is met; backers are automatically refunded otherwise +- **Campaign creation** — on-chain campaign with goal, deadline, metadata, and NFT-backed pledges +- **Reward tiers** — backers select a tier when pledging; each tier has a minimum value and can include physical items +- **Pledge tracking** — each pledge mints an NFT representing the backer's contribution and selected reward +- **Transparent progress** — raised amount, goal, deadline all readable on-chain +- **Dual fee model** — protocol fees and platform fees tracked and disbursed separately + +## Oak Contracts Used + +| Contract | Purpose | +|----------|---------| +| **CampaignInfoFactory** | Creates campaign instances with metadata, goal, and deadline | +| **CampaignInfo** | Stores campaign state, pledge NFTs, platform/fee configuration | +| **TreasuryFactory** | Deploys the AllOrNothing treasury for the campaign | +| **AllOrNothing** | Holds pledged funds; enforces goal-or-refund logic | + +## Multi-token support + +The protocol is **multi-token**: **`GlobalParams`** defines each **currency** as **one or more ERC-20 addresses** (`initialize` seeds `tokensPerCurrency`; the protocol admin uses **`addTokenToCurrency`** / **`removeTokenFromCurrency`**; **`getTokensForCurrency`** reads the list). **`CampaignInfoFactory`** copies that list onto **`CampaignInfo`** at creation; each pledge passes **`pledgeToken`** and the treasury checks **`CampaignInfo.isTokenAccepted`**. In the SDK, **`campaign.getAcceptedTokens()`** returns the cached whitelist for UI and validation. + +Raised balances and refunds are tracked **per token**; amounts use **that token’s decimals** (reward values in pledge flows are denormalized from 18-decimal form where applicable). This guide uses **USDC as an example**—substitute any accepted token for your deployment. + +## Roles + +| Role | Who | On-Chain Functions | +|------|-----|--------------------| +| **Platform Admin** | ArtFund backend | `createCampaign`, `deploy` (treasury), `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | +| **Creator (Campaign Owner)** | Maya (indie filmmaker) | `addRewards`, `removeReward`, `cancelTreasury` | +| **Backer** | Community supporters | ERC-20 `approve`, `pledgeForAReward`, `pledgeWithoutAReward`, `claimRefund` | +| **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | +| **Any caller** | Anyone | `disburseFees`, `withdraw`, all read functions (`getReward`, `getRaisedAmount`, `paused`, etc.) | + +## Integration Flow + +### Step 1: Creator submits campaign — create on-chain + +> **Role: Platform Admin** — only the enlisted platform can create campaigns through the factory. + +Maya wants to fund her documentary "Voices of the Valley." She needs 10,000 USDC and sets a 30-day deadline. ArtFund's backend creates the campaign on-chain. + +```typescript +import { + createOakContractsClient, CHAIN_IDS, toHex, keccak256, id, addDays, +} from "@oaknetwork/contracts-sdk"; +import type { CreateCampaignParams } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL, + privateKey: process.env.PLATFORM_PRIVATE_KEY as `0x${string}`, +}); + +const factory = oak.campaignInfoFactory(CAMPAIGN_INFO_FACTORY_ADDRESS); + +const platformHash = keccak256(toHex("ArtFund")); +const identifierHash = keccak256(toHex("voices-of-the-valley-2026")); + +const now = BigInt(Math.floor(Date.now() / 1000)); + +const params: CreateCampaignParams = { + creator: MAYA_WALLET_ADDRESS, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now, + deadline: now + BigInt(30 * 86400), // 30 days from now + goalAmount: 10_000_000000n, // 10,000 USDC (6 decimals) + currency: toHex("USD", { size: 32 }), + }, + nftName: "Voices of the Valley Backers", + nftSymbol: "VOTV", + nftImageURI: "ipfs://QmExampleImageHash", + contractURI: "ipfs://QmExampleContractMetadata", +}; + +await factory.simulate.createCampaign(params); +const txHash = await factory.createCampaign(params); +const receipt = await oak.waitForReceipt(txHash); +``` + +### Step 2: Look up the deployed CampaignInfo + +> **Role: Any caller** — all read functions are public. + +After creation, the factory maps the identifier hash to a CampaignInfo contract. ArtFund resolves the address. + +```typescript +const campaignInfoAddress = await factory.identifierToCampaignInfo(identifierHash); + +const campaign = oak.campaignInfo(campaignInfoAddress); + +// Verify campaign details +const [goal, deadline, currency] = await oak.multicall([ + () => campaign.getGoalAmount(), + () => campaign.getDeadline(), + () => campaign.getCampaignCurrency(), +]); +``` + +### Step 3: Deploy the AllOrNothing treasury + +> **Role: Platform Admin** — only the platform admin can deploy treasuries via the factory. + +ArtFund deploys an AllOrNothing treasury linked to Maya's campaign. The treasury enforces the all-or-nothing rule: if the goal is met by the deadline, the creator withdraws; if not, backers refund. + +```typescript +const treasuryFactory = oak.treasuryFactory(TREASURY_FACTORY_ADDRESS); + +// Implementation ID 0 = AllOrNothing +const txHash = await treasuryFactory.deploy( + platformHash, campaignInfoAddress, 0n, +); +const receipt = await oak.waitForReceipt(txHash); + +// The treasury address is emitted in the TreasuryDeployed event +// Parse it from receipt logs using the event helpers +``` + +Once deployed, connect to the treasury: + +```typescript +const aonTreasury = oak.allOrNothingTreasury(DEPLOYED_TREASURY_ADDRESS); +``` + +### Step 4: Add reward tiers + +> **Role: Creator (Campaign Owner)** — only the campaign owner can add or remove rewards. + +Maya defines three reward tiers for backers. + +```typescript +import type { TieredReward } from "@oaknetwork/contracts-sdk"; + +const rewardNames = [ + toHex("digital-download", { size: 32 }), + toHex("signed-poster", { size: 32 }), + toHex("premiere-tickets", { size: 32 }), +]; + +const rewards: TieredReward[] = [ + { + rewardValue: 25_000000n, // Minimum 25 USDC (6 decimals) + isRewardTier: true, + itemId: [], + itemValue: [], + itemQuantity: [], + }, + { + rewardValue: 100_000000n, // Minimum 100 USDC + isRewardTier: true, + itemId: [toHex("signed-poster-item", { size: 32 })], + itemValue: [50_000000n], + itemQuantity: [1n], + }, + { + rewardValue: 500_000000n, // Minimum 500 USDC + isRewardTier: true, + itemId: [ + toHex("premiere-ticket", { size: 32 }), + toHex("signed-poster-item", { size: 32 }), + ], + itemValue: [200_000000n, 50_000000n], + itemQuantity: [2n, 1n], + }, +]; + +await aonTreasury.simulate.addRewards(rewardNames, rewards); +const txHash = await aonTreasury.addRewards(rewardNames, rewards); +await oak.waitForReceipt(txHash); +``` + +### Step 4b: Read and remove reward tiers + +> **Role: Any caller** for `getReward` (read). **Creator (Campaign Owner)** for `removeReward` (write). + +ArtFund can verify a reward tier's configuration, and Maya can remove one that's no longer needed. + +```typescript +// Read a specific reward tier +const reward = await aonTreasury.getReward(toHex("signed-poster", { size: 32 })); +// reward.rewardValue — minimum pledge amount (in 18-decimal normalized form) +// reward.isRewardTier — true for tiered rewards +// reward.itemId — physical/digital item IDs included +// reward.itemValue — declared value of each item +// reward.itemQuantity — quantity of each item + +// Remove a reward tier (only before campaign ends, only by campaign owner) +const txHash = await aonTreasury.removeReward(toHex("digital-download", { size: 32 })); +await oak.waitForReceipt(txHash); +``` + +### Step 5: Backers pledge + +> **Role: Backer** — any wallet can pledge. The backer must first approve the treasury to transfer their ERC-20 tokens. + +Supporters pledge to Maya's campaign, optionally selecting reward tiers. Before any pledge, the backer must approve the AllOrNothing treasury contract to spend the pledge amount on their behalf. This is a standard ERC-20 approval: + +```typescript +import { erc20Abi } from "viem"; + +const usdc = { address: USDC_TOKEN_ADDRESS, abi: erc20Abi }; + +// Backer approves the treasury to spend up to 100 USDC +const approveTx = await walletClient.writeContract({ + ...usdc, + functionName: "approve", + args: [DEPLOYED_TREASURY_ADDRESS, 100_000000n], +}); +await publicClient.waitForTransactionReceipt({ hash: approveTx }); +``` + +**Pledge with a reward tier:** + +```typescript +// Backer selects the "signed-poster" tier (100 USDC minimum) +const txHash = await aonTreasury.pledgeForAReward( + BACKER_ADDRESS, + USDC_TOKEN_ADDRESS, + 0n, // no shipping fee + [toHex("signed-poster", { size: 32 })], // selected reward +); +await oak.waitForReceipt(txHash); +``` + +**Pledge without a reward:** + +```typescript +// Backer pledges 50 USDC with no reward selection +const txHash = await aonTreasury.pledgeWithoutAReward( + BACKER_ADDRESS, + USDC_TOKEN_ADDRESS, + 50_000000n, // 50 USDC (6 decimals) +); +await oak.waitForReceipt(txHash); +``` + +**Pledge for multiple rewards in a single call:** + +The contract supports selecting multiple reward tiers in one pledge. The first element must be a reward tier; subsequent elements can be either tiers or non-tier rewards. The total pledge amount is the sum of all selected rewards' values. + +```typescript +const txHash = await aonTreasury.pledgeForAReward( + BACKER_ADDRESS, + USDC_TOKEN_ADDRESS, + 10_000000n, // $10 shipping fee + [ + toHex("signed-poster", { size: 32 }), // primary reward tier + toHex("digital-download", { size: 32 }), // additional reward + ], +); +await oak.waitForReceipt(txHash); +``` + +Each pledge mints an NFT to the backer. The NFT carries the pledge metadata (amount, reward, treasury address). + +### Step 6: Monitor campaign progress + +> **Role: Any caller** — all read functions are public. + +ArtFund's campaign page shows live progress. + +```typescript +// Treasury reads +const [raised, lifetime, refunded] = await oak.multicall([ + () => aonTreasury.getRaisedAmount(), + () => aonTreasury.getLifetimeRaisedAmount(), + () => aonTreasury.getRefundedAmount(), +]); + +// Campaign reads +const [goal, deadline, pledgeCount] = await oak.multicall([ + () => campaign.getGoalAmount(), + () => campaign.getDeadline(), + () => campaign.getPledgeCount(), +]); + +// Progress: raised / goal +// Time remaining: deadline - now +// Total backers: pledgeCount +``` + +### Step 7: Disburse fees + +> **Role: Any caller** — `disburseFees` is permissionless, but it only succeeds after the deadline when the goal is met. Fees are sent to the Protocol Admin and Platform Admin automatically. + +Once the deadline has passed and the goal is met, anyone can trigger fee disbursement. This distributes the protocol fee to the Oak Protocol Admin and the platform fee to ArtFund. + +```typescript +const txHash = await aonTreasury.disburseFees(); +await oak.waitForReceipt(txHash); +``` + +### Step 8 (Success): Goal met — creator withdraws + +> **Role: Any caller** — `withdraw` is permissionless, but it requires `disburseFees` to have been called first. Funds are always sent to the campaign owner (Maya). + +If `raised >= goal` when the deadline passes, anyone can trigger the withdrawal. The remaining funds (after fees) are sent to Maya. + +```typescript +const txHash = await aonTreasury.withdraw(); +await oak.waitForReceipt(txHash); +// Funds are sent to the campaign creator (Maya) +``` + +### Step 8 (Failure): Goal not met — backers claim refunds + +> **Role: Any caller** — `claimRefund` is permissionless, but the refund is always sent to the current NFT owner. Backers can also claim refunds before the deadline if they change their mind. + +If the deadline passes and the goal was not reached, each backer can claim a refund by providing their pledge NFT token ID. The NFT is burned during the refund. + +```typescript +// Each backer calls claimRefund with their pledge NFT token ID +const txHash = await aonTreasury.claimRefund(tokenId); +await oak.waitForReceipt(txHash); +// Backer receives their pledge amount back; NFT is burned +``` + +### Step 9: Pause, unpause, or cancel the treasury + +**Pause the treasury:** + +> **Role: Platform Admin** — only the platform admin can pause and unpause. + +If ArtFund needs to halt operations for compliance or investigation, the platform admin pauses the treasury. While paused, no pledges, refunds, fee disbursement, or withdrawals can occur. + +```typescript +const txHash = await aonTreasury.pauseTreasury(toHex("compliance-review", { size: 32 })); +await oak.waitForReceipt(txHash); + +// Check pause status (any caller can read) +const isPaused = await aonTreasury.paused(); +``` + +**Unpause the treasury:** + +> **Role: Platform Admin** + +```typescript +const txHash = await aonTreasury.unpauseTreasury(toHex("review-complete", { size: 32 })); +await oak.waitForReceipt(txHash); +``` + +**Cancel the treasury permanently:** + +> **Role: Platform Admin or Creator (Campaign Owner)** — either party can cancel the treasury. + +Cancellation is irreversible. After cancellation, backers can still claim refunds, but no new pledges, fee disbursement, or withdrawals can happen. + +```typescript +const txHash = await aonTreasury.cancelTreasury(toHex("campaign-abandoned", { size: 32 })); +await oak.waitForReceipt(txHash); + +const isCancelled = await aonTreasury.cancelled(); +``` + +### Reading pledge NFT data + +> **Role: Any caller** — all read functions are public. + +Pledge NFTs are standard ERC-721 tokens minted by the CampaignInfo contract. Backers can transfer or manage them using standard ERC-721 operations (`safeTransferFrom`, `approve`, `setApprovalForAll`). If a pledge NFT is transferred, the new owner becomes eligible to claim the refund (on failure) or holds the reward entitlement. + +Each pledge NFT stores on-chain metadata accessible through CampaignInfo: + +```typescript +const pledgeData = await campaign.getPledgeData(tokenId); +// pledgeData.backer — backer wallet address +// pledgeData.reward — selected reward (bytes32) +// pledgeData.amount — pledge amount +// pledgeData.treasury — treasury address +// pledgeData.tokenAddress — ERC-20 token used + +const nftOwner = await campaign.ownerOf(tokenId); +const tokenURI = await campaign.tokenURI(tokenId); +``` + +## Architecture Diagram + +``` +Creator (Maya) ArtFund (Platform Admin) Blockchain + | | | + | Submit campaign | | + |----------------------->| createCampaign(...) | + | |--------------------------->| CampaignInfo deployed + | | | + | | deploy(platformHash, | + | | campaignInfo, 0) | + | |--------------------------->| AllOrNothing treasury deployed + | | | + | Add rewards | addRewards(...) | + | [Campaign Owner] | | + |----------------------->|--------------------------->| Reward tiers registered + | | | + | Read/remove reward | getReward() / | + | [Anyone / Owner] | removeReward() | + |----------------------->|--------------------------->| Reward read or removed + | | | +Backers | | + | ERC-20 approve() | | + |---------------------------------------------------->| Treasury approved to spend + | | | + | pledgeForAReward() | | + | (single or multi) | | + |----------------------->|--------------------------->| NFT minted, funds locked + | pledgeWithoutReward()| | + |----------------------->|--------------------------->| NFT minted, funds locked + | | | + | [Platform Admin or | pauseTreasury() / | + | Campaign Owner] | unpauseTreasury() / | + | | cancelTreasury() | + | |--------------------------->| Treasury state updated + | | | + | --- DEADLINE REACHED --- | + | | | + | [Any caller] | disburseFees() | + | |--------------------------->| Fees → Protocol + Platform + | | | + | [Any caller] | withdraw() | + | |--------------------------->| Funds → Creator (Maya) + | | | + | [Any caller] | claimRefund(tokenId) | + | |--------------------------->| Refund → NFT owner, NFT burned +``` + +## Key Takeaways + +- **All-or-nothing is enforced by the contract** — there is no way for the creator to withdraw if the goal is not met +- **ERC-20 approval is required** — backers must `approve` the treasury to transfer tokens before pledging +- **Multi-token campaigns** — each pledge names `pledgeToken`; only addresses whitelisted via `isTokenAccepted` are allowed; raised balances and refunds are per token (native decimals) +- **NFT-backed pledges** give backers a verifiable, transferable proof of their contribution +- **Reward tiers** can be added, read, and removed dynamically by the campaign owner before the campaign ends +- **Multi-reward pledges** — backers can select multiple rewards in a single pledge call +- **Role-based access** — `addRewards`/`removeReward` are owner-only; `pauseTreasury`/`unpauseTreasury` are platform-admin-only; `cancelTreasury` can be called by either; `disburseFees`, `withdraw`, and `claimRefund` are permissionless +- **Pause / cancel controls** — the platform admin can pause operations; both platform admin and campaign owner can permanently cancel +- **`multicall`** combines treasury and campaign reads for efficient dashboard rendering +- **Two-phase fee model** — `disburseFees()` before `withdraw()` ensures fees are handled correctly +- **Campaign metadata** (name, symbol, image URI) makes the pledge NFTs meaningful and displayable in wallets diff --git a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md new file mode 100644 index 00000000..1b1fec72 --- /dev/null +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -0,0 +1,358 @@ +# Healthcare Escrow — MedConnect + +## The Business + +**MedConnect** is a healthcare platform that connects patients with specialist doctors for consultations, lab work, and follow-up care. Patients pay upfront, but their funds are held in escrow until the doctor confirms the service was delivered. If the service is not delivered within the agreed timeframe, the patient gets a full refund. + +## Why Oak? + +MedConnect needs a trustless escrow mechanism that: + +- Holds patient payments securely until service confirmation +- Allows the platform to confirm delivery on behalf of the provider +- Enables automatic refunds if service is not delivered +- Tracks fees (platform booking fee, protocol fee) transparently +- Works with **any accepted ERC-20** on the campaign’s token whitelist (examples below use USDC for readability) + +## Oak Contract Used + +**PaymentTreasury** — a smart contract that holds funds until the platform confirms service delivery. Supports line items, external fees, and refund flows. + +### Multi-token support + +Payments specify **`paymentToken`**; the contract reverts unless **`CampaignInfo.isTokenAccepted(paymentToken)`** is true. The campaign may accept **several ERC-20s** for one logical currency; pending, confirmed, fee, and refund accounting is **per token address** in each token’s **native decimals**. Snippets in this guide use **USDC** as a stand-in—use any whitelisted token your `GlobalParams` / campaign configuration allows. Resolve the list with **`globalParams.getTokensForCurrency(currency)`** or read the campaign’s cached copy via **`campaign.getAcceptedTokens()`** (same addresses the factory stored at creation). + +## Roles + +| Role | Who | On-Chain Functions | +|------|-----|--------------------| +| **Platform Admin** | MedConnect backend | `createPayment`, `createPaymentBatch`, `confirmPayment`, `confirmPaymentBatch`, `cancelPayment`, `claimRefund(paymentId, address)` (non-NFT), `claimExpiredFunds`, `claimNonGoalLineItems`, `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | +| **Platform Admin or Campaign Owner** | MedConnect or clinic | `withdraw`, `cancelTreasury` | +| **Patient (Buyer)** | Sarah | ERC-20 `approve`, `processCryptoPayment`, `claimRefund(paymentId)` (NFT payments) | +| **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | +| **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `getExpectedAmount`, `paused`, etc.) | + +## Integration Flow + +### Step 1: Connect to the deployed PaymentTreasury + +> **Role: Any caller** — connecting and reading treasury state is public. + +MedConnect has a PaymentTreasury deployed for its healthcare escrow pool. The backend connects using the SDK. + +```typescript +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL, + privateKey: process.env.PLATFORM_PRIVATE_KEY as `0x${string}`, +}); + +const treasury = oak.paymentTreasury(TREASURY_ADDRESS); + +// Verify the treasury is operational +const isPaused = await treasury.paused(); +const isCancelled = await treasury.cancelled(); +``` + +### Step 2: Patient books appointment — create payment + +> **Role: Platform Admin** — only the platform admin can create payment records. + +Sarah books a cardiology consultation with Dr. Rivera. The appointment costs 150 USDC broken down into two line items: consultation (120 USDC) and lab work (30 USDC). + +```typescript +import { toHex } from "@oaknetwork/contracts-sdk"; + +const paymentId = toHex("medconnect-appt-12345", { size: 32 }); +const buyerId = toHex("patient-sarah-001", { size: 32 }); +const itemId = toHex("cardiology-consult", { size: 32 }); + +const lineItems = [ + { typeId: toHex("consultation", { size: 32 }), amount: 120_000000n }, // 120 USDC (6 decimals) + { typeId: toHex("lab-work", { size: 32 }), amount: 30_000000n }, // 30 USDC +]; + +const externalFees = [ + { feeType: toHex("platform-booking-fee", { size: 32 }), feeAmount: 5_000000n }, // 5 USDC +]; + +// Simulate first to catch errors before spending gas +await treasury.simulate.createPayment( + paymentId, buyerId, itemId, USDC_TOKEN_ADDRESS, + 150_000000n, // total: 150 USDC + BigInt(Math.floor(Date.now() / 1000) + 7 * 86400), // expires in 7 days + lineItems, externalFees, +); + +// Send the transaction +const txHash = await treasury.createPayment( + paymentId, buyerId, itemId, USDC_TOKEN_ADDRESS, + 150_000000n, + BigInt(Math.floor(Date.now() / 1000) + 7 * 86400), + lineItems, externalFees, +); +await oak.waitForReceipt(txHash); +``` + +At this point the payment record exists on-chain, but **no funds have moved yet**. The treasury is waiting for Sarah to pay. + +### Step 3: Patient pays — crypto payment processed on-chain + +> **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. + +Sarah opens the MedConnect app, sees the $150 charge, and approves the transfer. Before the treasury can pull funds, Sarah must grant an ERC-20 allowance: + +```typescript +import { erc20Abi } from "viem"; + +const usdc = { address: USDC_TOKEN_ADDRESS, abi: erc20Abi }; + +// Sarah approves the treasury to spend up to 150 USDC +const approveTx = await walletClient.writeContract({ + ...usdc, + functionName: "approve", + args: [TREASURY_ADDRESS, 150_000000n], +}); +await publicClient.waitForTransactionReceipt({ hash: approveTx }); +``` + +Now the payment can be processed: + +```typescript +const txHash = await treasury.processCryptoPayment( + paymentId, itemId, SARAH_WALLET_ADDRESS, USDC_TOKEN_ADDRESS, + 150_000000n, + lineItems, externalFees, +); +await oak.waitForReceipt(txHash); +``` + +Funds are now **locked in the treasury**. Sarah cannot withdraw them, and neither can Dr. Rivera — only the platform can release them by confirming delivery. + +### Step 4: Doctor confirms service delivery + +> **Role: Platform Admin** — only the platform admin can confirm payments. + +Dr. Rivera completes the consultation and marks it as delivered in the MedConnect dashboard. The backend calls `confirmPayment` to release the escrowed funds. + +```typescript +await treasury.simulate.confirmPayment(paymentId, SARAH_WALLET_ADDRESS); + +const txHash = await treasury.confirmPayment(paymentId, SARAH_WALLET_ADDRESS); +await oak.waitForReceipt(txHash); +``` + +The payment status is now **confirmed**. Funds are settled and ready for fee disbursement and withdrawal. + +### Step 5: Read the final treasury state + +> **Role: Any caller** — all read functions are public. + +MedConnect's dashboard shows the current state of the escrow pool. + +```typescript +const [raised, available, lifetime, refunded] = await oak.multicall([ + () => treasury.getRaisedAmount(), + () => treasury.getAvailableRaisedAmount(), + () => treasury.getLifetimeRaisedAmount(), + () => treasury.getRefundedAmount(), +]); +``` + +### Step 6: Disburse fees + +> **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. + +Before the provider can withdraw, accumulated protocol and platform fees are distributed. + +```typescript +const txHash = await treasury.disburseFees(); +await oak.waitForReceipt(txHash); +``` + +### Step 7: Withdraw settled funds + +> **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (Dr. Rivera's clinic). + +The settled amount (minus fees) is sent to the campaign owner. + +```typescript +const txHash = await treasury.withdraw(); +await oak.waitForReceipt(txHash); +``` + +### Alternative: Cancel and refund flow + +> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, address)`. **Any caller** for `claimRefund(paymentId)` (NFT payments only — refund goes to current NFT owner). + +If Sarah needs to cancel the appointment before the doctor confirms delivery, MedConnect cancels the payment and initiates a refund. + +**For off-chain payments (no NFT minted):** + +```typescript +// Platform cancels the unconfirmed payment +await treasury.cancelPayment(paymentId); + +// Platform initiates refund to Sarah's address +await treasury.claimRefund(paymentId, SARAH_WALLET_ADDRESS); +``` + +**For crypto payments (NFT was minted via `processCryptoPayment` or `confirmPayment` with buyerAddress):** + +```typescript +// Anyone can trigger the refund — funds go to the current NFT owner, and the NFT is burned +await treasury.claimRefund(paymentId); +``` + +### Step 8: Claim non-goal line items + +> **Role: Platform Admin** — only the platform admin can claim non-goal line items. + +If the payment included line items that don't count toward the campaign goal (e.g., platform commission, processing fees), these accumulate separately. The platform admin can claim them at any time after confirmation. + +```typescript +const txHash = await treasury.claimNonGoalLineItems(USDC_TOKEN_ADDRESS); +await oak.waitForReceipt(txHash); +``` + +### Step 9: Pause, unpause, or cancel the treasury + +**Pause the treasury:** + +> **Role: Platform Admin** — only the platform admin can pause and unpause. + +If MedConnect needs to halt operations for compliance or investigation: + +```typescript +const txHash = await treasury.pauseTreasury(toHex("compliance-review", { size: 32 })); +await oak.waitForReceipt(txHash); + +const isPaused = await treasury.paused(); +``` + +While paused, no payments, confirmations, refunds, or withdrawals can occur. + +**Unpause the treasury:** + +> **Role: Platform Admin** + +```typescript +const txHash = await treasury.unpauseTreasury(toHex("review-complete", { size: 32 })); +await oak.waitForReceipt(txHash); +``` + +**Cancel the treasury permanently:** + +> **Role: Platform Admin or Campaign Owner** — either party can cancel. + +Cancellation is irreversible. After cancellation, backers can still claim refunds for confirmed NFT payments, but no new payments, confirmations, or withdrawals can happen. + +```typescript +const txHash = await treasury.cancelTreasury(toHex("treasury-shutdown", { size: 32 })); +await oak.waitForReceipt(txHash); + +const isCancelled = await treasury.cancelled(); +``` + +### Batch operations + +> **Role: Platform Admin** — batch create and confirm are platform-admin-only. + +For high-volume platforms, PaymentTreasury supports batch operations to reduce gas costs and prevent nonce conflicts. + +**Batch create payments:** + +```typescript +const paymentIds = [ + toHex("appt-001", { size: 32 }), + toHex("appt-002", { size: 32 }), +]; +const buyerIds = [ + toHex("patient-sarah", { size: 32 }), + toHex("patient-john", { size: 32 }), +]; +const itemIds = [ + toHex("cardiology", { size: 32 }), + toHex("dermatology", { size: 32 }), +]; +const tokens = [USDC_TOKEN_ADDRESS, USDC_TOKEN_ADDRESS]; +const amounts = [150_000000n, 200_000000n]; +const expirations = [ + BigInt(Math.floor(Date.now() / 1000) + 7 * 86400), + BigInt(Math.floor(Date.now() / 1000) + 7 * 86400), +]; + +const txHash = await treasury.createPaymentBatch( + paymentIds, buyerIds, itemIds, tokens, amounts, expirations, + [lineItems1, lineItems2], [externalFees1, externalFees2], +); +await oak.waitForReceipt(txHash); +``` + +**Batch confirm payments:** + +```typescript +const txHash = await treasury.confirmPaymentBatch( + [toHex("appt-001", { size: 32 }), toHex("appt-002", { size: 32 })], + [SARAH_WALLET_ADDRESS, JOHN_WALLET_ADDRESS], +); +await oak.waitForReceipt(txHash); +``` + +## Architecture Diagram + +``` +Patient (Sarah) MedConnect (Platform Admin) PaymentTreasury + | | | + | Books appointment | | + |------------------------------->| | + | | createPayment(...) | + | | [Platform Admin] | + | |------------------------------>| Payment record created + | | | + | ERC-20 approve() | | + |--------------------------------------------------------------->| Treasury approved to spend + | | | + | | processCryptoPayment(...) | + | | [Any caller] | + | |------------------------------>| Funds locked in escrow + | | | + | Doctor confirms delivery | + | | confirmPayment(...) | + | | [Platform Admin] | + | |------------------------------>| Funds settled + | | | + | | claimNonGoalLineItems() | + | | [Platform Admin] | + | |------------------------------>| Non-goal items claimed + | | | + | | disburseFees() | + | | [Any caller] | + | |------------------------------>| Fees → Protocol + Platform + | | | + | | withdraw() | + | | [Admin or Owner] | + | |------------------------------>| Funds → Provider + | | | + | [Optional] Pause / Cancel | pauseTreasury() / | + | | cancelTreasury() | + | | [Platform Admin / Owner] | + | |------------------------------>| Treasury state updated +``` + +## Key Takeaways + +- **ERC-20 approval is required** — the buyer must `approve` the treasury contract before `processCryptoPayment` can transfer tokens +- **Multi-token** — `paymentToken` must be on the campaign’s accepted list; balances and refunds are tracked per ERC-20 (each token’s decimals) +- **Funds are never at risk** — they stay locked in the smart contract until service is confirmed +- **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner +- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefund(paymentId)` for NFT payments (permissionless, refund to NFT owner) +- **Line items** allow granular tracking (consultation vs. lab work) with configurable goal-counting, fees, and refund rules +- **Non-goal line items** (e.g., platform commission) can be claimed separately via `claimNonGoalLineItems` +- **Batch operations** — `createPaymentBatch` and `confirmPaymentBatch` for high-volume platforms +- **Pause / cancel controls** — platform admin can pause; either admin or owner can permanently cancel +- **External fees** track platform charges transparently on-chain (informational only, no financial impact) +- **Simulate before send** catches errors before spending gas +- **`multicall`** batches multiple reads into a single RPC call for dashboard views diff --git a/packages/contracts/src/use-cases/flexible-funding/community-project.md b/packages/contracts/src/use-cases/flexible-funding/community-project.md new file mode 100644 index 00000000..a1a128a4 --- /dev/null +++ b/packages/contracts/src/use-cases/flexible-funding/community-project.md @@ -0,0 +1,395 @@ +# Flexible Funding — TechForge + +## The Business + +**TechForge** is a technology platform that helps hardware startups raise funds from their community. Unlike all-or-nothing crowdfunding, TechForge uses a **keep-what's-raised** model: creators keep whatever they raise, even if they don't hit their goal. This works well for hardware projects where any amount of funding helps move the project forward. + +TechForge also lets backers **tip** creators, charges **payment gateway fees** on each pledge, and allows creators to make **partial withdrawals** during the campaign (with platform approval) to cover manufacturing costs before the deadline. + +## Why Oak? + +TechForge needs: + +- **Flexible funding** — creators keep whatever is raised, no all-or-nothing threshold +- **Partial withdrawals** — creators can withdraw funds mid-campaign with platform approval +- **Tips** — backers can tip on top of their pledge; tips are claimable separately +- **Payment gateway fees** — per-pledge fees recorded on-chain for transparent accounting +- **Configurable fee structure** — flat fees, percentage fees, and cumulative fee caps +- **Refund delay** — backers can refund, but only after a configurable delay period post-deadline +- **Reward tiers** — like all-or-nothing, but with the flexibility of partial delivery + +## Oak Contracts Used + +| Contract | Purpose | +|----------|---------| +| **CampaignInfoFactory** | Creates campaign instances with metadata, goal, and deadline | +| **CampaignInfo** | Stores campaign state, pledge NFTs, platform/fee configuration | +| **TreasuryFactory** | Deploys the KeepWhatsRaised treasury for the campaign | +| **KeepWhatsRaised** | Holds pledged funds; supports partial withdrawals, tips, configurable fees | + +## Multi-token support + +Campaigns accept a **whitelist of ERC-20s** resolved from the campaign **currency**; each pledge and withdrawal names **`pledgeToken` / `token`** explicitly, and the treasury enforces **`isTokenAccepted`**. Balances, tips, gateway fees, and withdrawals are tracked **per token contract** (each token’s decimals). Examples below use **USDC**; TechForge can enable additional accepted tokens the same way—**`GlobalParams`** maintains **`currencyToTokens`** (`initialize`, then **`addTokenToCurrency`** / **`removeTokenFromCurrency`**); **`campaign.getAcceptedTokens()`** lists what a given campaign accepts after creation. + +## How KeepWhatsRaised Differs from AllOrNothing + +| Feature | AllOrNothing | KeepWhatsRaised | +|---------|-------------|-----------------| +| Funding outcome | Goal met = creator gets funds; goal not met = full refund | Creator keeps whatever is raised | +| Partial withdrawals | Not supported | Supported with platform approval | +| Tips | Not supported | Backers can tip; platform claims tips separately | +| Payment gateway fees | Not supported | Per-pledge fee tracking via `setPaymentGatewayFee` | +| Treasury configuration | Not needed | Required — delays, refund policy, fee structure | +| Refund timing | Immediate after deadline (if goal not met) | After deadline + configurable refund delay | +| Withdrawal approval | Not needed | Platform must call `approveWithdrawal` first | +| Fund claiming | `withdraw()` by anyone | `claimFund()` by platform after claim delay | + +## Roles + +| Role | Who | Actions | +|------|-----|---------| +| Platform Admin | TechForge backend | Configures treasury, approves withdrawals, claims tips/funds | +| Creator | Lena (hardware startup founder) | Creates campaign, adds rewards, withdraws approved amounts | +| Backer | Community supporters | Pledges with/without rewards, can tip, claims refund after delay | + +## Integration Flow + +### Step 1: Create the campaign + +Lena wants to fund her open-source IoT sensor kit. She needs $15,000 ideally but any amount helps. TechForge creates the campaign on-chain. + +```typescript +import { + createOakContractsClient, CHAIN_IDS, toHex, keccak256, +} from "@oaknetwork/contracts-sdk"; +import type { CreateCampaignParams } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL, + privateKey: process.env.PLATFORM_PRIVATE_KEY as `0x${string}`, +}); + +const factory = oak.campaignInfoFactory(CAMPAIGN_INFO_FACTORY_ADDRESS); + +const platformHash = keccak256(toHex("TechForge")); +const identifierHash = keccak256(toHex("iot-sensor-kit-2026")); +const now = BigInt(Math.floor(Date.now() / 1000)); + +const params: CreateCampaignParams = { + creator: LENA_WALLET_ADDRESS, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now, + deadline: now + BigInt(45 * 86400), // 45 days + goalAmount: 15_000_000000n, // 15,000 USDC (6 decimals) + currency: toHex("USD", { size: 32 }), + }, + nftName: "IoT Sensor Kit Backers", + nftSymbol: "IOTSK", + nftImageURI: "ipfs://QmSensorKitImage", + contractURI: "ipfs://QmSensorKitMetadata", +}; + +await factory.simulate.createCampaign(params); +const txHash = await factory.createCampaign(params); +await oak.waitForReceipt(txHash); +``` + +### Step 2: Deploy the KeepWhatsRaised treasury + +TechForge deploys a KWR treasury for Lena's campaign. Implementation ID `1` = KeepWhatsRaised. + +```typescript +const treasuryFactory = oak.treasuryFactory(TREASURY_FACTORY_ADDRESS); + +// Implementation ID 1 = KeepWhatsRaised +const txHash = await treasuryFactory.deploy( + platformHash, campaignInfoAddress, 1n, +); +await oak.waitForReceipt(txHash); + +const kwrTreasury = oak.keepWhatsRaisedTreasury(DEPLOYED_KWR_ADDRESS); +``` + +### Step 3: Configure the treasury + +This is **unique to KeepWhatsRaised** — the platform must configure delays, refund policy, and fee structure before the treasury is operational. + +```typescript +import type { + KeepWhatsRaisedConfig, KeepWhatsRaisedFeeKeys, KeepWhatsRaisedFeeValues, +} from "@oaknetwork/contracts-sdk"; + +const config: KeepWhatsRaisedConfig = { + minimumWithdrawalForFeeExemption: 5_000_000000n, // No flat fee on withdrawals > 5,000 USDC + withdrawalDelay: BigInt(3 * 86400), // 3-day delay after approval + refundDelay: BigInt(14 * 86400), // Backers can refund 14 days after deadline + configLockPeriod: BigInt(7 * 86400), // Config locked for 7 days after setting + isColombianCreator: false, +}; + +const campaignData = { + launchTime: now, + deadline: now + BigInt(45 * 86400), + goalAmount: 15_000_000000n, // 15,000 USDC (6 decimals) + currency: toHex("USD", { size: 32 }), +}; + +const feeKeys: KeepWhatsRaisedFeeKeys = { + flatFeeKey: toHex("flat-withdrawal-fee", { size: 32 }), + cumulativeFlatFeeKey: toHex("cumulative-flat-fee-cap", { size: 32 }), + grossPercentageFeeKeys: [ + toHex("gross-fee-tier-1", { size: 32 }), + ], +}; + +const feeValues: KeepWhatsRaisedFeeValues = { + flatFeeValue: 10_000000n, // 10 USDC flat fee per withdrawal + cumulativeFlatFeeValue: 50_000000n, // 50 USDC lifetime cap on flat fees + grossPercentageFeeValues: [250n], // 2.5% gross percentage fee +}; + +await kwrTreasury.simulate.configureTreasury(config, campaignData, feeKeys, feeValues); +const txHash = await kwrTreasury.configureTreasury(config, campaignData, feeKeys, feeValues); +await oak.waitForReceipt(txHash); +``` + +### Step 4: Add reward tiers + +Lena defines two reward tiers. + +```typescript +import type { TieredReward } from "@oaknetwork/contracts-sdk"; + +const rewardNames = [ + toHex("early-bird-kit", { size: 32 }), + toHex("developer-bundle", { size: 32 }), +]; + +const rewards: TieredReward[] = [ + { + rewardValue: 50_000000n, // Minimum 50 USDC (6 decimals) + isRewardTier: true, + itemId: [toHex("sensor-kit-v1", { size: 32 })], + itemValue: [40_000000n], + itemQuantity: [1n], + }, + { + rewardValue: 150_000000n, // Minimum 150 USDC + isRewardTier: true, + itemId: [ + toHex("sensor-kit-v1", { size: 32 }), + toHex("dev-board-pro", { size: 32 }), + ], + itemValue: [40_000000n, 80_000000n], + itemQuantity: [2n, 1n], + }, +]; + +const txHash = await kwrTreasury.addRewards(rewardNames, rewards); +await oak.waitForReceipt(txHash); +``` + +### Step 5: Backers pledge with tips and gateway fees + +Backers pledge to Lena's campaign. KWR supports **tips** (on top of the pledge) and **payment gateway fees** (recorded per-pledge). + +**Pledge with a reward and a tip:** + +```typescript +const pledgeId = toHex("pledge-001", { size: 32 }); + +const txHash = await kwrTreasury.pledgeForAReward( + pledgeId, + BACKER_ADDRESS, + USDC_TOKEN_ADDRESS, + 5_000000n, // 5 USDC tip (6 decimals) + [toHex("early-bird-kit", { size: 32 })], // selected reward +); +await oak.waitForReceipt(txHash); +``` + +**Record a payment gateway fee for the pledge:** + +```typescript +await kwrTreasury.setPaymentGatewayFee( + pledgeId, + 2_500000n, // $2.50 USDC gateway fee (6 decimals) +); +``` + +**Combined fee + pledge in one transaction:** + +```typescript +const pledgeId = toHex("pledge-002", { size: 32 }); + +const txHash = await kwrTreasury.setFeeAndPledge( + pledgeId, + BACKER_ADDRESS, + USDC_TOKEN_ADDRESS, + 75_000000n, // 75 USDC pledge (6 decimals) + 3_000000n, // 3 USDC tip + 2_000000n, // 2 USDC gateway fee + [toHex("early-bird-kit", { size: 32 })], // reward + true, // isPledgeForAReward +); +await oak.waitForReceipt(txHash); +``` + +**Pledge without a reward:** + +```typescript +const pledgeId = toHex("pledge-003", { size: 32 }); + +const txHash = await kwrTreasury.pledgeWithoutAReward( + pledgeId, + BACKER_ADDRESS, + USDC_TOKEN_ADDRESS, + 30_000000n, // 30 USDC pledge (6 decimals) + 2_000000n, // 2 USDC tip +); +await oak.waitForReceipt(txHash); +``` + +### Step 6: Mid-campaign partial withdrawal + +Lena needs funds to order components from her supplier. TechForge approves a partial withdrawal. + +**Platform approves the withdrawal:** + +```typescript +const txHash = await kwrTreasury.approveWithdrawal(); +await oak.waitForReceipt(txHash); +``` + +**After the withdrawal delay, creator withdraws:** + +```typescript +// 3 days after approval (configured withdrawalDelay) +const txHash = await kwrTreasury.withdraw( + USDC_TOKEN_ADDRESS, + 3_000_000000n, // Withdraw 3,000 USDC for component order (6 decimals) +); +await oak.waitForReceipt(txHash); +``` + +### Step 7: Monitor campaign progress + +TechForge's dashboard shows live progress with all KWR-specific metrics. + +```typescript +const [raised, available, lifetime, refunded, goal, deadline] = await oak.multicall([ + () => kwrTreasury.getRaisedAmount(), + () => kwrTreasury.getAvailableRaisedAmount(), + () => kwrTreasury.getLifetimeRaisedAmount(), + () => kwrTreasury.getRefundedAmount(), + () => kwrTreasury.getGoalAmount(), + () => kwrTreasury.getDeadline(), +]); + +const withdrawalApproved = await kwrTreasury.getWithdrawalApprovalStatus(); +``` + +### Step 8: Disburse fees + +After the campaign, protocol and platform fees are distributed. + +```typescript +const txHash = await kwrTreasury.disburseFees(); +await oak.waitForReceipt(txHash); +``` + +### Step 9: Platform claims tips + +Tips are claimed separately by the platform and forwarded to the creator. + +```typescript +const txHash = await kwrTreasury.claimTip(); +await oak.waitForReceipt(txHash); +``` + +### Step 10: Platform claims remaining funds + +After the deadline + claim delay period, the platform claims any remaining funds for the creator. + +```typescript +const txHash = await kwrTreasury.claimFund(); +await oak.waitForReceipt(txHash); +``` + +### Step 11: Backer claims refund (after refund delay) + +If a backer wants a refund, they can claim one — but only after the deadline + the configured refund delay (14 days in this example). + +```typescript +// After deadline + 14-day refund delay +const txHash = await kwrTreasury.claimRefund(backerTokenId); +await oak.waitForReceipt(txHash); +// Pledge amount refunded; NFT burned +``` + +### Optional: Update campaign parameters + +KWR allows updating the deadline and goal mid-campaign (subject to config lock period). + +```typescript +// Extend deadline by 2 weeks +const newDeadline = currentDeadline + BigInt(14 * 86400); +await kwrTreasury.updateDeadline(newDeadline); + +// Adjust goal +await kwrTreasury.updateGoalAmount(20_000_000000n); // 20,000 USDC +``` + +## Architecture Diagram + +``` +Creator (Lena) TechForge Platform KeepWhatsRaised Treasury + | | | + | Submit campaign | createCampaign(...) | + |----------------------->|------------------------------>| Campaign created + | | deploy(hash, info, 1) | + | |------------------------------>| KWR treasury deployed + | | configureTreasury(...) | + | |------------------------------>| Delays, fees configured + | Add rewards | addRewards(...) | + |----------------------->|------------------------------>| Reward tiers set + | | | +Backers pledge + tip | | + | pledgeForAReward() | | + |----------------------->|------------------------------>| NFT minted, funds + tip locked + | setPaymentGatewayFee()| | + | |------------------------------>| Gateway fee recorded + | | | + | --- MID-CAMPAIGN WITHDRAWAL --- | + | | approveWithdrawal() | + | |------------------------------>| Withdrawal approved + | withdraw(token, amt) | | + |----------------------->|------------------------------>| Partial funds to creator + | | | + | --- AFTER DEADLINE --- | + | | disburseFees() | + | |------------------------------>| Fees distributed + | | claimTip() | + | |------------------------------>| Tips to platform + | | claimFund() | + | |------------------------------>| Remaining funds to creator + | | | +Backer refund (after delay) | | + | claimRefund(tokenId) | | + |----------------------->|------------------------------>| Backer refunded, NFT burned +``` + +## Key Takeaways + +- **`configureTreasury`** is mandatory and unique to KWR — it sets withdrawal delays, refund delays, and the full fee structure before the treasury operates +- **Partial withdrawals** let creators access funds mid-campaign, but require explicit platform approval via `approveWithdrawal()` +- **Tips** are a separate fund pool claimed via `claimTip()`, distinct from pledges +- **Payment gateway fees** are tracked per-pledge with `setPaymentGatewayFee()` or combined with the pledge in `setFeeAndPledge()` +- **Refund delay** protects creators from last-minute refund rushes — backers can only refund after deadline + configured delay +- **Three claim methods** serve different purposes: `claimFund()` for main funds, `claimTip()` for tips, `claimRefund()` for backers +- **`withdraw()` takes a specific token and amount**, unlike AllOrNothing where `withdraw()` sweeps everything +- **Multi-token** — pledges and withdrawals name the ERC-20 explicitly; only whitelisted tokens are accepted; accounting is per token +- **Campaign parameters are updatable** (`updateDeadline`, `updateGoalAmount`) subject to config lock period diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md new file mode 100644 index 00000000..8addd2c8 --- /dev/null +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -0,0 +1,377 @@ +# E-Commerce Marketplace — CeloMarket + +## The Business + +**CeloMarket** is an online marketplace where independent sellers list physical products (electronics, handmade goods, apparel). Buyers pay using fiat through the platform's UI, but under the hood, funds flow through crypto rails on the Celo network. Funds are locked until the seller ships the product and the platform confirms delivery, providing buyer protection similar to traditional e-commerce escrow. + +## Why Oak? + +CeloMarket needs: + +- **Buyer protection** — funds locked until shipment is confirmed +- **Physical item tracking** — product dimensions, weight, and category stored on-chain via ItemRegistry +- **Multi-line-item orders** — product cost, shipping fee, and platform commission as separate line items +- **Fee transparency** — protocol and platform fees are tracked and disbursed on-chain +- **Fiat-to-fiat UX** — end users see USD prices; crypto conversion happens behind the scenes + +## Oak Contracts Used + +| Contract | Purpose | +|----------|---------| +| **PaymentTreasury** | Holds buyer funds until delivery is confirmed | +| **ItemRegistry** | Stores physical product metadata (weight, dimensions, category) | + +## Multi-token support + +**PaymentTreasury** is **multi-token**: each order’s **`paymentToken`** must be on the campaign’s accepted-token list (`isTokenAccepted`). Balances and fee paths are **per ERC-20 contract** (native decimals). This story uses **USDC** for pricing clarity; CeloMarket can offer the same UX in “USD” while settling on-chain in **any whitelisted stablecoin or other ERC-20** your protocol maps to that currency. **`GlobalParams.getTokensForCurrency`** defines the mapping; **`CampaignInfo.getAcceptedTokens`** reflects what that campaign was created with. + +## Roles + +| Role | Who | On-Chain Functions | +|------|-----|--------------------| +| **Platform Admin** | CeloMarket backend | `createPayment`, `createPaymentBatch`, `confirmPayment`, `confirmPaymentBatch`, `cancelPayment`, `claimRefund(paymentId, address)` (non-NFT), `claimExpiredFunds`, `claimNonGoalLineItems`, `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | +| **Platform Admin or Campaign Owner** | CeloMarket or seller | `withdraw`, `cancelTreasury` | +| **Buyer** | End customer | ERC-20 `approve`, `processCryptoPayment`, `claimRefund(paymentId)` (NFT payments) | +| **Seller** | Independent merchant | `addItem`, `addItemsBatch` (ItemRegistry) | +| **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | +| **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `getItem`, `paused`, etc.) | + +## Integration Flow + +### Step 1: Connect to PaymentTreasury and ItemRegistry + +> **Role: Any caller** — connecting and reading state is public. + +CeloMarket's backend connects to both contracts. + +```typescript +import { createOakContractsClient, CHAIN_IDS, toHex } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL, + privateKey: process.env.PLATFORM_PRIVATE_KEY as `0x${string}`, +}); + +const treasury = oak.paymentTreasury(TREASURY_ADDRESS); +const registry = oak.itemRegistry(ITEM_REGISTRY_ADDRESS); +``` + +### Step 2: Seller lists a product — register in ItemRegistry + +> **Role: Seller** — the item registry owner (typically the seller or platform) registers items. + +When a seller lists a new product, CeloMarket registers its physical attributes in the ItemRegistry. This data can be used for shipping calculations, customs declarations, and dispute resolution. + +```typescript +const productId = toHex("wireless-headphones-v2", { size: 32 }); + +const item = { + actualWeight: 250n, // 250 grams + height: 200n, // 200mm + width: 180n, // 180mm + length: 80n, // 80mm + category: toHex("electronics", { size: 32 }), + declaredCurrency: toHex("USD", { size: 32 }), +}; + +await registry.simulate.addItem(productId, item); +const txHash = await registry.addItem(productId, item); +await oak.waitForReceipt(txHash); +``` + +For bulk listings, use batch registration: + +```typescript +const productIds = [ + toHex("headphones-black", { size: 32 }), + toHex("headphones-white", { size: 32 }), +]; + +const items = [ + { actualWeight: 250n, height: 200n, width: 180n, length: 80n, + category: toHex("electronics", { size: 32 }), + declaredCurrency: toHex("USD", { size: 32 }) }, + { actualWeight: 250n, height: 200n, width: 180n, length: 80n, + category: toHex("electronics", { size: 32 }), + declaredCurrency: toHex("USD", { size: 32 }) }, +]; + +const txHash = await registry.addItemsBatch(productIds, items); +await oak.waitForReceipt(txHash); +``` + +### Step 3: Buyer places order — create payment with line items + +> **Role: Platform Admin** — only the platform admin can create payment records. + +A buyer orders wireless headphones for $79.99. The order breaks down into three line items: product ($69.99), shipping ($7.50), and platform commission ($2.50). + +```typescript +const orderId = toHex("order-20260415-001", { size: 32 }); +const buyerId = toHex("buyer-alex-042", { size: 32 }); + +const lineItems = [ + { typeId: toHex("product", { size: 32 }), amount: 69_990000n }, // $69.99 USDC (6 decimals) + { typeId: toHex("shipping", { size: 32 }), amount: 7_500000n }, // $7.50 + { typeId: toHex("commission", { size: 32 }), amount: 2_500000n }, // $2.50 +]; + +const externalFees = [ + { feeType: toHex("payment-processing", { size: 32 }), feeAmount: 1_200000n }, // $1.20 +]; + +const totalAmount = 79_990000n; // $79.99 USDC +const expiration = BigInt(Math.floor(Date.now() / 1000) + 30 * 86400); // 30 days + +await treasury.simulate.createPayment( + orderId, buyerId, productId, USDC_TOKEN_ADDRESS, + totalAmount, expiration, lineItems, externalFees, +); + +const txHash = await treasury.createPayment( + orderId, buyerId, productId, USDC_TOKEN_ADDRESS, + totalAmount, expiration, lineItems, externalFees, +); +await oak.waitForReceipt(txHash); +``` + +### Step 4: Buyer pays — ERC-20 transfer to treasury + +> **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. + +The buyer's fiat payment is converted to an **accepted** on-chain ERC-20 (often a stablecoin such as USDC) behind the scenes. Before the treasury can pull funds, the buyer must grant an ERC-20 allowance for the **same `paymentToken`** used in `createPayment`: + +```typescript +import { erc20Abi } from "viem"; + +const usdc = { address: USDC_TOKEN_ADDRESS, abi: erc20Abi }; + +// Buyer approves the treasury to spend the order amount +const approveTx = await walletClient.writeContract({ + ...usdc, + functionName: "approve", + args: [TREASURY_ADDRESS, totalAmount], +}); +await publicClient.waitForTransactionReceipt({ hash: approveTx }); +``` + +Now the payment can be processed: + +```typescript +const txHash = await treasury.processCryptoPayment( + orderId, productId, BUYER_ADDRESS, USDC_TOKEN_ADDRESS, + totalAmount, lineItems, externalFees, +); +await oak.waitForReceipt(txHash); +``` + +Funds are now **locked** — the seller cannot access them until CeloMarket confirms shipment. + +### Step 5: Seller ships — platform confirms payment + +> **Role: Platform Admin** — only the platform admin can confirm payments. + +The seller uploads a shipping proof (tracking number). CeloMarket's backend verifies and confirms the payment. + +```typescript +await treasury.simulate.confirmPayment(orderId, BUYER_ADDRESS); + +const txHash = await treasury.confirmPayment(orderId, BUYER_ADDRESS); +await oak.waitForReceipt(txHash); +``` + +For batch order processing (e.g. end-of-day settlement): + +```typescript +const orderIds = [orderId1, orderId2, orderId3]; +const buyerAddresses = [buyer1, buyer2, buyer3]; + +const txHash = await treasury.confirmPaymentBatch(orderIds, buyerAddresses); +await oak.waitForReceipt(txHash); +``` + +### Step 6: Read order state — dashboard view + +> **Role: Any caller** — all read functions are public. + +CeloMarket's admin dashboard reads the payment details and treasury state. + +```typescript +// Read specific order +const paymentData = await treasury.getPaymentData(orderId); +// paymentData.isConfirmed, paymentData.amount, paymentData.lineItems, etc. + +// Read treasury-wide metrics +const [raised, available, refunded, expected] = await oak.multicall([ + () => treasury.getRaisedAmount(), + () => treasury.getAvailableRaisedAmount(), + () => treasury.getRefundedAmount(), + () => treasury.getExpectedAmount(), +]); +``` + +### Step 7: Fee disbursement + +> **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. + +Protocol and platform fees are distributed before the seller can withdraw. + +```typescript +const txHash = await treasury.disburseFees(); +await oak.waitForReceipt(txHash); +``` + +### Step 8: Seller withdrawal + +> **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (the seller). + +The settled amount (product price minus fees) is sent to the seller. + +```typescript +const txHash = await treasury.withdraw(); +await oak.waitForReceipt(txHash); +``` + +### Alternative: Buyer refund before shipment + +> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, address)`. **Any caller** for `claimRefund(paymentId)` (NFT payments only — refund goes to current NFT owner). + +If the buyer cancels before the seller ships, CeloMarket cancels the payment and the buyer gets a refund. + +**For off-chain payments (no NFT minted):** + +```typescript +// Platform cancels the unconfirmed payment +await treasury.cancelPayment(orderId); + +// Platform initiates refund to the buyer's address +await treasury.claimRefund(orderId, BUYER_ADDRESS); +``` + +**For crypto payments (NFT was minted):** + +```typescript +// Anyone can trigger the refund — funds go to the current NFT owner, and the NFT is burned +await treasury.claimRefund(orderId); +``` + +### Claim non-goal line items + +> **Role: Platform Admin** — only the platform admin can claim non-goal line items. + +If the order included line items that don't count toward the campaign goal (e.g., platform commission, shipping fees configured as non-goal), these accumulate separately. The platform admin can claim them at any time after confirmation. + +```typescript +const txHash = await treasury.claimNonGoalLineItems(USDC_TOKEN_ADDRESS); +await oak.waitForReceipt(txHash); +``` + +### Pause, unpause, or cancel the treasury + +**Pause the treasury:** + +> **Role: Platform Admin** — only the platform admin can pause and unpause. + +If CeloMarket needs to halt operations (e.g., suspected fraud, compliance review): + +```typescript +const txHash = await treasury.pauseTreasury(toHex("fraud-investigation", { size: 32 })); +await oak.waitForReceipt(txHash); + +const isPaused = await treasury.paused(); +``` + +While paused, no payments, confirmations, refunds, or withdrawals can occur. + +**Unpause the treasury:** + +> **Role: Platform Admin** + +```typescript +const txHash = await treasury.unpauseTreasury(toHex("investigation-cleared", { size: 32 })); +await oak.waitForReceipt(txHash); +``` + +**Cancel the treasury permanently:** + +> **Role: Platform Admin or Campaign Owner** — either party can cancel. + +Cancellation is irreversible. After cancellation, buyers can still claim refunds for confirmed NFT payments, but no new payments or withdrawals can happen. + +```typescript +const txHash = await treasury.cancelTreasury(toHex("marketplace-shutdown", { size: 32 })); +await oak.waitForReceipt(txHash); + +const isCancelled = await treasury.cancelled(); +``` + +### Reading product data for disputes + +> **Role: Any caller** — `getItem` is a public read function. + +If a dispute arises (e.g. wrong item shipped), CeloMarket can verify the registered product attributes: + +```typescript +const registeredItem = await registry.getItem(SELLER_ADDRESS, productId); +// registeredItem.actualWeight, registeredItem.height, registeredItem.category, etc. +``` + +## Architecture Diagram + +``` +Buyer (Alex) CeloMarket (Platform Admin) Blockchain + | | | + | Browse & order | | + |------------------------->| | + | | addItem() [ItemRegistry] | + | | [Seller] | + | |------------------------------->| Product registered + | | | + | | createPayment() | + | | [Platform Admin] | + | |------------------------------->| Order created + | | | + | ERC-20 approve() | | + |------------------------------------------------------> | Treasury approved + | | | + | | processCryptoPayment() | + | | [Any caller] | + | |------------------------------->| Funds locked + | | | + | Seller ships product | + | | confirmPayment() | + | | [Platform Admin] | + | |------------------------------->| Funds settled + | | | + | | claimNonGoalLineItems() | + | | [Platform Admin] | + | |------------------------------->| Non-goal items claimed + | | | + | | disburseFees() | + | | [Any caller] | + | |------------------------------->| Fees → Protocol + Platform + | | | + | | withdraw() | + | | [Admin or Owner] | + | |------------------------------->| Funds → Seller + | | | + | [Optional] Pause / | pauseTreasury() / | + | Cancel | cancelTreasury() | + | | [Platform Admin / Owner] | + | |------------------------------->| Treasury state updated +``` + +## Key Takeaways + +- **ERC-20 approval is required** — the buyer must `approve` the treasury contract before `processCryptoPayment` can transfer tokens +- **Multi-token** — orders can settle in any **accepted** `paymentToken`; treasury accounting is per token address +- **ItemRegistry** provides on-chain proof of product attributes for dispute resolution and compliance +- **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner +- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefund(paymentId)` for NFT payments (permissionless, refund to NFT owner) +- **Line items** separate product cost, shipping, and commission with configurable goal-counting, fees, and refund rules +- **Non-goal line items** (e.g., platform commission) can be claimed separately via `claimNonGoalLineItems` +- **Batch operations** (`createPaymentBatch`, `confirmPaymentBatch`) enable efficient end-of-day settlement +- **Pause / cancel controls** — platform admin can pause; either admin or owner can permanently cancel +- **Fiat-to-fiat for users** — buyers and sellers deal in USD; crypto conversion is abstracted away +- **Buyer protection** — funds only released after shipment confirmation, with a full refund path diff --git a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md new file mode 100644 index 00000000..2211ad11 --- /dev/null +++ b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md @@ -0,0 +1,317 @@ +# Automotive Prepayment — Karma Automotive + +## The Business + +**Karma Automotive** sells luxury electric vehicles. Customers place prepayment deposits when ordering a vehicle, with the full balance due before delivery. If the vehicle is not delivered within the agreed timeframe (e.g. 6 months), the customer is entitled to a full refund of their deposit. + +## Why Oak? + +Karma Automotive needs: + +- **Time-constrained escrow** — funds are locked with a hard deadline; if delivery doesn't happen by the deadline, the buyer is automatically protected +- **Structured payments** — line items for base price, options packages, and delivery fees +- **Automatic expiry protection** — after the deadline + claim delay, expired funds can be swept back to the buyer +- **Transparent fee tracking** — dealer fees, protocol fees, all visible on-chain +- **Trust for high-value transactions** — a $50,000+ vehicle deposit requires stronger guarantees than a traditional wire transfer + +## Oak Contract Used + +**TimeConstrainedPaymentTreasury** — identical to PaymentTreasury in its SDK interface (both use `oak.paymentTreasury()`), but the smart contract enforces launch-time and deadline constraints on-chain. After the deadline passes and the claim delay expires, `claimExpiredFunds` becomes callable. + +### Multi-token support + +Like **PaymentTreasury**, the time-constrained variant is **multi-token**: **`paymentToken`** must be accepted for the campaign, and all pending / confirmed / fee accounting is **per token address** in that token’s decimals. The Karma example uses **USDT** only as a familiar stablecoin; deposits and `claimNonGoalLineItems` can use **any accepted ERC-20** from the campaign whitelist. Whitelist source: **`GlobalParams`** (`getTokensForCurrency` / owner `addTokenToCurrency`); per-campaign cache: **`campaign.getAcceptedTokens()`**. + +## Roles + +| Role | Who | On-Chain Functions | +|------|-----|--------------------| +| **Platform Admin** | Karma's ordering system | `createPayment`, `createPaymentBatch`, `confirmPayment`, `confirmPaymentBatch`, `cancelPayment`, `claimRefund(paymentId, address)` (non-NFT), `claimExpiredFunds`, `claimNonGoalLineItems`, `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | +| **Platform Admin or Campaign Owner** | Karma or dealer | `withdraw`, `cancelTreasury` | +| **Buyer** | Vehicle customer | ERC-20 `approve`, `processCryptoPayment`, `claimRefund(paymentId)` (NFT payments) | +| **Dealer (Campaign Owner)** | Karma dealership | Receives funds after `withdraw` | +| **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | +| **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `getExpectedAmount`, `paused`, etc.) | + +> **Note on time constraints:** Unlike the standard PaymentTreasury, `createPayment`, `createPaymentBatch`, `processCryptoPayment`, `cancelPayment`, `confirmPayment`, and `confirmPaymentBatch` must be called while the current time is within `launchTime` … `deadline + bufferTime` (per `TimestampChecker`). `claimRefund` (both overloads), `claimExpiredFunds`, `disburseFees`, `withdraw`, and `claimNonGoalLineItems` require the current time to be **after** `launchTime` (they use `_checkTimeIsGreater()`). + +## Integration Flow + +### Step 1: Connect to the TimeConstrainedPaymentTreasury + +> **Role: Any caller** — connecting and reading treasury state is public. + +Karma's order management system connects to their deployed treasury. + +```typescript +import { createOakContractsClient, CHAIN_IDS, toHex } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL, + privateKey: process.env.KARMA_PLATFORM_KEY as `0x${string}`, +}); + +// TimeConstrainedPaymentTreasury uses the same SDK entity as PaymentTreasury +const treasury = oak.paymentTreasury(TIME_CONSTRAINED_TREASURY_ADDRESS); + +const isPaused = await treasury.paused(); +const isCancelled = await treasury.cancelled(); +``` + +### Step 2: Customer orders a vehicle — create prepayment + +> **Role: Platform Admin** — only the platform admin can create payment records. Must be called within the time window (`launchTime` to `deadline + bufferTime`). + +James orders a Karma GS-6 electric sedan with the Performance Package. The total prepayment is $52,500 broken down into line items. + +```typescript +const orderId = toHex("karma-order-GS6-2026-0415", { size: 32 }); +const buyerId = toHex("customer-james-091", { size: 32 }); +const itemId = toHex("karma-gs6-performance", { size: 32 }); + +const lineItems = [ + { typeId: toHex("vehicle-base", { size: 32 }), amount: 45_000_000000n }, // $45,000 USDT (6 decimals) + { typeId: toHex("performance-pkg", { size: 32 }), amount: 5_500_000000n }, // $5,500 + { typeId: toHex("delivery-fee", { size: 32 }), amount: 2_000_000000n }, // $2,000 +]; + +const externalFees = [ + { feeType: toHex("dealer-processing", { size: 32 }), feeAmount: 500_000000n }, // $500 +]; + +const totalAmount = 52_500_000000n; // $52,500 USDT +// Delivery deadline: 6 months from now +const expiration = BigInt(Math.floor(Date.now() / 1000) + 180 * 86400); + +await treasury.simulate.createPayment( + orderId, buyerId, itemId, USDT_TOKEN_ADDRESS, + totalAmount, expiration, lineItems, externalFees, +); + +const txHash = await treasury.createPayment( + orderId, buyerId, itemId, USDT_TOKEN_ADDRESS, + totalAmount, expiration, lineItems, externalFees, +); +await oak.waitForReceipt(txHash); +``` + +### Step 3: Customer pays the deposit + +> **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. Must be called within the time window. + +James transfers the full prepayment amount. Before the treasury can pull funds, James must grant an ERC-20 allowance: + +```typescript +import { erc20Abi } from "viem"; + +const usdt = { address: USDT_TOKEN_ADDRESS, abi: erc20Abi }; + +// James approves the treasury to spend $52,500 USDT +const approveTx = await walletClient.writeContract({ + ...usdt, + functionName: "approve", + args: [TIME_CONSTRAINED_TREASURY_ADDRESS, totalAmount], +}); +await publicClient.waitForTransactionReceipt({ hash: approveTx }); +``` + +Now the payment can be processed: + +```typescript +const txHash = await treasury.processCryptoPayment( + orderId, itemId, JAMES_WALLET_ADDRESS, USDT_TOKEN_ADDRESS, + totalAmount, lineItems, externalFees, +); +await oak.waitForReceipt(txHash); +``` + +Funds are now **locked in the time-constrained treasury**. The clock is ticking toward the 6-month delivery deadline. + +### Step 4: Monitor the order status + +> **Role: Any caller** — all read functions are public. + +Karma's dashboard tracks the prepayment and treasury health. + +```typescript +// Read the specific order +const paymentData = await treasury.getPaymentData(orderId); +// paymentData.isConfirmed === false (not yet delivered) +// paymentData.expiration — the delivery deadline + +// Treasury-level metrics +const [raised, available, expected] = await oak.multicall([ + () => treasury.getRaisedAmount(), + () => treasury.getAvailableRaisedAmount(), + () => treasury.getExpectedAmount(), +]); +``` + +### Step 5 (Success): Vehicle delivered — confirm and withdraw + +> **Role: Platform Admin** for `confirmPayment` (must still be within the launch…deadline+buffer window). **Any caller** for `disburseFees` (after `launchTime`). **Platform Admin or Campaign Owner** for `withdraw` (after `launchTime`). + +The GS-6 is manufactured and delivered to James. Karma's system confirms delivery. + +```typescript +// Confirm delivery +await treasury.simulate.confirmPayment(orderId, JAMES_WALLET_ADDRESS); +const confirmTx = await treasury.confirmPayment(orderId, JAMES_WALLET_ADDRESS); +await oak.waitForReceipt(confirmTx); + +// Disburse protocol and platform fees +const feeTx = await treasury.disburseFees(); +await oak.waitForReceipt(feeTx); + +// Withdraw settled funds to the dealer (campaign owner) +const withdrawTx = await treasury.withdraw(); +await oak.waitForReceipt(withdrawTx); +``` + +### Step 5 (Failure): Claim window after deadline — platform sweeps expired funds + +> **Role: Platform Admin** — only the platform admin can call `claimExpiredFunds`. Callable only after `campaignDeadline + platformClaimDelay`, and only after `launchTime` (time-constrained variant). + +If the vehicle is not delivered and funds remain in the treasury past the campaign deadline plus the configured claim delay, Karma's backend can sweep idle balances on-chain. The contract transfers swept amounts to the **platform admin** and **protocol admin** addresses (see `ExpiredFundsClaimed`). Consumer-facing refunds to James are then handled by Karma's policy and ops (off-chain settlement or a follow-on transfer), not by a single `claimExpiredFunds` transfer directly to the buyer wallet in the base contract logic. + +```typescript +// After INFO.getDeadline() + INFO.getPlatformClaimDelay(PLATFORM_HASH) has passed: +const txHash = await treasury.claimExpiredFunds(); +await oak.waitForReceipt(txHash); +``` + +This is the core value of the **TimeConstrainedPaymentTreasury** — the **claim window** is enforced on-chain, so idle balances cannot sit forever without a defined recovery path. + +### Alternative: Refunds before or after the claim window + +**A) Cancel unconfirmed off-chain payment (before `confirmPayment`):** + +> **Role: Platform Admin** for `cancelPayment` (within the launch…deadline+buffer window). + +```typescript +await treasury.cancelPayment(orderId); +``` + +**B) Refund after confirmation — non-NFT payment (no pledge NFT minted):** + +> **Role: Platform Admin** for `claimRefund(paymentId, refundAddress)` (after `launchTime`). + +```typescript +await treasury.claimRefund(orderId, JAMES_WALLET_ADDRESS); +``` + +**C) Refund after confirmation — NFT-backed crypto payment:** + +> **Role: Any caller** — `claimRefund(paymentId)` burns the NFT and sends the refund to the **current NFT owner** (after `launchTime`). + +```typescript +await treasury.claimRefund(orderId); +``` + +### Claim non-goal line items + +> **Role: Platform Admin** — only the platform admin can claim non-goal line items (after `launchTime`). + +If the prepayment used line items that do not count toward the campaign goal (e.g., processing fees), the platform admin can claim accumulated non-goal balances per token. + +```typescript +const txHash = await treasury.claimNonGoalLineItems(USDT_TOKEN_ADDRESS); +await oak.waitForReceipt(txHash); +``` + +### Batch operations + +> **Role: Platform Admin** — batch create and confirm are platform-admin-only (within the launch…deadline+buffer window). + +```typescript +const txHash = await treasury.createPaymentBatch( + paymentIds, buyerIds, itemIds, tokens, amounts, expirations, + lineItemsArray, externalFeesArray, +); +await oak.waitForReceipt(txHash); + +const txHash2 = await treasury.confirmPaymentBatch(paymentIds, buyerAddresses); +await oak.waitForReceipt(txHash2); +``` + +### Pause, unpause, or cancel the treasury + +**Pause / unpause:** + +> **Role: Platform Admin** — only the platform admin can pause and unpause. + +```typescript +const pauseTx = await treasury.pauseTreasury(toHex("compliance-hold", { size: 32 })); +await oak.waitForReceipt(pauseTx); + +const unpauseTx = await treasury.unpauseTreasury(toHex("hold-cleared", { size: 32 })); +await oak.waitForReceipt(unpauseTx); +``` + +**Cancel the treasury permanently:** + +> **Role: Platform Admin or Campaign Owner** — either party can cancel (same override pattern as `PaymentTreasury`). + +```typescript +const txHash = await treasury.cancelTreasury(toHex("program-ended", { size: 32 })); +await oak.waitForReceipt(txHash); +``` + +## Architecture Diagram + +``` +Customer (James) Karma (Platform Admin) TimeConstrainedTreasury + | | | + | Order GS-6 | | + |------------------------>| | + | | createPayment(...) | + | | [Platform Admin, in window] | + | |------------------------------->| Order recorded + | | | + | ERC-20 approve() | | + |-------------------------------------------------------->| Treasury approved + | | | + | | processCryptoPayment(...) | + | | [Any caller, in window] | + | |------------------------------->| Funds locked + | | | + | --- SUCCESS PATH --- | + | | | + | Vehicle delivered | confirmPayment(...) | + | | [Platform Admin, in window] | + | |------------------------------->| Funds settled + | | | + | | disburseFees() | + | | [Any caller, after launch] | + | |------------------------------->| Fees → Protocol + Platform + | | | + | | withdraw() | + | | [Admin or Owner, after launch]| + | |------------------------------->| Dealer paid + | | | + | --- FAILURE / LATE PATH --- | + | | | + | After deadline + | claimExpiredFunds() | + | claim delay | [Platform Admin] | + | |------------------------------->| Swept → Platform + Protocol + | Policy refund | (ops / off-chain follow-up) | + |<------------------------| | + | | | + | Or: NFT refund | claimRefund(paymentId) | + | | [Any caller, after launch] | + | |------------------------------->| Refund → NFT owner +``` + +## Key Takeaways + +- **ERC-20 approval is required** — James must `approve` the treasury before `processCryptoPayment` can pull tokens +- **Multi-token** — use any **accepted** `paymentToken` for the campaign; balances and sweeps are per ERC-20 +- **Time gates are enforced on-chain** — create/confirm/cancel/pay paths must occur within `launchTime` … `deadline + bufferTime`; refunds, fee disbursement, withdrawal, non-goal claims, and expired sweeps require time **after** `launchTime` +- **Same SDK interface** as PaymentTreasury — `oak.paymentTreasury()` works for both; behavior differs in the deployed contract bytecode +- **`claimExpiredFunds()`** is platform-admin-only and only after `deadline + platformClaimDelay`; on-chain recipients are the platform and protocol admins — align customer refunds with your product policy +- **Role-based access** — matches PaymentTreasury for admin-only writes; `withdraw` is platform admin or campaign owner; `disburseFees` is permissionless +- **Two refund models** — `claimRefund(paymentId, address)` (platform admin, non-NFT) vs `claimRefund(paymentId)` (any caller, NFT owner receives funds) +- **High-value transactions** benefit from deterministic rules instead of informal wire holds +- **Line items** provide a clear audit trail (base price vs. options vs. delivery) +- **Batch, pause, cancel, and `claimNonGoalLineItems`** behave like PaymentTreasury but inherit the same time checks from `TimeConstrainedPaymentTreasury` From bfb8e0871b6fba318c520a8b297ffbe547bea595 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 15 Apr 2026 12:54:04 +0600 Subject: [PATCH 57/86] docs: update examples for partial and final withdrawal processes - Renamed and reorganized example scripts for clarity: `06a-partial-withdrawal.ts` is now `06a-approve-partial-withdrawal.ts`, and `06b-final-withdrawal.ts` is now `06b-execute-partial-withdrawal.ts`. - Added new example script `06c-final-withdrawal.ts` for executing final withdrawals after the campaign deadline. - Updated README files to reflect changes in withdrawal steps and clarify the withdrawal delay configuration for production use. --- .../03-configure-treasury.ts | 7 ++- .../06a-approve-partial-withdrawal.ts | 32 ++++++++++ .../06a-partial-withdrawal.ts | 62 ------------------- .../06b-execute-partial-withdrawal.ts | 29 +++++++++ ...-withdrawal.ts => 06c-final-withdrawal.ts} | 4 +- .../02-campaign-keep-whats-raised/README.md | 9 +-- packages/contracts/src/examples/README.md | 5 +- .../flexible-funding/community-project.md | 3 +- 8 files changed, 77 insertions(+), 74 deletions(-) create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-approve-partial-withdrawal.ts delete mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts create mode 100644 packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-execute-partial-withdrawal.ts rename packages/contracts/src/examples/02-campaign-keep-whats-raised/{06b-final-withdrawal.ts => 06c-final-withdrawal.ts} (96%) diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts index b7a2ef95..747c185b 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts @@ -8,7 +8,8 @@ * Configuration includes: * * - Withdrawal delay: how long after approval before funds can be - * withdrawn (gives backers visibility) + * withdrawn (gives backers visibility). This file uses 0 so Steps 6a/6b + * can run in one session; use a positive value in production. * - Refund delay: how long after the deadline (or cancellation) * backers must wait before claiming refunds * - Config lock period: prevents parameter changes close to the @@ -48,7 +49,9 @@ const currency = toHex("USD", { size: 32 }); const config: KeepWhatsRaisedConfig = { minimumWithdrawalForFeeExemption: 1_000_000_000n, // $1,000 — withdrawals above this skip flat fee - withdrawalDelay: 86400n, // 24 hours between approval and withdrawal + // 0 so Steps 6a (approve) and 6b (partial withdraw) can run back-to-back in this tutorial. + // In production, use e.g. 86400n (24h) so backers have time after `approveWithdrawal`. + withdrawalDelay: 0n, refundDelay: 259200n, // 3-day delay after deadline before backers can refund configLockPeriod: 604800n, // config is locked for 7 days before deadline isColombianCreator: false, diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-approve-partial-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-approve-partial-withdrawal.ts new file mode 100644 index 00000000..90f79444 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-approve-partial-withdrawal.ts @@ -0,0 +1,32 @@ +/** + * Step 6a: Approve partial withdrawal (Platform Admin) + * + * Before the creator can withdraw mid-campaign, the platform admin must + * call `approveWithdrawal` once. After this, the creator (or platform) + * may call `withdraw(token, amount)` — but only after the configured + * **withdrawal delay** has elapsed since this approval (unless the delay + * is 0, as in Step 3 of this walkthrough). + * + * Run **06b-execute-partial-withdrawal.ts** next (creator wallet) in the + * same session when `withdrawalDelay` is 0. If you set a non-zero delay + * in production, wait that many seconds or advance time on a local node + * before running Step 6b. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const platformOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const platformTreasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); + +const approvalTxHash = await platformTreasury.approveWithdrawal(); +await platformOak.waitForReceipt(approvalTxHash); +console.log("Platform admin approved withdrawals"); + +const approvalStatus = await platformTreasury.getWithdrawalApprovalStatus(); +console.log("Withdrawal approved:", approvalStatus); // true diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts deleted file mode 100644 index eedf14f6..00000000 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06a-partial-withdrawal.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Step 6a: Partial Withdrawal — Mid-Campaign (Platform Admin + Creator) - * - * One of the key advantages of the Keep-What's-Raised model is that - * the creator does not have to wait until the campaign ends to access - * funds. TechForge needs $2,000 now to begin prototyping. - * - * The withdrawal process involves two parties: - * - * 1. The **platform admin** approves withdrawal capability for the - * treasury (`approveWithdrawal`). This is a one-time action — - * once approved, the withdrawal flag stays on. - * 2. The **creator or platform admin** executes the withdrawal - * (`withdraw(token, amount)`) after the configured delay period. - * - * Partial withdrawals let the creator specify exactly how much to - * withdraw. A cumulative or flat fee may apply depending on the - * amount relative to `minimumWithdrawalForFeeExemption`. - * - * This is distinct from the final withdrawal (Step 6b), which happens - * after the deadline and sweeps the remaining balance. - */ - -import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; - -// --- Step A: Platform admin approves withdrawal --- - -const platformOak = createOakContractsClient({ - chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, - rpcUrl: process.env.RPC_URL!, - privateKey: process.env.PLATFORM_PRIVATE_KEY! as `0x${string}`, -}); - -const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; -const platformTreasury = platformOak.keepWhatsRaisedTreasury(treasuryAddress); - -const approvalTxHash = await platformTreasury.approveWithdrawal(); -await platformOak.waitForReceipt(approvalTxHash); -console.log("Platform admin approved withdrawals"); - -const approvalStatus = await platformTreasury.getWithdrawalApprovalStatus(); -console.log("Withdrawal approved:", approvalStatus); // true - -// --- Step B: Creator withdraws after the delay period --- -// The on-chain withdrawal delay (set during treasury configuration) -// must have elapsed since approval before this call can succeed. -// If the delay has not passed, the transaction will revert. - -const creatorOak = createOakContractsClient({ - chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, - rpcUrl: process.env.RPC_URL!, - privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, -}); - -const creatorTreasury = creatorOak.keepWhatsRaisedTreasury(treasuryAddress); - -const withdrawToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; -const withdrawAmount = 2_000_000_000n; // $2,000 - -const withdrawTxHash = await creatorTreasury.withdraw(withdrawToken, withdrawAmount); -await creatorOak.waitForReceipt(withdrawTxHash); -console.log("Creator withdrew $2,000 for prototyping"); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-execute-partial-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-execute-partial-withdrawal.ts new file mode 100644 index 00000000..da9ec395 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-execute-partial-withdrawal.ts @@ -0,0 +1,29 @@ +/** + * Step 6b: Execute partial withdrawal (Creator) + * + * After Step 6a, the creator withdraws a specific amount of an accepted + * ERC-20. The contract enforces **withdrawalDelay** seconds between + * approval and this call (see `configureTreasury` in Step 3). + * + * This scenario sets **withdrawalDelay: 0** in `03-configure-treasury.ts` + * so you can run 6a then 6b immediately. In production, use a positive + * delay (e.g. 86400n) so backers have a window after approval. + */ + +import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +const creatorOak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.TECHFORGE_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const creatorTreasury = creatorOak.keepWhatsRaisedTreasury(treasuryAddress); + +const withdrawToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; +const withdrawAmount = 2_000_000_000n; // $2,000 + +const withdrawTxHash = await creatorTreasury.withdraw(withdrawToken, withdrawAmount); +await creatorOak.waitForReceipt(withdrawTxHash); +console.log("Creator withdrew $2,000 for prototyping"); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06c-final-withdrawal.ts similarity index 96% rename from packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts rename to packages/contracts/src/examples/02-campaign-keep-whats-raised/06c-final-withdrawal.ts index 736a6793..4be17deb 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/06b-final-withdrawal.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06c-final-withdrawal.ts @@ -1,8 +1,8 @@ /** - * Step 6b: Final Withdrawal — After Deadline (Creator or Platform Admin) + * Step 6c: Final withdrawal — after deadline (Creator or Platform Admin) * * After the campaign deadline passes, the creator or platform admin - * can execute a final withdrawal. Unlike partial withdrawals (Step 6a), + * can execute a final withdrawal. Unlike partial withdrawals (Steps 6a–6b), * the final withdrawal sweeps the entire remaining balance of a * specific token from the treasury. * diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md index bf426cda..4cd88d7b 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md @@ -25,8 +25,8 @@ Same model as Scenario 1: the campaign whitelists **multiple ERC-20s** per **cur 4. **TechForge** adds reward tiers — and optionally removes one they no longer want to offer 5. **Backers** discover the campaign and pledge — some choose a reward tier, others pledge without a reward as a show of support 6. Two types of withdrawal: - - **(a) Partial:** ArtFund approves withdrawals, then TechForge withdraws $2,000 mid-campaign to begin prototyping - - **(b) Final:** After the deadline, TechForge sweeps the remaining balance minus applicable fees + - **(a–b) Partial:** ArtFund approves withdrawals (`06a`), then TechForge executes the partial amount (`06b`). Step 3 sets **`withdrawalDelay: 0`** so both scripts can run in one session; use a non-zero delay in production. + - **(c) Final:** After the deadline, TechForge sweeps the remaining balance (`06c`) minus applicable fees 7. **Anyone** monitors the campaign dashboard — total raised vs. goal, fee details, treasury state 8. **Anyone** disburses accumulated protocol and platform fees (must happen before cancellation) 9. **ArtFund (Platform Admin)** claims any residual funds after the withdrawal delay has fully elapsed @@ -45,8 +45,9 @@ Same model as Scenario 1: the campaign whitelists **multiple ERC-20s** per **cur | 3 | `03-configure-treasury.ts` | Platform Admin | Set withdrawal delays, refund policies, and fees | Required | | 4 | `04-manage-rewards.ts` | Creator | Add reward tiers + optionally remove a tier | Required | | 5 | `05-backer-pledge.ts` | Backer | Pledge with or without a reward; platform can set gateway fees | Required | -| 6a | `06a-partial-withdrawal.ts` | Platform Admin + Creator | Platform approves, then creator withdraws partial funds mid-campaign | Required | -| 6b | `06b-final-withdrawal.ts` | Creator or Platform Admin | Post-deadline withdrawal — sweep remaining balance with fees | Required | +| 6a | `06a-approve-partial-withdrawal.ts` | Platform Admin | `approveWithdrawal` — required before any mid-campaign `withdraw` | Required | +| 6b | `06b-execute-partial-withdrawal.ts` | Creator | Partial `withdraw(token, amount)` after delay (0 in Step 3 for sequential runs) | Required | +| 6c | `06c-final-withdrawal.ts` | Creator or Platform Admin | Post-deadline withdrawal — sweep remaining balance with fees | Required | | 7 | `07-monitor-progress.ts` | Anyone | Full campaign dashboard — raised amount, fees, treasury state | Required | | 8 | `08-disburse-fees.ts` | Anyone | Disburse accumulated fees (must call before cancellation) | Required | | 9 | `09-claim-fund.ts` | Platform Admin | Claim residual funds after the withdrawal delay elapses | Required | diff --git a/packages/contracts/src/examples/README.md b/packages/contracts/src/examples/README.md index 3c1bd432..b3e68256 100644 --- a/packages/contracts/src/examples/README.md +++ b/packages/contracts/src/examples/README.md @@ -67,8 +67,9 @@ examples/ │ ├── 03-configure-treasury.ts ← Platform Admin │ ├── 04-manage-rewards.ts ← add + remove rewards │ ├── 05-backer-pledge.ts ← with/without reward, gateway fees -│ ├── 06a-partial-withdrawal.ts ← mid-campaign partial withdrawal -│ ├── 06b-final-withdrawal.ts ← post-deadline sweep +│ ├── 06a-approve-partial-withdrawal.ts ← platform approves mid-campaign withdraw +│ ├── 06b-execute-partial-withdrawal.ts ← creator partial withdraw (after delay) +│ ├── 06c-final-withdrawal.ts ← post-deadline sweep │ ├── 07-monitor-progress.ts ← full campaign dashboard │ ├── 08-disburse-fees.ts ← must call before cancellation │ ├── 09-claim-fund.ts ← Platform Admin diff --git a/packages/contracts/src/use-cases/flexible-funding/community-project.md b/packages/contracts/src/use-cases/flexible-funding/community-project.md index a1a128a4..d23f548d 100644 --- a/packages/contracts/src/use-cases/flexible-funding/community-project.md +++ b/packages/contracts/src/use-cases/flexible-funding/community-project.md @@ -264,10 +264,9 @@ const txHash = await kwrTreasury.approveWithdrawal(); await oak.waitForReceipt(txHash); ``` -**After the withdrawal delay, creator withdraws:** +**Creator executes the partial amount** (only after **`withdrawalDelay`** seconds since approval, unless the delay is `0` in `configureTreasury` for a walkthrough): ```typescript -// 3 days after approval (configured withdrawalDelay) const txHash = await kwrTreasury.withdraw( USDC_TOKEN_ADDRESS, 3_000_000000n, // Withdraw 3,000 USDC for component order (6 decimals) From 3d42e6a98a26e67080c150cb8a3ec38b0a228d1e Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 15 Apr 2026 13:06:00 +0600 Subject: [PATCH 58/86] refactor: improve token ID handling in refund examples - Updated the refund example scripts to require trimmed environment variables for pledge token IDs, enhancing error handling by throwing descriptive errors when the required IDs are not provided. - This change ensures that the scripts fail gracefully with clear messages, improving developer experience and reducing potential runtime errors. --- .../01-campaign-all-or-nothing/09b-failure-refund.ts | 6 +++++- .../02-campaign-keep-whats-raised/11-claim-refund.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts index a38e58a5..00838c53 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts @@ -32,7 +32,11 @@ const alexOak = createOakContractsClient({ const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; const treasury = alexOak.allOrNothingTreasury(treasuryAddress); -const tokenId = BigInt(process.env.ALEX_PLEDGE_TOKEN_ID ?? "0"); // NFT token ID from the pledge receipt event +const pledgeTokenIdEnv = process.env.ALEX_PLEDGE_TOKEN_ID?.trim(); +if (!pledgeTokenIdEnv) { + throw new Error("ALEX_PLEDGE_TOKEN_ID is required (pledge NFT tokenId from Step 6)."); +} +const tokenId = BigInt(pledgeTokenIdEnv); const refundTxHash = await treasury.claimRefund(tokenId); const refundReceipt = await alexOak.waitForReceipt(refundTxHash); console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts index f2d4733a..08983fa4 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts @@ -28,7 +28,11 @@ const backerOak = createOakContractsClient({ const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; const treasury = backerOak.keepWhatsRaisedTreasury(treasuryAddress); -const tokenId = BigInt(process.env.BACKER_PLEDGE_TOKEN_ID ?? "0"); // NFT token ID from the pledge receipt event +const pledgeTokenIdEnv = process.env.BACKER_PLEDGE_TOKEN_ID?.trim(); +if (!pledgeTokenIdEnv) { + throw new Error("BACKER_PLEDGE_TOKEN_ID is required (pledge NFT tokenId from Step 5)."); +} +const tokenId = BigInt(pledgeTokenIdEnv); const refundTxHash = await treasury.claimRefund(tokenId); const refundReceipt = await backerOak.waitForReceipt(refundTxHash); console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); From bd0ef4a51a2e348770fd3af59e4c3b438cb57742 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 15 Apr 2026 13:30:26 +0600 Subject: [PATCH 59/86] docs: update refund models in healthcare, marketplace, and prepayment use cases - Changed the refund function for NFT payments from `claimRefund(paymentId)` to `claimRefundSelf(paymentId)` across healthcare, marketplace, and prepayment documentation. - Clarified roles for refund processes, specifying that the buyer (NFT owner) is responsible for initiating refunds for NFT payments. - Enhanced consistency in terminology and improved clarity in the documentation regarding refund procedures. --- .../src/use-cases/escrow/healthcare-escrow.md | 8 ++++---- .../use-cases/marketplace/ecommerce-marketplace.md | 8 ++++---- .../use-cases/prepayment/automotive-prepayment.md | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md index 1b1fec72..8a678cbd 100644 --- a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -28,7 +28,7 @@ Payments specify **`paymentToken`**; the contract reverts unless **`CampaignInfo |------|-----|--------------------| | **Platform Admin** | MedConnect backend | `createPayment`, `createPaymentBatch`, `confirmPayment`, `confirmPaymentBatch`, `cancelPayment`, `claimRefund(paymentId, address)` (non-NFT), `claimExpiredFunds`, `claimNonGoalLineItems`, `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | | **Platform Admin or Campaign Owner** | MedConnect or clinic | `withdraw`, `cancelTreasury` | -| **Patient (Buyer)** | Sarah | ERC-20 `approve`, `processCryptoPayment`, `claimRefund(paymentId)` (NFT payments) | +| **Patient (Buyer)** | Sarah | ERC-20 `approve`, `processCryptoPayment`, `claimRefundSelf(paymentId)` (NFT payments) | | **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | | **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `getExpectedAmount`, `paused`, etc.) | @@ -185,7 +185,7 @@ await oak.waitForReceipt(txHash); ### Alternative: Cancel and refund flow -> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, address)`. **Any caller** for `claimRefund(paymentId)` (NFT payments only — refund goes to current NFT owner). +> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, refundAddress)`. **Buyer (NFT owner)** for `claimRefundSelf(paymentId)` (crypto / NFT payments — refund to current NFT owner). If Sarah needs to cancel the appointment before the doctor confirms delivery, MedConnect cancels the payment and initiates a refund. @@ -203,7 +203,7 @@ await treasury.claimRefund(paymentId, SARAH_WALLET_ADDRESS); ```typescript // Anyone can trigger the refund — funds go to the current NFT owner, and the NFT is burned -await treasury.claimRefund(paymentId); +await treasury.claimRefundSelf(paymentId); ``` ### Step 8: Claim non-goal line items @@ -348,7 +348,7 @@ Patient (Sarah) MedConnect (Platform Admin) PaymentTreasur - **Multi-token** — `paymentToken` must be on the campaign’s accepted list; balances and refunds are tracked per ERC-20 (each token’s decimals) - **Funds are never at risk** — they stay locked in the smart contract until service is confirmed - **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner -- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefund(paymentId)` for NFT payments (permissionless, refund to NFT owner) +- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefundSelf(paymentId)` for NFT payments (signer must be NFT owner; burns pledge NFT) - **Line items** allow granular tracking (consultation vs. lab work) with configurable goal-counting, fees, and refund rules - **Non-goal line items** (e.g., platform commission) can be claimed separately via `claimNonGoalLineItems` - **Batch operations** — `createPaymentBatch` and `confirmPaymentBatch` for high-volume platforms diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md index 8addd2c8..09d5ecce 100644 --- a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -31,7 +31,7 @@ CeloMarket needs: |------|-----|--------------------| | **Platform Admin** | CeloMarket backend | `createPayment`, `createPaymentBatch`, `confirmPayment`, `confirmPaymentBatch`, `cancelPayment`, `claimRefund(paymentId, address)` (non-NFT), `claimExpiredFunds`, `claimNonGoalLineItems`, `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | | **Platform Admin or Campaign Owner** | CeloMarket or seller | `withdraw`, `cancelTreasury` | -| **Buyer** | End customer | ERC-20 `approve`, `processCryptoPayment`, `claimRefund(paymentId)` (NFT payments) | +| **Buyer** | End customer | ERC-20 `approve`, `processCryptoPayment`, `claimRefundSelf(paymentId)` (NFT payments) | | **Seller** | Independent merchant | `addItem`, `addItemsBatch` (ItemRegistry) | | **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | | **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `getItem`, `paused`, etc.) | @@ -235,7 +235,7 @@ await oak.waitForReceipt(txHash); ### Alternative: Buyer refund before shipment -> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, address)`. **Any caller** for `claimRefund(paymentId)` (NFT payments only — refund goes to current NFT owner). +> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, refundAddress)`. **Buyer (NFT owner)** for `claimRefundSelf(paymentId)` (crypto / NFT payments). If the buyer cancels before the seller ships, CeloMarket cancels the payment and the buyer gets a refund. @@ -253,7 +253,7 @@ await treasury.claimRefund(orderId, BUYER_ADDRESS); ```typescript // Anyone can trigger the refund — funds go to the current NFT owner, and the NFT is burned -await treasury.claimRefund(orderId); +await treasury.claimRefundSelf(orderId); ``` ### Claim non-goal line items @@ -368,7 +368,7 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain - **Multi-token** — orders can settle in any **accepted** `paymentToken`; treasury accounting is per token address - **ItemRegistry** provides on-chain proof of product attributes for dispute resolution and compliance - **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner -- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefund(paymentId)` for NFT payments (permissionless, refund to NFT owner) +- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefundSelf(paymentId)` for NFT payments (signer must be NFT owner) - **Line items** separate product cost, shipping, and commission with configurable goal-counting, fees, and refund rules - **Non-goal line items** (e.g., platform commission) can be claimed separately via `claimNonGoalLineItems` - **Batch operations** (`createPaymentBatch`, `confirmPaymentBatch`) enable efficient end-of-day settlement diff --git a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md index 2211ad11..b7348076 100644 --- a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md +++ b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md @@ -28,12 +28,12 @@ Like **PaymentTreasury**, the time-constrained variant is **multi-token**: **`pa |------|-----|--------------------| | **Platform Admin** | Karma's ordering system | `createPayment`, `createPaymentBatch`, `confirmPayment`, `confirmPaymentBatch`, `cancelPayment`, `claimRefund(paymentId, address)` (non-NFT), `claimExpiredFunds`, `claimNonGoalLineItems`, `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | | **Platform Admin or Campaign Owner** | Karma or dealer | `withdraw`, `cancelTreasury` | -| **Buyer** | Vehicle customer | ERC-20 `approve`, `processCryptoPayment`, `claimRefund(paymentId)` (NFT payments) | +| **Buyer** | Vehicle customer | ERC-20 `approve`, `processCryptoPayment`, `claimRefundSelf(paymentId)` (NFT payments) | | **Dealer (Campaign Owner)** | Karma dealership | Receives funds after `withdraw` | | **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | | **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `getExpectedAmount`, `paused`, etc.) | -> **Note on time constraints:** Unlike the standard PaymentTreasury, `createPayment`, `createPaymentBatch`, `processCryptoPayment`, `cancelPayment`, `confirmPayment`, and `confirmPaymentBatch` must be called while the current time is within `launchTime` … `deadline + bufferTime` (per `TimestampChecker`). `claimRefund` (both overloads), `claimExpiredFunds`, `disburseFees`, `withdraw`, and `claimNonGoalLineItems` require the current time to be **after** `launchTime` (they use `_checkTimeIsGreater()`). +> **Note on time constraints:** Unlike the standard PaymentTreasury, `createPayment`, `createPaymentBatch`, `processCryptoPayment`, `cancelPayment`, `confirmPayment`, and `confirmPaymentBatch` must be called while the current time is within `launchTime` … `deadline + bufferTime` (per `TimestampChecker`). `claimRefund`, `claimRefundSelf`, `claimExpiredFunds`, `disburseFees`, `withdraw`, and `claimNonGoalLineItems` require the current time to be **after** `launchTime` (they use `_checkTimeIsGreater()`). ## Integration Flow @@ -203,10 +203,10 @@ await treasury.claimRefund(orderId, JAMES_WALLET_ADDRESS); **C) Refund after confirmation — NFT-backed crypto payment:** -> **Role: Any caller** — `claimRefund(paymentId)` burns the NFT and sends the refund to the **current NFT owner** (after `launchTime`). +> **Role: Buyer (NFT owner)** — `claimRefundSelf(paymentId)` burns the NFT and sends the refund to the **current NFT owner** (after `launchTime`). ```typescript -await treasury.claimRefund(orderId); +await treasury.claimRefundSelf(orderId); ``` ### Claim non-goal line items @@ -298,7 +298,7 @@ Customer (James) Karma (Platform Admin) TimeConstrainedTreasu | Policy refund | (ops / off-chain follow-up) | |<------------------------| | | | | - | Or: NFT refund | claimRefund(paymentId) | + | Or: NFT refund | claimRefundSelf(paymentId) | | | [Any caller, after launch] | | |------------------------------->| Refund → NFT owner ``` @@ -311,7 +311,7 @@ Customer (James) Karma (Platform Admin) TimeConstrainedTreasu - **Same SDK interface** as PaymentTreasury — `oak.paymentTreasury()` works for both; behavior differs in the deployed contract bytecode - **`claimExpiredFunds()`** is platform-admin-only and only after `deadline + platformClaimDelay`; on-chain recipients are the platform and protocol admins — align customer refunds with your product policy - **Role-based access** — matches PaymentTreasury for admin-only writes; `withdraw` is platform admin or campaign owner; `disburseFees` is permissionless -- **Two refund models** — `claimRefund(paymentId, address)` (platform admin, non-NFT) vs `claimRefund(paymentId)` (any caller, NFT owner receives funds) +- **Two refund models** — `claimRefund(paymentId, address)` (platform admin, non-NFT) vs `claimRefundSelf(paymentId)` (signer must be NFT owner) - **High-value transactions** benefit from deterministic rules instead of informal wire holds - **Line items** provide a clear audit trail (base price vs. options vs. delivery) - **Batch, pause, cancel, and `claimNonGoalLineItems`** behave like PaymentTreasury but inherit the same time checks from `TimeConstrainedPaymentTreasury` From b7a89745efb2b6c85e96bd966075fee4569e4201 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Wed, 15 Apr 2026 13:42:17 +0600 Subject: [PATCH 60/86] chore: exclude example and script files from coverage collection - Updated jest configuration to exclude files in the `src/examples` and `src/scripts` directories from coverage reports, improving the accuracy of coverage metrics by focusing on relevant source files. --- packages/contracts/jest.config.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts/jest.config.cjs b/packages/contracts/jest.config.cjs index 06f9825a..02d01688 100644 --- a/packages/contracts/jest.config.cjs +++ b/packages/contracts/jest.config.cjs @@ -7,6 +7,7 @@ module.exports = { collectCoverageFrom: [ "src/**/*.{ts,tsx}", "!src/**/*.d.ts", + "!src/examples/**", "!src/scripts/**", "!src/contracts/*/abi.ts", "!src/index.ts", From 33c8315503cf99d8260cc0a7a998c3d7f64542bf Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:03:15 +0600 Subject: [PATCH 61/86] docs: clarify payment flows in campaign payment treasury examples - Updated comments and README to distinguish between two independent payment flows: Flow A (off-chain payment via `createPayment`) and Flow B (on-chain payment via `processCryptoPayment`). - Enhanced descriptions to improve understanding of the payment process, including the roles of the platform admin and buyer in each flow. - Added details on the NFT minting process as proof of payment for on-chain transactions. --- .../02-create-payment.ts | 13 +++++++++--- .../03-process-crypto-payment.ts | 21 +++++++++++-------- .../03-campaign-payment-treasury/README.md | 14 +++++++++---- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts index 464362cb..8be06fd9 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts @@ -1,5 +1,5 @@ /** - * Step 2: Create a Payment Record (Platform Admin) + * Step 2: Create a Payment Record (Platform Admin) — Off-Chain Payment Flow * * Sam has added a handcrafted ceramic vase ($120) to his cart and * proceeds to checkout. CeloMarket creates a payment record on-chain @@ -11,8 +11,15 @@ * - A 24-hour expiration window — if Sam does not pay within this * time, the payment record expires * - * This step does not move any funds. It simply records the payment - * intent on-chain so the buyer can execute it in the next step. + * This step does not move any funds. It records the payment intent + * on-chain. The buyer pays through off-chain rails (credit card, bank + * transfer, etc.) and the platform later confirms via `confirmPayment`. + * + * This is one of two independent payment flows: + * - Flow A (`createPayment` → off-chain payment → `confirmPayment`): + * shown here — platform-initiated, no on-chain token transfer. + * - Flow B (`processCryptoPayment`): shown in Step 3 — buyer pays + * directly on-chain with ERC-20 tokens in a single transaction. * * For high-volume platforms, `createPaymentBatch` is available to * create multiple payment records in a single transaction. diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts index 0dfdf3cd..df7e1623 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts @@ -1,20 +1,23 @@ /** - * Step 3: Process the Crypto Payment (Buyer) + * Step 3: Process a Crypto Payment (Buyer) — Independent On-Chain Flow * - * Sam completes the purchase by transferring ERC-20 tokens (e.g., USDC) - * from his wallet to the treasury contract. This is the moment funds - * actually move on-chain. + * This is an alternative to Step 2's off-chain `createPayment` flow. + * `processCryptoPayment` is a standalone operation that creates the + * payment record AND transfers ERC-20 tokens to the treasury in a + * single transaction. It does NOT require or complete a prior + * `createPayment` call — they are two independent payment flows: * - * The payment details (line items, amounts, external fees) must match - * what the platform recorded in Step 2. If anything differs, the - * contract reverts to prevent mismatched payments. + * - Flow A (`createPayment`): Platform records payment off-chain, + * buyer pays via fiat/external rails, platform confirms later. + * - Flow B (`processCryptoPayment`): Buyer pays directly on-chain + * with ERC-20 tokens. An NFT is minted as proof of payment. * * Prerequisite: Sam must have already approved the treasury contract * to spend his ERC-20 tokens before calling this method. This is a * standard ERC-20 approval, not specific to Oak Protocol. * - * Multi-token: `paymentToken` here must match Step 2 and be accepted for - * the campaign; use a separate approval per token if you support several. + * Multi-token: `paymentToken` must be on the campaign's accepted-token + * list; use a separate approval per token if you support several. */ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md index 4c508706..cf13d1f7 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md @@ -15,8 +15,14 @@ Every payment record includes **`paymentToken`**. The treasury only accepts toke ## How It Unfolds 1. **CeloMarket (Platform Admin)** connects to its deployed PaymentTreasury contract and reads back the platform configuration -2. **CeloMarket** creates a payment record for Sam's order — this includes the total amount, line items (product + shipping), external fees, and an expiration window. Batch creation is also available for high-volume platforms. -3. **Sam (Buyer)** transfers the payment on-chain by sending ERC-20 tokens to the treasury contract + +**Two independent payment flows** — a platform uses one or both depending on its business model: + +2. **Flow A — Off-chain / fiat payment:** **CeloMarket** creates a payment record for Sam's order via `createPayment`. This records the intent on-chain (total amount, line items, external fees, expiration) but **no funds move**. A buyer pays through off-chain rails (credit card, bank transfer, etc.) and the platform later calls `confirmPayment` after verifying receipt. +3. **Flow B — On-chain crypto payment:** **Sam (Buyer)** pays directly on-chain via `processCryptoPayment`. This is a **standalone operation** — it creates the payment record AND transfers ERC-20 tokens to the treasury in a single transaction. It does **not** require a prior `createPayment` call. An NFT is minted to the buyer as proof of payment. + +> **These are two separate flows, not sequential steps.** `processCryptoPayment` does not "complete" a pending `createPayment` — it is an independent entry point for on-chain payments. + 4. **CeloMarket** verifies the order (inventory check, fraud review) and confirms the payment. Batch confirmation is available for multiple payments. 5. **Anyone** can read payment data and treasury balances — buyer address, amount, confirmation status, expected pending amount, and line item breakdown 6. If something goes wrong (wrong item shipped, order cancelled), a refund is issued. For off-chain payments the **platform admin** cancels and directs the refund to an address (`claimRefund`). For on-chain crypto payments the **buyer (NFT owner)** calls `claimRefundSelf` — the contract verifies NFT ownership, burns the NFT, and sends refundable line items back. @@ -56,8 +62,8 @@ Which variant your platform uses depends on the treasury implementation register | Step | File | Role | Description | Required? | | --- | --- | --- | --- | --- | | 1 | `01-setup-treasury.ts` | Platform Admin | Connect to the PaymentTreasury and read platform config | Required | -| 2 | `02-create-payment.ts` | Platform Admin | Create a payment record with line items (single + batch) | Required | -| 3 | `03-process-crypto-payment.ts` | Buyer | Transfer ERC-20 tokens to the treasury | Required | +| 2 | `02-create-payment.ts` | Platform Admin | Flow A: Create an off-chain payment record with line items (single + batch) | Required | +| 3 | `03-process-crypto-payment.ts` | Buyer | Flow B: Pay on-chain — creates the payment AND transfers ERC-20 tokens in one step (independent of Step 2) | Required | | 4 | `04-confirm-payment.ts` | Platform Admin | Confirm the payment after order verification (single + batch) | Required | | 5 | `05-read-payment-data.ts` | Anyone | Read payment details and treasury dashboard | Required | | 6 | `06-handle-refunds.ts` | Platform Admin / Buyer | Cancel a payment and claim a refund (self or admin-directed) | Required | From 6eaca098b0cecf1793c68dd9c4ca92557ad4064c Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:03:36 +0600 Subject: [PATCH 62/86] docs: clarify payment flows in automotive prepayment use case - Updated the payment process documentation to distinguish between two independent payment flows: Flow A (off-chain payment via `createPayment`) and Flow B (on-chain payment via `processCryptoPayment`). - Enhanced descriptions to improve understanding of the payment process, including the roles of the platform admin and buyer in each flow. - Added details on the NFT minting process as proof of payment for on-chain transactions. --- .../src/use-cases/escrow/healthcare-escrow.md | 45 +++++++++++++------ .../marketplace/ecommerce-marketplace.md | 36 ++++++++++----- .../prepayment/automotive-prepayment.md | 35 +++++++++++---- 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md index 8a678cbd..cb9b369f 100644 --- a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -56,11 +56,17 @@ const isPaused = await treasury.paused(); const isCancelled = await treasury.cancelled(); ``` -### Step 2: Patient books appointment — create payment +### Step 2: Patient books appointment — two independent payment flows + +Sarah books a cardiology consultation with Dr. Rivera. The appointment costs 150 USDC broken down into two line items: consultation (120 USDC) and lab work (30 USDC). + +MedConnect supports two payment methods — they are **not** sequential steps: + +#### Flow A: Off-chain / fiat payment (`createPayment`) > **Role: Platform Admin** — only the platform admin can create payment records. -Sarah books a cardiology consultation with Dr. Rivera. The appointment costs 150 USDC broken down into two line items: consultation (120 USDC) and lab work (30 USDC). +MedConnect creates a payment record on-chain. **No funds move** — Sarah pays through off-chain rails (credit card, insurance billing, etc.) and MedConnect calls `confirmPayment` after verifying receipt. ```typescript import { toHex } from "@oaknetwork/contracts-sdk"; @@ -96,13 +102,15 @@ const txHash = await treasury.createPayment( await oak.waitForReceipt(txHash); ``` -At this point the payment record exists on-chain, but **no funds have moved yet**. The treasury is waiting for Sarah to pay. +At this point the payment record exists on-chain, but **no funds have moved yet**. Sarah pays through off-chain channels. -### Step 3: Patient pays — crypto payment processed on-chain +#### Flow B: On-chain crypto payment (`processCryptoPayment`) > **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. -Sarah opens the MedConnect app, sees the $150 charge, and approves the transfer. Before the treasury can pull funds, Sarah must grant an ERC-20 allowance: +This is a **standalone operation** — it creates the payment record AND transfers ERC-20 tokens in a single transaction. It does **not** require or complete a prior `createPayment` call. An NFT is minted to Sarah as proof of payment. + +Sarah opens the MedConnect app, sees the $150 charge, and approves the transfer: ```typescript import { erc20Abi } from "viem"; @@ -131,7 +139,7 @@ await oak.waitForReceipt(txHash); Funds are now **locked in the treasury**. Sarah cannot withdraw them, and neither can Dr. Rivera — only the platform can release them by confirming delivery. -### Step 4: Doctor confirms service delivery +### Step 3: Doctor confirms service delivery > **Role: Platform Admin** — only the platform admin can confirm payments. @@ -146,7 +154,7 @@ await oak.waitForReceipt(txHash); The payment status is now **confirmed**. Funds are settled and ready for fee disbursement and withdrawal. -### Step 5: Read the final treasury state +### Step 4: Read the final treasury state > **Role: Any caller** — all read functions are public. @@ -161,7 +169,7 @@ const [raised, available, lifetime, refunded] = await oak.multicall([ ]); ``` -### Step 6: Disburse fees +### Step 5: Disburse fees > **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. @@ -172,7 +180,7 @@ const txHash = await treasury.disburseFees(); await oak.waitForReceipt(txHash); ``` -### Step 7: Withdraw settled funds +### Step 6: Withdraw settled funds > **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (Dr. Rivera's clinic). @@ -206,7 +214,7 @@ await treasury.claimRefund(paymentId, SARAH_WALLET_ADDRESS); await treasury.claimRefundSelf(paymentId); ``` -### Step 8: Claim non-goal line items +### Step 7: Claim non-goal line items > **Role: Platform Admin** — only the platform admin can claim non-goal line items. @@ -217,7 +225,7 @@ const txHash = await treasury.claimNonGoalLineItems(USDC_TOKEN_ADDRESS); await oak.waitForReceipt(txHash); ``` -### Step 9: Pause, unpause, or cancel the treasury +### Step 8: Pause, unpause, or cancel the treasury **Pause the treasury:** @@ -308,16 +316,27 @@ Patient (Sarah) MedConnect (Platform Admin) PaymentTreasur | | | | Books appointment | | |------------------------------->| | + | | | + | --- FLOW A: Off-chain / fiat payment --- | + | | | | | createPayment(...) | | | [Platform Admin] | - | |------------------------------>| Payment record created + | |------------------------------>| Payment recorded (no funds) + | | | + | Pays off-chain | | + | (insurance, credit card) | | + |------------------------------->| | + | | | + | --- FLOW B: On-chain crypto payment --- | | | | | ERC-20 approve() | | |--------------------------------------------------------------->| Treasury approved to spend | | | | | processCryptoPayment(...) | | | [Any caller] | - | |------------------------------>| Funds locked in escrow + | |------------------------------>| Payment created + funds locked + | | | + | --- Both flows continue here --- | | | | | Doctor confirms delivery | | | confirmPayment(...) | diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md index 09d5ecce..fa3062a8 100644 --- a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -101,11 +101,15 @@ const txHash = await registry.addItemsBatch(productIds, items); await oak.waitForReceipt(txHash); ``` -### Step 3: Buyer places order — create payment with line items +### Step 3: Buyer places order — two independent payment flows + +CeloMarket supports two payment methods. A platform uses one or both depending on its business model — they are **not** sequential steps. + +#### Flow A: Off-chain / fiat payment (`createPayment`) > **Role: Platform Admin** — only the platform admin can create payment records. -A buyer orders wireless headphones for $79.99. The order breaks down into three line items: product ($69.99), shipping ($7.50), and platform commission ($2.50). +A buyer orders wireless headphones for $79.99. CeloMarket's backend creates a payment record on-chain. **No funds move** — the buyer pays through off-chain rails (credit card, bank transfer, etc.) and CeloMarket calls `confirmPayment` after verifying receipt. ```typescript const orderId = toHex("order-20260415-001", { size: 32 }); @@ -136,11 +140,13 @@ const txHash = await treasury.createPayment( await oak.waitForReceipt(txHash); ``` -### Step 4: Buyer pays — ERC-20 transfer to treasury +#### Flow B: On-chain crypto payment (`processCryptoPayment`) > **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. -The buyer's fiat payment is converted to an **accepted** on-chain ERC-20 (often a stablecoin such as USDC) behind the scenes. Before the treasury can pull funds, the buyer must grant an ERC-20 allowance for the **same `paymentToken`** used in `createPayment`: +This is a **standalone operation** — it creates the payment record AND transfers ERC-20 tokens in a single transaction. It does **not** require or complete a prior `createPayment` call. An NFT is minted to the buyer as proof of payment. + +Before the treasury can pull funds, the buyer must grant an ERC-20 allowance: ```typescript import { erc20Abi } from "viem"; @@ -168,7 +174,7 @@ await oak.waitForReceipt(txHash); Funds are now **locked** — the seller cannot access them until CeloMarket confirms shipment. -### Step 5: Seller ships — platform confirms payment +### Step 4: Seller ships — platform confirms payment > **Role: Platform Admin** — only the platform admin can confirm payments. @@ -191,7 +197,7 @@ const txHash = await treasury.confirmPaymentBatch(orderIds, buyerAddresses); await oak.waitForReceipt(txHash); ``` -### Step 6: Read order state — dashboard view +### Step 5: Read order state — dashboard view > **Role: Any caller** — all read functions are public. @@ -211,7 +217,7 @@ const [raised, available, refunded, expected] = await oak.multicall([ ]); ``` -### Step 7: Fee disbursement +### Step 6: Fee disbursement > **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. @@ -222,7 +228,7 @@ const txHash = await treasury.disburseFees(); await oak.waitForReceipt(txHash); ``` -### Step 8: Seller withdrawal +### Step 7: Seller withdrawal > **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (the seller). @@ -328,16 +334,26 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain | | [Seller] | | |------------------------------->| Product registered | | | + | --- FLOW A: Off-chain / fiat payment --- | + | | | | | createPayment() | | | [Platform Admin] | - | |------------------------------->| Order created + | |------------------------------->| Order recorded (no funds) + | | | + | Buyer pays off-chain | | + | (credit card, etc.) | | + |------------------------->| | + | | | + | --- FLOW B: On-chain crypto payment --- | | | | | ERC-20 approve() | | |------------------------------------------------------> | Treasury approved | | | | | processCryptoPayment() | | | [Any caller] | - | |------------------------------->| Funds locked + | |------------------------------->| Payment created + funds locked + | | | + | --- Both flows continue here --- | | | | | Seller ships product | | | confirmPayment() | diff --git a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md index b7348076..0a8a9806 100644 --- a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md +++ b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md @@ -59,11 +59,17 @@ const isPaused = await treasury.paused(); const isCancelled = await treasury.cancelled(); ``` -### Step 2: Customer orders a vehicle — create prepayment +### Step 2: Customer orders a vehicle — two independent payment flows + +James orders a Karma GS-6 electric sedan with the Performance Package. The total prepayment is $52,500 broken down into line items. + +Karma supports two payment methods — they are **not** sequential steps: + +#### Flow A: Off-chain / fiat payment (`createPayment`) > **Role: Platform Admin** — only the platform admin can create payment records. Must be called within the time window (`launchTime` to `deadline + bufferTime`). -James orders a Karma GS-6 electric sedan with the Performance Package. The total prepayment is $52,500 broken down into line items. +Karma's system creates a payment record on-chain. **No funds move** — James pays through off-chain rails (wire transfer, dealership financing, etc.) and Karma calls `confirmPayment` after verifying receipt. ```typescript const orderId = toHex("karma-order-GS6-2026-0415", { size: 32 }); @@ -96,10 +102,12 @@ const txHash = await treasury.createPayment( await oak.waitForReceipt(txHash); ``` -### Step 3: Customer pays the deposit +#### Flow B: On-chain crypto payment (`processCryptoPayment`) > **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. Must be called within the time window. +This is a **standalone operation** — it creates the payment record AND transfers ERC-20 tokens in a single transaction. It does **not** require or complete a prior `createPayment` call. An NFT is minted to James as proof of payment. + James transfers the full prepayment amount. Before the treasury can pull funds, James must grant an ERC-20 allowance: ```typescript @@ -128,7 +136,7 @@ await oak.waitForReceipt(txHash); Funds are now **locked in the time-constrained treasury**. The clock is ticking toward the 6-month delivery deadline. -### Step 4: Monitor the order status +### Step 3: Monitor the order status > **Role: Any caller** — all read functions are public. @@ -148,7 +156,7 @@ const [raised, available, expected] = await oak.multicall([ ]); ``` -### Step 5 (Success): Vehicle delivered — confirm and withdraw +### Step 4 (Success): Vehicle delivered — confirm and withdraw > **Role: Platform Admin** for `confirmPayment` (must still be within the launch…deadline+buffer window). **Any caller** for `disburseFees` (after `launchTime`). **Platform Admin or Campaign Owner** for `withdraw` (after `launchTime`). @@ -169,7 +177,7 @@ const withdrawTx = await treasury.withdraw(); await oak.waitForReceipt(withdrawTx); ``` -### Step 5 (Failure): Claim window after deadline — platform sweeps expired funds +### Step 4 (Failure): Claim window after deadline — platform sweeps expired funds > **Role: Platform Admin** — only the platform admin can call `claimExpiredFunds`. Callable only after `campaignDeadline + platformClaimDelay`, and only after `launchTime` (time-constrained variant). @@ -265,16 +273,27 @@ Customer (James) Karma (Platform Admin) TimeConstrainedTreasu | | | | Order GS-6 | | |------------------------>| | + | | | + | --- FLOW A: Off-chain / fiat payment --- | + | | | | | createPayment(...) | | | [Platform Admin, in window] | - | |------------------------------->| Order recorded + | |------------------------------->| Order recorded (no funds) + | | | + | Pays off-chain | | + | (wire, financing) | | + |------------------------>| | + | | | + | --- FLOW B: On-chain crypto payment --- | | | | | ERC-20 approve() | | |-------------------------------------------------------->| Treasury approved | | | | | processCryptoPayment(...) | | | [Any caller, in window] | - | |------------------------------->| Funds locked + | |------------------------------->| Payment created + funds locked + | | | + | --- Both flows continue here --- | | | | | --- SUCCESS PATH --- | | | | From 6470b9863421a09db8be5195b3ba995628255d28 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:08:40 +0600 Subject: [PATCH 63/86] docs: update marketplace use case documentation for clarity - Revised the E-Commerce Marketplace documentation to remove references to ItemRegistry, emphasizing the use of PaymentTreasury for product escrow with line items. - Clarified the integration flow and payment processes, including the roles of buyers and sellers, and updated the steps to reflect the removal of ItemRegistry interactions. - Enhanced overall readability and consistency in the documentation regarding payment flows and order processing. --- packages/contracts/src/use-cases/README.md | 12 +-- .../marketplace/ecommerce-marketplace.md | 81 +++---------------- 2 files changed, 11 insertions(+), 82 deletions(-) diff --git a/packages/contracts/src/use-cases/README.md b/packages/contracts/src/use-cases/README.md index 7df98181..7aeb494f 100644 --- a/packages/contracts/src/use-cases/README.md +++ b/packages/contracts/src/use-cases/README.md @@ -17,7 +17,7 @@ Every pledge or payment specifies **`pledgeToken` / `paymentToken`**; treasuries | Use Case | Demo | Contract(s) Used | Business Story | |----------|------|-------------------|----------------| | **Escrow** | [Healthcare Escrow](escrow/healthcare-escrow.md) | PaymentTreasury | MedConnect holds patient payments until a doctor confirms service delivery | -| **Marketplace** | [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) | PaymentTreasury + ItemRegistry | CeloMarket locks buyer funds until seller ships; physical items tracked on-chain | +| **Marketplace** | [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) | PaymentTreasury | CeloMarket locks buyer funds until seller ships; on-chain escrow with line items | | **Prepayment** | [Automotive Prepayment](prepayment/automotive-prepayment.md) | TimeConstrainedPaymentTreasury | Karma Automotive holds vehicle deposits with automatic expiry protection | | **Flexible Funding** | [Community Project](flexible-funding/community-project.md) | CampaignInfoFactory + KeepWhatsRaised | TechForge runs keep-what's-raised campaigns with partial withdrawals, tips, and gateway fees | | **Crowdfunding** | [Creative Campaign](crowdfunding/creative-campaign.md) | CampaignInfoFactory + AllOrNothing | ArtFund runs all-or-nothing campaigns with NFT-backed pledges and reward tiers | @@ -45,7 +45,7 @@ Best for: **escrow**, **marketplace**, **service payments** Funds are held until the platform confirms delivery/service. Supports line items, external fees, batch operations, and refund flows. - [Healthcare Escrow](escrow/healthcare-escrow.md) — service escrow -- [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) — product escrow with ItemRegistry +- [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) — product escrow with line items ### TimeConstrainedPaymentTreasury @@ -71,14 +71,6 @@ Creates a campaign with a goal and deadline. Pledges mint NFTs. If the goal is m - [Creative Campaign](crowdfunding/creative-campaign.md) — indie film funding with reward tiers -### ItemRegistry - -Best for: **physical goods**, **product catalogs**, **shipping/compliance** - -Stores physical item attributes (weight, dimensions, category) on-chain. Useful for dispute resolution, customs declarations, and shipping calculations. - -- [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) — product registration alongside PaymentTreasury - ## Common Patterns Across All Demos ### Simulate Before Send diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md index fa3062a8..ae7b3cd4 100644 --- a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -9,17 +9,15 @@ CeloMarket needs: - **Buyer protection** — funds locked until shipment is confirmed -- **Physical item tracking** — product dimensions, weight, and category stored on-chain via ItemRegistry - **Multi-line-item orders** — product cost, shipping fee, and platform commission as separate line items - **Fee transparency** — protocol and platform fees are tracked and disbursed on-chain - **Fiat-to-fiat UX** — end users see USD prices; crypto conversion happens behind the scenes -## Oak Contracts Used +## Oak Contract Used | Contract | Purpose | |----------|---------| | **PaymentTreasury** | Holds buyer funds until delivery is confirmed | -| **ItemRegistry** | Stores physical product metadata (weight, dimensions, category) | ## Multi-token support @@ -32,17 +30,16 @@ CeloMarket needs: | **Platform Admin** | CeloMarket backend | `createPayment`, `createPaymentBatch`, `confirmPayment`, `confirmPaymentBatch`, `cancelPayment`, `claimRefund(paymentId, address)` (non-NFT), `claimExpiredFunds`, `claimNonGoalLineItems`, `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | | **Platform Admin or Campaign Owner** | CeloMarket or seller | `withdraw`, `cancelTreasury` | | **Buyer** | End customer | ERC-20 `approve`, `processCryptoPayment`, `claimRefundSelf(paymentId)` (NFT payments) | -| **Seller** | Independent merchant | `addItem`, `addItemsBatch` (ItemRegistry) | | **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | -| **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `getItem`, `paused`, etc.) | +| **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `paused`, etc.) | ## Integration Flow -### Step 1: Connect to PaymentTreasury and ItemRegistry +### Step 1: Connect to the PaymentTreasury > **Role: Any caller** — connecting and reading state is public. -CeloMarket's backend connects to both contracts. +CeloMarket's backend connects to the deployed PaymentTreasury contract. ```typescript import { createOakContractsClient, CHAIN_IDS, toHex } from "@oaknetwork/contracts-sdk"; @@ -54,54 +51,9 @@ const oak = createOakContractsClient({ }); const treasury = oak.paymentTreasury(TREASURY_ADDRESS); -const registry = oak.itemRegistry(ITEM_REGISTRY_ADDRESS); ``` -### Step 2: Seller lists a product — register in ItemRegistry - -> **Role: Seller** — the item registry owner (typically the seller or platform) registers items. - -When a seller lists a new product, CeloMarket registers its physical attributes in the ItemRegistry. This data can be used for shipping calculations, customs declarations, and dispute resolution. - -```typescript -const productId = toHex("wireless-headphones-v2", { size: 32 }); - -const item = { - actualWeight: 250n, // 250 grams - height: 200n, // 200mm - width: 180n, // 180mm - length: 80n, // 80mm - category: toHex("electronics", { size: 32 }), - declaredCurrency: toHex("USD", { size: 32 }), -}; - -await registry.simulate.addItem(productId, item); -const txHash = await registry.addItem(productId, item); -await oak.waitForReceipt(txHash); -``` - -For bulk listings, use batch registration: - -```typescript -const productIds = [ - toHex("headphones-black", { size: 32 }), - toHex("headphones-white", { size: 32 }), -]; - -const items = [ - { actualWeight: 250n, height: 200n, width: 180n, length: 80n, - category: toHex("electronics", { size: 32 }), - declaredCurrency: toHex("USD", { size: 32 }) }, - { actualWeight: 250n, height: 200n, width: 180n, length: 80n, - category: toHex("electronics", { size: 32 }), - declaredCurrency: toHex("USD", { size: 32 }) }, -]; - -const txHash = await registry.addItemsBatch(productIds, items); -await oak.waitForReceipt(txHash); -``` - -### Step 3: Buyer places order — two independent payment flows +### Step 2: Buyer places order — two independent payment flows CeloMarket supports two payment methods. A platform uses one or both depending on its business model — they are **not** sequential steps. @@ -174,7 +126,7 @@ await oak.waitForReceipt(txHash); Funds are now **locked** — the seller cannot access them until CeloMarket confirms shipment. -### Step 4: Seller ships — platform confirms payment +### Step 3: Seller ships — platform confirms payment > **Role: Platform Admin** — only the platform admin can confirm payments. @@ -197,7 +149,7 @@ const txHash = await treasury.confirmPaymentBatch(orderIds, buyerAddresses); await oak.waitForReceipt(txHash); ``` -### Step 5: Read order state — dashboard view +### Step 4: Read order state — dashboard view > **Role: Any caller** — all read functions are public. @@ -217,7 +169,7 @@ const [raised, available, refunded, expected] = await oak.multicall([ ]); ``` -### Step 6: Fee disbursement +### Step 5: Fee disbursement > **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. @@ -228,7 +180,7 @@ const txHash = await treasury.disburseFees(); await oak.waitForReceipt(txHash); ``` -### Step 7: Seller withdrawal +### Step 6: Seller withdrawal > **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (the seller). @@ -312,17 +264,6 @@ await oak.waitForReceipt(txHash); const isCancelled = await treasury.cancelled(); ``` -### Reading product data for disputes - -> **Role: Any caller** — `getItem` is a public read function. - -If a dispute arises (e.g. wrong item shipped), CeloMarket can verify the registered product attributes: - -```typescript -const registeredItem = await registry.getItem(SELLER_ADDRESS, productId); -// registeredItem.actualWeight, registeredItem.height, registeredItem.category, etc. -``` - ## Architecture Diagram ``` @@ -330,9 +271,6 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain | | | | Browse & order | | |------------------------->| | - | | addItem() [ItemRegistry] | - | | [Seller] | - | |------------------------------->| Product registered | | | | --- FLOW A: Off-chain / fiat payment --- | | | | @@ -382,7 +320,6 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain - **ERC-20 approval is required** — the buyer must `approve` the treasury contract before `processCryptoPayment` can transfer tokens - **Multi-token** — orders can settle in any **accepted** `paymentToken`; treasury accounting is per token address -- **ItemRegistry** provides on-chain proof of product attributes for dispute resolution and compliance - **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner - **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefundSelf(paymentId)` for NFT payments (signer must be NFT owner) - **Line items** separate product cost, shipping, and commission with configurable goal-counting, fees, and refund rules From c37bbb25a37ebff140a6ef14235f9dba142a578c Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:12:06 +0600 Subject: [PATCH 64/86] docs: update campaign and community project documentation for clarity - Enhanced README files to clarify the roles of Backers and Platform Admins in the campaign process, specifically regarding the recording of payment gateway fees. - Updated the community project documentation to better explain the pledge process, including tips and the recording of gateway fees, ensuring clear guidance for both backers and platform administrators. - Improved overall readability and consistency in the documentation, reflecting recent changes in the pledge and fee recording processes. --- .../02-campaign-keep-whats-raised/README.md | 4 +- .../flexible-funding/community-project.md | 51 +++++++++++-------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md index 4cd88d7b..3299f8f0 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md @@ -23,7 +23,7 @@ Same model as Scenario 1: the campaign whitelists **multiple ERC-20s** per **cur 2. **TechForge** deploys a Keep-What's-Raised treasury for the campaign 3. **ArtFund (Platform Admin)** configures the treasury with withdrawal delays, refund policies, and the fee structure 4. **TechForge** adds reward tiers — and optionally removes one they no longer want to offer -5. **Backers** discover the campaign and pledge — some choose a reward tier, others pledge without a reward as a show of support +5. **Backers** discover the campaign and pledge — some choose a reward tier, others pledge without a reward as a show of support. **ArtFund (Platform Admin)** may record payment gateway fees per pledge via `setPaymentGatewayFee` or `setFeeAndPledge` 6. Two types of withdrawal: - **(a–b) Partial:** ArtFund approves withdrawals (`06a`), then TechForge executes the partial amount (`06b`). Step 3 sets **`withdrawalDelay: 0`** so both scripts can run in one session; use a non-zero delay in production. - **(c) Final:** After the deadline, TechForge sweeps the remaining balance (`06c`) minus applicable fees @@ -44,7 +44,7 @@ Same model as Scenario 1: the campaign whitelists **multiple ERC-20s** per **cur | 2 | `02-deploy-treasury.ts` | Creator | Deploy a Keep-What's-Raised treasury | Required | | 3 | `03-configure-treasury.ts` | Platform Admin | Set withdrawal delays, refund policies, and fees | Required | | 4 | `04-manage-rewards.ts` | Creator | Add reward tiers + optionally remove a tier | Required | -| 5 | `05-backer-pledge.ts` | Backer | Pledge with or without a reward; platform can set gateway fees | Required | +| 5 | `05-backer-pledge.ts` | Backer + Platform Admin | Backer pledges with or without a reward; Platform Admin records gateway fees (`setPaymentGatewayFee` / `setFeeAndPledge`) | Required | | 6a | `06a-approve-partial-withdrawal.ts` | Platform Admin | `approveWithdrawal` — required before any mid-campaign `withdraw` | Required | | 6b | `06b-execute-partial-withdrawal.ts` | Creator | Partial `withdraw(token, amount)` after delay (0 in Step 3 for sequential runs) | Required | | 6c | `06c-final-withdrawal.ts` | Creator or Platform Admin | Post-deadline withdrawal — sweep remaining balance with fees | Required | diff --git a/packages/contracts/src/use-cases/flexible-funding/community-project.md b/packages/contracts/src/use-cases/flexible-funding/community-project.md index d23f548d..8697aae2 100644 --- a/packages/contracts/src/use-cases/flexible-funding/community-project.md +++ b/packages/contracts/src/use-cases/flexible-funding/community-project.md @@ -192,12 +192,14 @@ const txHash = await kwrTreasury.addRewards(rewardNames, rewards); await oak.waitForReceipt(txHash); ``` -### Step 5: Backers pledge with tips and gateway fees +### Step 5: Backers pledge with tips -Backers pledge to Lena's campaign. KWR supports **tips** (on top of the pledge) and **payment gateway fees** (recorded per-pledge). +Backers pledge to Lena's campaign. KWR supports **tips** (on top of the pledge), which go directly to the platform. **Pledge with a reward and a tip:** +> **Role: Any caller (backer)** — `pledgeForAReward` is permissionless but time-gated (must be within the campaign window). + ```typescript const pledgeId = toHex("pledge-001", { size: 32 }); @@ -211,7 +213,30 @@ const txHash = await kwrTreasury.pledgeForAReward( await oak.waitForReceipt(txHash); ``` -**Record a payment gateway fee for the pledge:** +**Pledge without a reward:** + +> **Role: Any caller (backer)** — `pledgeWithoutAReward` is permissionless but time-gated. + +```typescript +const pledgeId = toHex("pledge-003", { size: 32 }); + +const txHash = await kwrTreasury.pledgeWithoutAReward( + pledgeId, + BACKER_ADDRESS, + USDC_TOKEN_ADDRESS, + 30_000000n, // 30 USDC pledge (6 decimals) + 2_000000n, // 2 USDC tip +); +await oak.waitForReceipt(txHash); +``` + +### Step 5b: Platform records payment gateway fees + +> **Role: Platform Admin** — `setPaymentGatewayFee` and `setFeeAndPledge` are admin-gated (`onlyPlatformAdmin`). These are called by the platform backend, not by the backer. + +Platforms that charge on-ramp or payment processing fees can record them on-chain for transparent accounting. There are two approaches: + +**Record a gateway fee for an existing pledge:** ```typescript await kwrTreasury.setPaymentGatewayFee( @@ -220,7 +245,7 @@ await kwrTreasury.setPaymentGatewayFee( ); ``` -**Combined fee + pledge in one transaction:** +**Combined fee + pledge in one transaction** — records the gateway fee and creates the pledge atomically. Tokens are transferred from the admin wallet: ```typescript const pledgeId = toHex("pledge-002", { size: 32 }); @@ -238,21 +263,6 @@ const txHash = await kwrTreasury.setFeeAndPledge( await oak.waitForReceipt(txHash); ``` -**Pledge without a reward:** - -```typescript -const pledgeId = toHex("pledge-003", { size: 32 }); - -const txHash = await kwrTreasury.pledgeWithoutAReward( - pledgeId, - BACKER_ADDRESS, - USDC_TOKEN_ADDRESS, - 30_000000n, // 30 USDC pledge (6 decimals) - 2_000000n, // 2 USDC tip -); -await oak.waitForReceipt(txHash); -``` - ### Step 6: Mid-campaign partial withdrawal Lena needs funds to order components from her supplier. TechForge approves a partial withdrawal. @@ -359,7 +369,8 @@ Creator (Lena) TechForge Platform KeepWhatsRaised Treasury Backers pledge + tip | | | pledgeForAReward() | | |----------------------->|------------------------------>| NFT minted, funds + tip locked - | setPaymentGatewayFee()| | + | | setPaymentGatewayFee() | + | | [Platform Admin] | | |------------------------------>| Gateway fee recorded | | | | --- MID-CAMPAIGN WITHDRAWAL --- | From 59858d307d0b3cd55862f076e40445ded90d7aec Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:15:14 +0600 Subject: [PATCH 65/86] docs: update refund process examples to include NFT approval requirements - Added prerequisites for backers to approve the treasury contract to manage their pledge NFTs before calling `claimRefund` in both all-or-nothing and keep-whats-raised examples. - Clarified that the treasury is an ERC-721, requiring direct approval on the treasury entity. - Enhanced comments to improve understanding of the refund process and NFT handling. --- .../01-campaign-all-or-nothing/09b-failure-refund.ts | 12 ++++++++++++ .../02-campaign-keep-whats-raised/11-claim-refund.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts index 00838c53..45ae086d 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts @@ -11,6 +11,12 @@ * This means a backer can call it themselves, or a platform bot could * trigger refunds on behalf of all backers. * + * Prerequisite: the backer must approve the treasury contract to + * manage their pledge NFT before calling `claimRefund`. The treasury + * is an ERC-721 itself, so `approve` is called directly on the + * treasury entity (not on a separate NFT contract). Use `approve` + * for a single token or `setApprovalForAll` for all tokens at once. + * * The contract does two things in a single transaction: * * 1. Burns the pledge NFT (the token is permanently destroyed) @@ -37,6 +43,12 @@ if (!pledgeTokenIdEnv) { throw new Error("ALEX_PLEDGE_TOKEN_ID is required (pledge NFT tokenId from Step 6)."); } const tokenId = BigInt(pledgeTokenIdEnv); + +// Approve the treasury to burn this pledge NFT. +// The AllOrNothing treasury IS the ERC-721, so approve is called on the treasury itself. +const approveTxHash = await treasury.approve(treasuryAddress, tokenId); +await alexOak.waitForReceipt(approveTxHash); + const refundTxHash = await treasury.claimRefund(tokenId); const refundReceipt = await alexOak.waitForReceipt(refundTxHash); console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts index 08983fa4..189956d5 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts @@ -6,6 +6,12 @@ * the NFT and returns the pledged tokens (minus any payment fees) * to the NFT owner's wallet in a single transaction. * + * Prerequisite: the backer must approve the treasury contract to + * manage their pledge NFT before calling `claimRefund`. The KWR + * treasury is an ERC-721 itself, so `approve` is called directly on + * the treasury entity (not on a separate NFT contract). Use `approve` + * for a single token or `setApprovalForAll` for all tokens at once. + * * Refund eligibility timing: * * - If the campaign is NOT cancelled: refunds are available after @@ -33,6 +39,12 @@ if (!pledgeTokenIdEnv) { throw new Error("BACKER_PLEDGE_TOKEN_ID is required (pledge NFT tokenId from Step 5)."); } const tokenId = BigInt(pledgeTokenIdEnv); + +// Approve the treasury to burn this pledge NFT. +// The KWR treasury IS the ERC-721, so approve is called on the treasury itself. +const approveTxHash = await treasury.approve(treasuryAddress, tokenId); +await backerOak.waitForReceipt(approveTxHash); + const refundTxHash = await treasury.claimRefund(tokenId); const refundReceipt = await backerOak.waitForReceipt(refundTxHash); console.log(`Refund claimed at block ${refundReceipt.blockNumber}`); From c934f8a894ae22746b1479598d62790f76c1e0bf Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:15:26 +0600 Subject: [PATCH 66/86] docs: enhance refund process documentation with NFT approval steps - Added instructions for backers to approve the treasury to manage their pledge NFTs before calling `claimRefund` in both all-or-nothing and flexible funding scenarios. - Clarified that the treasury acts as the ERC-721 contract, requiring direct approval on the treasury entity. - Improved comments to enhance understanding of the refund process and NFT handling. --- .../src/use-cases/crowdfunding/creative-campaign.md | 7 ++++++- .../src/use-cases/flexible-funding/community-project.md | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md index 09b9e7dc..53784c94 100644 --- a/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md +++ b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md @@ -318,8 +318,13 @@ await oak.waitForReceipt(txHash); If the deadline passes and the goal was not reached, each backer can claim a refund by providing their pledge NFT token ID. The NFT is burned during the refund. +Before calling `claimRefund`, the backer must approve the treasury to manage their pledge NFT. The AllOrNothing treasury **is** the ERC-721 contract itself, so `approve` is called directly on the treasury entity: + ```typescript -// Each backer calls claimRefund with their pledge NFT token ID +// Approve the treasury to burn this pledge NFT +await aonTreasury.approve(AON_TREASURY_ADDRESS, tokenId); + +// Claim the refund — burns the NFT and returns pledged tokens const txHash = await aonTreasury.claimRefund(tokenId); await oak.waitForReceipt(txHash); // Backer receives their pledge amount back; NFT is burned diff --git a/packages/contracts/src/use-cases/flexible-funding/community-project.md b/packages/contracts/src/use-cases/flexible-funding/community-project.md index 8697aae2..0413b03c 100644 --- a/packages/contracts/src/use-cases/flexible-funding/community-project.md +++ b/packages/contracts/src/use-cases/flexible-funding/community-project.md @@ -332,8 +332,15 @@ await oak.waitForReceipt(txHash); If a backer wants a refund, they can claim one — but only after the deadline + the configured refund delay (14 days in this example). +Before calling `claimRefund`, the backer must approve the treasury to manage their pledge NFT. The KWR treasury **is** the ERC-721 contract itself, so `approve` is called directly on the treasury entity: + ```typescript // After deadline + 14-day refund delay + +// Approve the treasury to burn this pledge NFT +await kwrTreasury.approve(KWR_TREASURY_ADDRESS, backerTokenId); + +// Claim the refund — burns the NFT and returns pledged tokens const txHash = await kwrTreasury.claimRefund(backerTokenId); await oak.waitForReceipt(txHash); // Pledge amount refunded; NFT burned From 2d02bbf8aaf793d8800cbfaa32467ae3dfc9951e Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:22:29 +0600 Subject: [PATCH 67/86] fix: update privateKey handling in README example - Changed the privateKey assignment in the example to use a non-null assertion, ensuring that the environment variable is treated as defined. This improves type safety and prevents potential runtime errors. --- packages/contracts/src/examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/examples/README.md b/packages/contracts/src/examples/README.md index b3e68256..d838023f 100644 --- a/packages/contracts/src/examples/README.md +++ b/packages/contracts/src/examples/README.md @@ -150,7 +150,7 @@ import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, rpcUrl: process.env.RPC_URL, - privateKey: process.env.PRIVATE_KEY as `0x${string}`, + privateKey: process.env.PRIVATE_KEY! as `0x${string}`, }); ``` From 827c5097c089e1921a83d05b77dca4188620db25 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 19:34:15 +0600 Subject: [PATCH 68/86] docs: enhance use case documentation for clarity and accuracy - Updated the README files for various use cases, including Escrow, Marketplace, and Crowdfunding, to improve clarity on roles, payment processes, and refund mechanisms. - Clarified the prepayment process in the Automotive Prepayment documentation, emphasizing time-based expiry for vehicle deposits. - Enhanced the description of the KeepWhatsRaised model in the Community Project documentation, detailing the tip collection process and partial withdrawal capabilities. - Improved overall readability and consistency across all documentation, ensuring accurate representation of the functionalities and processes involved. --- packages/contracts/src/use-cases/README.md | 19 ++++++- .../crowdfunding/creative-campaign.md | 50 +++++++++++-------- .../src/use-cases/escrow/healthcare-escrow.md | 2 +- .../flexible-funding/community-project.md | 14 +++--- .../marketplace/ecommerce-marketplace.md | 7 +-- 5 files changed, 58 insertions(+), 34 deletions(-) diff --git a/packages/contracts/src/use-cases/README.md b/packages/contracts/src/use-cases/README.md index 7aeb494f..7828efe2 100644 --- a/packages/contracts/src/use-cases/README.md +++ b/packages/contracts/src/use-cases/README.md @@ -18,7 +18,7 @@ Every pledge or payment specifies **`pledgeToken` / `paymentToken`**; treasuries |----------|------|-------------------|----------------| | **Escrow** | [Healthcare Escrow](escrow/healthcare-escrow.md) | PaymentTreasury | MedConnect holds patient payments until a doctor confirms service delivery | | **Marketplace** | [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) | PaymentTreasury | CeloMarket locks buyer funds until seller ships; on-chain escrow with line items | -| **Prepayment** | [Automotive Prepayment](prepayment/automotive-prepayment.md) | TimeConstrainedPaymentTreasury | Karma Automotive holds vehicle deposits with automatic expiry protection | +| **Prepayment** | [Automotive Prepayment](prepayment/automotive-prepayment.md) | TimeConstrainedPaymentTreasury | Karma Automotive holds vehicle deposits with time-based expiry; expired funds are swept to the platform/protocol, and end-customer refunds are handled per Karma's policy | | **Flexible Funding** | [Community Project](flexible-funding/community-project.md) | CampaignInfoFactory + KeepWhatsRaised | TechForge runs keep-what's-raised campaigns with partial withdrawals, tips, and gateway fees | | **Crowdfunding** | [Creative Campaign](crowdfunding/creative-campaign.md) | CampaignInfoFactory + AllOrNothing | ArtFund runs all-or-nothing campaigns with NFT-backed pledges and reward tiers | @@ -88,11 +88,19 @@ await oak.waitForReceipt(txHash); Batch multiple reads into a single RPC call: ```typescript +// PaymentTreasury / KeepWhatsRaised — all three methods available const [raised, available, refunded] = await oak.multicall([ () => treasury.getRaisedAmount(), () => treasury.getAvailableRaisedAmount(), () => treasury.getRefundedAmount(), ]); + +// AllOrNothing — uses getRaisedAmount + getLifetimeRaisedAmount (no getAvailableRaisedAmount) +const [raised, lifetime, refunded] = await oak.multicall([ + () => aonTreasury.getRaisedAmount(), + () => aonTreasury.getLifetimeRaisedAmount(), + () => aonTreasury.getRefundedAmount(), +]); ``` ### Fee Lifecycle @@ -101,9 +109,16 @@ Fees are always disbursed before withdrawal: ```typescript await treasury.disburseFees(); // protocol + platform fees distributed -await treasury.withdraw(); // remaining funds to the campaign owner/seller +await treasury.withdraw(); // AllOrNothing / PaymentTreasury — sends all remaining funds ``` +> **KeepWhatsRaised** uses a different withdrawal model — `withdraw(token, amount)` for partial withdrawals and `claimFund()` for the final withdrawal: +> +> ```typescript +> await kwrTreasury.withdraw(USDC_TOKEN_ADDRESS, 3_000_000000n); // partial withdrawal +> await kwrTreasury.claimFund(); // final withdrawal after deadline +> ``` + ### Signer Flexibility The SDK supports three levels of signer configuration for different architectures: diff --git a/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md index 53784c94..02faa045 100644 --- a/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md +++ b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md @@ -34,8 +34,8 @@ Raised balances and refunds are tracked **per token**; amounts use **that token | Role | Who | On-Chain Functions | |------|-----|--------------------| -| **Platform Admin** | ArtFund backend | `createCampaign`, `deploy` (treasury), `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | -| **Creator (Campaign Owner)** | Maya (indie filmmaker) | `addRewards`, `removeReward`, `cancelTreasury` | +| **Platform Admin** | ArtFund backend | `deploy` (treasury), `pauseTreasury`, `unpauseTreasury`, `cancelTreasury` | +| **Creator (Campaign Owner)** | Maya (indie filmmaker) | `createCampaign`, `addRewards`, `removeReward`, `cancelTreasury` | | **Backer** | Community supporters | ERC-20 `approve`, `pledgeForAReward`, `pledgeWithoutAReward`, `claimRefund` | | **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | | **Any caller** | Anyone | `disburseFees`, `withdraw`, all read functions (`getReward`, `getRaisedAmount`, `paused`, etc.) | @@ -44,13 +44,13 @@ Raised balances and refunds are tracked **per token**; amounts use **that token ### Step 1: Creator submits campaign — create on-chain -> **Role: Platform Admin** — only the enlisted platform can create campaigns through the factory. +> **Role: Any caller** — `createCampaign` is permissionless; the factory validates that the selected platform(s) are enlisted and that campaign timing constraints are met. Maya wants to fund her documentary "Voices of the Valley." She needs 10,000 USDC and sets a 30-day deadline. ArtFund's backend creates the campaign on-chain. ```typescript import { - createOakContractsClient, CHAIN_IDS, toHex, keccak256, id, addDays, + createOakContractsClient, CHAIN_IDS, toHex, keccak256, } from "@oaknetwork/contracts-sdk"; import type { CreateCampaignParams } from "@oaknetwork/contracts-sdk"; @@ -188,7 +188,7 @@ ArtFund can verify a reward tier's configuration, and Maya can remove one that's ```typescript // Read a specific reward tier const reward = await aonTreasury.getReward(toHex("signed-poster", { size: 32 })); -// reward.rewardValue — minimum pledge amount (in 18-decimal normalized form) +// reward.rewardValue — minimum pledge amount (in the campaign token's native decimals) // reward.isRewardTier — true for tiered rewards // reward.itemId — physical/digital item IDs included // reward.itemValue — declared value of each item @@ -314,7 +314,7 @@ await oak.waitForReceipt(txHash); ### Step 8 (Failure): Goal not met — backers claim refunds -> **Role: Any caller** — `claimRefund` is permissionless, but the refund is always sent to the current NFT owner. Backers can also claim refunds before the deadline if they change their mind. +> **Role: Any caller** — `claimRefund` is permissionless, but the refund is always sent to the current NFT owner. If the deadline passes and the goal was not reached, each backer can claim a refund by providing their pledge NFT token ID. The NFT is burned during the refund. @@ -378,11 +378,13 @@ Each pledge NFT stores on-chain metadata accessible through CampaignInfo: ```typescript const pledgeData = await campaign.getPledgeData(tokenId); -// pledgeData.backer — backer wallet address -// pledgeData.reward — selected reward (bytes32) -// pledgeData.amount — pledge amount -// pledgeData.treasury — treasury address +// pledgeData.backer — backer wallet address +// pledgeData.reward — selected reward (bytes32) +// pledgeData.treasury — treasury address // pledgeData.tokenAddress — ERC-20 token used +// pledgeData.amount — pledge amount +// pledgeData.shippingFee — shipping fee (0n if none) +// pledgeData.tipAmount — tip amount (0n if none) const nftOwner = await campaign.ownerOf(tokenId); const tokenURI = await campaign.tokenURI(tokenId); @@ -393,21 +395,22 @@ const tokenURI = await campaign.tokenURI(tokenId); ``` Creator (Maya) ArtFund (Platform Admin) Blockchain | | | - | Submit campaign | | - |----------------------->| createCampaign(...) | - | |--------------------------->| CampaignInfo deployed + | createCampaign(...) | | + | [Any caller] | | + |---------------------------------------------------->| CampaignInfo deployed | | | | | deploy(platformHash, | | | campaignInfo, 0) | | |--------------------------->| AllOrNothing treasury deployed | | | - | Add rewards | addRewards(...) | + | addRewards(...) | | | [Campaign Owner] | | - |----------------------->|--------------------------->| Reward tiers registered + |---------------------------------------------------->| Reward tiers registered | | | - | Read/remove reward | getReward() / | - | [Anyone / Owner] | removeReward() | - |----------------------->|--------------------------->| Reward read or removed + | getReward() / | | + | removeReward() | | + | [Anyone / Owner] | | + |---------------------------------------------------->| Reward read or removed | | | Backers | | | ERC-20 approve() | | @@ -419,10 +422,13 @@ Backers | | | pledgeWithoutReward()| | |----------------------->|--------------------------->| NFT minted, funds locked | | | - | [Platform Admin or | pauseTreasury() / | - | Campaign Owner] | unpauseTreasury() / | - | | cancelTreasury() | - | |--------------------------->| Treasury state updated + | [Platform Admin] | pauseTreasury() / | + | | unpauseTreasury() | + | |--------------------------->| Treasury paused/unpaused + | | | + | [Platform Admin or | cancelTreasury() | + | Campaign Owner] | | + | |--------------------------->| Treasury cancelled | | | | --- DEADLINE REACHED --- | | | | diff --git a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md index cb9b369f..b4282bf4 100644 --- a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -207,7 +207,7 @@ await treasury.cancelPayment(paymentId); await treasury.claimRefund(paymentId, SARAH_WALLET_ADDRESS); ``` -**For crypto payments (NFT was minted via `processCryptoPayment` or `confirmPayment` with buyerAddress):** +**For crypto payments (NFT was minted via `processCryptoPayment`):** ```typescript // Anyone can trigger the refund — funds go to the current NFT owner, and the NFT is burned diff --git a/packages/contracts/src/use-cases/flexible-funding/community-project.md b/packages/contracts/src/use-cases/flexible-funding/community-project.md index 0413b03c..d06e062b 100644 --- a/packages/contracts/src/use-cases/flexible-funding/community-project.md +++ b/packages/contracts/src/use-cases/flexible-funding/community-project.md @@ -4,7 +4,7 @@ **TechForge** is a technology platform that helps hardware startups raise funds from their community. Unlike all-or-nothing crowdfunding, TechForge uses a **keep-what's-raised** model: creators keep whatever they raise, even if they don't hit their goal. This works well for hardware projects where any amount of funding helps move the project forward. -TechForge also lets backers **tip** creators, charges **payment gateway fees** on each pledge, and allows creators to make **partial withdrawals** during the campaign (with platform approval) to cover manufacturing costs before the deadline. +TechForge also lets backers add **tips** to their pledges, charges **payment gateway fees** on each pledge, and allows creators to make **partial withdrawals** during the campaign (with platform approval) to cover manufacturing costs before the deadline. Tips are collected by the platform via `claimTip` and sent to the configured **platform tip recipient**. ## Why Oak? @@ -312,7 +312,7 @@ await oak.waitForReceipt(txHash); ### Step 9: Platform claims tips -Tips are claimed separately by the platform and forwarded to the creator. +Tips are claimed separately by the platform. The `claimTip` function transfers accumulated tips to the **platform tip recipient** (configured during platform enlistment). ```typescript const txHash = await kwrTreasury.claimTip(); @@ -364,14 +364,16 @@ await kwrTreasury.updateGoalAmount(20_000_000000n); // 20,000 USDC ``` Creator (Lena) TechForge Platform KeepWhatsRaised Treasury | | | - | Submit campaign | createCampaign(...) | - |----------------------->|------------------------------>| Campaign created + | createCampaign(...) | | + | [Any caller] | | + |------------------------------------------------------->| Campaign created | | deploy(hash, info, 1) | | |------------------------------>| KWR treasury deployed | | configureTreasury(...) | | |------------------------------>| Delays, fees configured - | Add rewards | addRewards(...) | - |----------------------->|------------------------------>| Reward tiers set + | addRewards(...) | | + | [Campaign Owner] | | + |------------------------------------------------------->| Reward tiers set | | | Backers pledge + tip | | | pledgeForAReward() | | diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md index ae7b3cd4..663ee38a 100644 --- a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -66,6 +66,7 @@ A buyer orders wireless headphones for $79.99. CeloMarket's backend creates a pa ```typescript const orderId = toHex("order-20260415-001", { size: 32 }); const buyerId = toHex("buyer-alex-042", { size: 32 }); +const itemId = toHex("wireless-headphones-v2", { size: 32 }); const lineItems = [ { typeId: toHex("product", { size: 32 }), amount: 69_990000n }, // $69.99 USDC (6 decimals) @@ -81,12 +82,12 @@ const totalAmount = 79_990000n; // $79.99 USDC const expiration = BigInt(Math.floor(Date.now() / 1000) + 30 * 86400); // 30 days await treasury.simulate.createPayment( - orderId, buyerId, productId, USDC_TOKEN_ADDRESS, + orderId, buyerId, itemId, USDC_TOKEN_ADDRESS, totalAmount, expiration, lineItems, externalFees, ); const txHash = await treasury.createPayment( - orderId, buyerId, productId, USDC_TOKEN_ADDRESS, + orderId, buyerId, itemId, USDC_TOKEN_ADDRESS, totalAmount, expiration, lineItems, externalFees, ); await oak.waitForReceipt(txHash); @@ -118,7 +119,7 @@ Now the payment can be processed: ```typescript const txHash = await treasury.processCryptoPayment( - orderId, productId, BUYER_ADDRESS, USDC_TOKEN_ADDRESS, + orderId, itemId, BUYER_ADDRESS, USDC_TOKEN_ADDRESS, totalAmount, lineItems, externalFees, ); await oak.waitForReceipt(txHash); From 9b0eece454a0a807135a63e2cd88e5a6d9bb36b9 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 20:25:50 +0600 Subject: [PATCH 69/86] refactor: remove unused functions and events from AllOrNothing and KeepWhatsRaised contracts - Eliminate redundant functions and events related to token management (e.g., Approval, Transfer) from the AllOrNothing and KeepWhatsRaised contracts to streamline the ABI. - Update event and read/write methods accordingly to reflect the removal of these functions, enhancing code clarity and maintainability. - Ensure that the remaining contract functionalities remain intact and operational. --- .../src/contracts/all-or-nothing/abi.ts | 150 ------------------ .../src/contracts/all-or-nothing/events.ts | 18 --- .../src/contracts/all-or-nothing/reads.ts | 26 +-- .../src/contracts/all-or-nothing/simulate.ts | 65 -------- .../src/contracts/all-or-nothing/types.ts | 50 +----- .../src/contracts/all-or-nothing/writes.ts | 20 --- .../src/contracts/campaign-info/abi.ts | 37 +++++ .../src/contracts/campaign-info/reads.ts | 6 + .../src/contracts/campaign-info/simulate.ts | 26 +++ .../src/contracts/campaign-info/types.ts | 12 ++ .../src/contracts/campaign-info/writes.ts | 8 + .../src/contracts/keep-whats-raised/abi.ts | 143 ----------------- .../src/contracts/keep-whats-raised/events.ts | 18 --- .../src/contracts/keep-whats-raised/reads.ts | 50 +----- 14 files changed, 92 insertions(+), 537 deletions(-) diff --git a/packages/contracts/src/contracts/all-or-nothing/abi.ts b/packages/contracts/src/contracts/all-or-nothing/abi.ts index b632e029..f9229832 100644 --- a/packages/contracts/src/contracts/all-or-nothing/abi.ts +++ b/packages/contracts/src/contracts/all-or-nothing/abi.ts @@ -53,26 +53,6 @@ export const ALL_OR_NOTHING_ABI = [ { inputs: [], name: "TreasuryFeeNotDisbursed", type: "error" }, { inputs: [], name: "TreasurySuccessConditionNotFulfilled", type: "error" }, { inputs: [], name: "TreasuryTransferFailed", type: "error" }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "address", name: "owner", type: "address" }, - { indexed: true, internalType: "address", name: "approved", type: "address" }, - { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "Approval", - type: "event", - }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "address", name: "owner", type: "address" }, - { indexed: true, internalType: "address", name: "operator", type: "address" }, - { indexed: false, internalType: "bool", name: "approved", type: "bool" }, - ], - name: "ApprovalForAll", - type: "event", - }, { anonymous: false, inputs: [ @@ -138,16 +118,6 @@ export const ALL_OR_NOTHING_ABI = [ type: "event", }, { anonymous: false, inputs: [], name: "SuccessConditionNotFulfilled", type: "event" }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "address", name: "from", type: "address" }, - { indexed: true, internalType: "address", name: "to", type: "address" }, - { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "Transfer", - type: "event", - }, { anonymous: false, inputs: [ @@ -235,30 +205,6 @@ export const ALL_OR_NOTHING_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [ - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "approve", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [{ internalType: "address", name: "owner", type: "address" }], - name: "balanceOf", - outputs: [{ internalType: "uint256", name: "", type: "uint256" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], - name: "burn", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], name: "claimRefund", @@ -273,13 +219,6 @@ export const ALL_OR_NOTHING_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], - name: "getApproved", - outputs: [{ internalType: "address", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, { inputs: [], name: "getLifetimeRaisedAmount", @@ -329,30 +268,6 @@ export const ALL_OR_NOTHING_ABI = [ stateMutability: "view", type: "function", }, - { - inputs: [ - { internalType: "address", name: "owner", type: "address" }, - { internalType: "address", name: "operator", type: "address" }, - ], - name: "isApprovedForAll", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [], - name: "name", - outputs: [{ internalType: "string", name: "", type: "string" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], - name: "ownerOf", - outputs: [{ internalType: "address", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, { inputs: [], name: "paused", @@ -390,71 +305,6 @@ export const ALL_OR_NOTHING_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [ - { internalType: "address", name: "from", type: "address" }, - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "safeTransferFrom", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "from", type: "address" }, - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - { internalType: "bytes", name: "data", type: "bytes" }, - ], - name: "safeTransferFrom", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "operator", type: "address" }, - { internalType: "bool", name: "approved", type: "bool" }, - ], - name: "setApprovalForAll", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [{ internalType: "bytes4", name: "interfaceId", type: "bytes4" }], - name: "supportsInterface", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [], - name: "symbol", - outputs: [{ internalType: "string", name: "", type: "string" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], - name: "tokenURI", - outputs: [{ internalType: "string", name: "", type: "string" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "from", type: "address" }, - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "transferFrom", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [], name: "withdraw", diff --git a/packages/contracts/src/contracts/all-or-nothing/events.ts b/packages/contracts/src/contracts/all-or-nothing/events.ts index 542dff4b..881ef1da 100644 --- a/packages/contracts/src/contracts/all-or-nothing/events.ts +++ b/packages/contracts/src/contracts/all-or-nothing/events.ts @@ -109,18 +109,9 @@ export function createAllOrNothingEvents( async getCancelledLogs(options) { return fetchEventLogs(publicClient, address, "Cancelled", options); }, - async getTransferLogs(options) { - return fetchEventLogs(publicClient, address, "Transfer", options); - }, async getSuccessConditionNotFulfilledLogs(options) { return fetchEventLogs(publicClient, address, "SuccessConditionNotFulfilled", options); }, - async getApprovalLogs(options) { - return fetchEventLogs(publicClient, address, "Approval", options); - }, - async getApprovalForAllLogs(options) { - return fetchEventLogs(publicClient, address, "ApprovalForAll", options); - }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); }, @@ -151,17 +142,8 @@ export function createAllOrNothingEvents( watchCancelled(onLogs) { return createWatcher(publicClient, address, "Cancelled", onLogs); }, - watchTransfer(onLogs) { - return createWatcher(publicClient, address, "Transfer", onLogs); - }, watchSuccessConditionNotFulfilled(onLogs) { return createWatcher(publicClient, address, "SuccessConditionNotFulfilled", onLogs); }, - watchApproval(onLogs) { - return createWatcher(publicClient, address, "Approval", onLogs); - }, - watchApprovalForAll(onLogs) { - return createWatcher(publicClient, address, "ApprovalForAll", onLogs); - }, }; } diff --git a/packages/contracts/src/contracts/all-or-nothing/reads.ts b/packages/contracts/src/contracts/all-or-nothing/reads.ts index 6dcb0810..e628bab1 100644 --- a/packages/contracts/src/contracts/all-or-nothing/reads.ts +++ b/packages/contracts/src/contracts/all-or-nothing/reads.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient } from "../../lib"; import { ALL_OR_NOTHING_ABI } from "./abi"; import type { AllOrNothingReads } from "./types"; -import type { Bytes4, TieredReward } from "../../types/structs"; +import type { TieredReward } from "../../types/structs"; /** * Builds read methods for an AllOrNothing treasury contract instance. @@ -44,29 +44,5 @@ export function createAllOrNothingReads( async cancelled() { return publicClient.readContract({ ...contract, functionName: "cancelled" }); }, - async balanceOf(owner: Address) { - return publicClient.readContract({ ...contract, functionName: "balanceOf", args: [owner] }); - }, - async ownerOf(tokenId: bigint) { - return publicClient.readContract({ ...contract, functionName: "ownerOf", args: [tokenId] }); - }, - async tokenURI(tokenId: bigint) { - return publicClient.readContract({ ...contract, functionName: "tokenURI", args: [tokenId] }); - }, - async name() { - return publicClient.readContract({ ...contract, functionName: "name" }); - }, - async symbol() { - return publicClient.readContract({ ...contract, functionName: "symbol" }); - }, - async getApproved(tokenId: bigint) { - return publicClient.readContract({ ...contract, functionName: "getApproved", args: [tokenId] }); - }, - async isApprovedForAll(owner: Address, operator: Address) { - return publicClient.readContract({ ...contract, functionName: "isApprovedForAll", args: [owner, operator] }); - }, - async supportsInterface(interfaceId: Bytes4) { - return publicClient.readContract({ ...contract, functionName: "supportsInterface", args: [interfaceId] }); - }, }; } diff --git a/packages/contracts/src/contracts/all-or-nothing/simulate.ts b/packages/contracts/src/contracts/all-or-nothing/simulate.ts index 617b90f0..90ea2651 100644 --- a/packages/contracts/src/contracts/all-or-nothing/simulate.ts +++ b/packages/contracts/src/contracts/all-or-nothing/simulate.ts @@ -155,70 +155,5 @@ export function createAllOrNothingSimulate( ); return toSimulationResult(response); }, - async burn(tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "burn", - args: [tokenId], - }), - ); - return toSimulationResult(response); - }, - async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "approve", - args: [to, tokenId], - }), - ); - return toSimulationResult(response); - }, - async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "setApprovalForAll", - args: [operator, approved], - }), - ); - return toSimulationResult(response); - }, - async safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "safeTransferFrom", - args: [from, to, tokenId], - }), - ); - return toSimulationResult(response); - }, - async transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "transferFrom", - args: [from, to, tokenId], - }), - ); - return toSimulationResult(response); - }, }; } diff --git a/packages/contracts/src/contracts/all-or-nothing/types.ts b/packages/contracts/src/contracts/all-or-nothing/types.ts index c04e9643..c300ada9 100644 --- a/packages/contracts/src/contracts/all-or-nothing/types.ts +++ b/packages/contracts/src/contracts/all-or-nothing/types.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from "../../lib"; -import type { Bytes4, TieredReward } from "../../types/structs"; +import type { TieredReward } from "../../types/structs"; import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; @@ -21,22 +21,6 @@ export interface AllOrNothingReads { paused(): Promise; /** Returns true if the treasury has been cancelled. */ cancelled(): Promise; - /** Returns the NFT balance for the given owner address. */ - balanceOf(owner: Address): Promise; - /** Returns the owner of a pledge NFT by token ID. */ - ownerOf(tokenId: bigint): Promise
; - /** Returns the token URI for a pledge NFT. */ - tokenURI(tokenId: bigint): Promise; - /** Returns the ERC-721 collection name. */ - name(): Promise; - /** Returns the ERC-721 collection symbol. */ - symbol(): Promise; - /** Returns the approved address for a token ID. */ - getApproved(tokenId: bigint): Promise
; - /** Returns true if operator is approved for all tokens of owner. */ - isApprovedForAll(owner: Address, operator: Address): Promise; - /** Returns true if the contract implements the given ERC-165 interface. */ - supportsInterface(interfaceId: Bytes4): Promise; } /** Write methods for an AllOrNothing treasury contract instance. */ @@ -61,16 +45,6 @@ export interface AllOrNothingWrites { disburseFees(options?: CallSignerOptions): Promise; /** Withdraws raised funds (campaign succeeded). */ withdraw(options?: CallSignerOptions): Promise; - /** Burns a pledge NFT. */ - burn(tokenId: bigint, options?: CallSignerOptions): Promise; - /** Approves an address to transfer a specific pledge NFT. */ - approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Sets or revokes operator approval for all tokens. */ - setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; - /** Safely transfers a pledge NFT. */ - safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Transfers a pledge NFT without ERC-721 receiver check. */ - transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; } /** Simulate counterparts for AllOrNothing write methods. */ @@ -95,16 +69,6 @@ export interface AllOrNothingSimulate { disburseFees(options?: CallSignerOptions): Promise; /** Simulates withdraw; returns a SimulationResult on success, throws a typed error on revert. */ withdraw(options?: CallSignerOptions): Promise; - /** Simulates burn; returns a SimulationResult on success, throws a typed error on revert. */ - burn(tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates approve; returns a SimulationResult on success, throws a typed error on revert. */ - approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates setApprovalForAll; returns a SimulationResult on success, throws a typed error on revert. */ - setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; - /** Simulates safeTransferFrom; returns a SimulationResult on success, throws a typed error on revert. */ - safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates transferFrom; returns a SimulationResult on success, throws a typed error on revert. */ - transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; } /** Event helpers for an AllOrNothing treasury contract instance. */ @@ -127,14 +91,8 @@ export interface AllOrNothingEvents { getUnpausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Cancelled event logs. */ getCancelledLogs(options?: EventFilterOptions): Promise; - /** Returns decoded Transfer event logs. */ - getTransferLogs(options?: EventFilterOptions): Promise; /** Returns decoded SuccessConditionNotFulfilled event logs. */ getSuccessConditionNotFulfilledLogs(options?: EventFilterOptions): Promise; - /** Returns decoded Approval event logs. */ - getApprovalLogs(options?: EventFilterOptions): Promise; - /** Returns decoded ApprovalForAll event logs. */ - getApprovalForAllLogs(options?: EventFilterOptions): Promise; /** Decodes a raw log entry against all known AllOrNothing events. */ decodeLog(log: RawLog): DecodedEventLog; /** Watches for Receipt events in real time. Returns an unwatch function. */ @@ -155,14 +113,8 @@ export interface AllOrNothingEvents { watchUnpaused(onLogs: EventWatchHandler): () => void; /** Watches for Cancelled events in real time. Returns an unwatch function. */ watchCancelled(onLogs: EventWatchHandler): () => void; - /** Watches for Transfer events in real time. Returns an unwatch function. */ - watchTransfer(onLogs: EventWatchHandler): () => void; /** Watches for SuccessConditionNotFulfilled events in real time. Returns an unwatch function. */ watchSuccessConditionNotFulfilled(onLogs: EventWatchHandler): () => void; - /** Watches for Approval events in real time. Returns an unwatch function. */ - watchApproval(onLogs: EventWatchHandler): () => void; - /** Watches for ApprovalForAll events in real time. Returns an unwatch function. */ - watchApprovalForAll(onLogs: EventWatchHandler): () => void; } /** Full AllOrNothing treasury entity combining reads, writes, simulate, and events. */ diff --git a/packages/contracts/src/contracts/all-or-nothing/writes.ts b/packages/contracts/src/contracts/all-or-nothing/writes.ts index e9467ed5..2f3612d9 100644 --- a/packages/contracts/src/contracts/all-or-nothing/writes.ts +++ b/packages/contracts/src/contracts/all-or-nothing/writes.ts @@ -60,25 +60,5 @@ export function createAllOrNothingWrites( const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); return signer.writeContract({ ...contract, chain, account, functionName: "withdraw", args: [] }); }, - async burn(tokenId: bigint, options?: CallSignerOptions): Promise { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ ...contract, chain, account, functionName: "burn", args: [tokenId] }); - }, - async approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ ...contract, chain, account, functionName: "approve", args: [to, tokenId] }); - }, - async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ ...contract, chain, account, functionName: "setApprovalForAll", args: [operator, approved] }); - }, - async safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ ...contract, chain, account, functionName: "safeTransferFrom", args: [from, to, tokenId] }); - }, - async transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ ...contract, chain, account, functionName: "transferFrom", args: [from, to, tokenId] }); - }, }; } diff --git a/packages/contracts/src/contracts/campaign-info/abi.ts b/packages/contracts/src/contracts/campaign-info/abi.ts index f8b48565..7f3287ab 100644 --- a/packages/contracts/src/contracts/campaign-info/abi.ts +++ b/packages/contracts/src/contracts/campaign-info/abi.ts @@ -239,6 +239,43 @@ export const CAMPAIGN_INFO_ABI = [ stateMutability: "view", type: "function", }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "getApproved", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "owner", type: "address" }, + { internalType: "address", name: "operator", type: "address" }, + ], + name: "isApprovedForAll", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "approve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "operator", type: "address" }, + { internalType: "bool", name: "approved", type: "bool" }, + ], + name: "setApprovalForAll", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, // The underscore-prefixed functions below are publicly callable on-chain despite // the naming convention. The Solidity contract inherits from PausableCancellable // which exposes them as external functions; the underscore distinguishes them from diff --git a/packages/contracts/src/contracts/campaign-info/reads.ts b/packages/contracts/src/contracts/campaign-info/reads.ts index 6fe3de22..f520e139 100644 --- a/packages/contracts/src/contracts/campaign-info/reads.ts +++ b/packages/contracts/src/contracts/campaign-info/reads.ts @@ -142,5 +142,11 @@ export function createCampaignInfoReads( async supportsInterface(interfaceId: Bytes4) { return publicClient.readContract({ ...contract, functionName: "supportsInterface", args: [interfaceId] }); }, + async getApproved(tokenId: bigint) { + return publicClient.readContract({ ...contract, functionName: "getApproved", args: [tokenId] }); + }, + async isApprovedForAll(owner: Address, operator: Address) { + return publicClient.readContract({ ...contract, functionName: "isApprovedForAll", args: [owner, operator] }); + }, }; } diff --git a/packages/contracts/src/contracts/campaign-info/simulate.ts b/packages/contracts/src/contracts/campaign-info/simulate.ts index 7eda92a4..c197d893 100644 --- a/packages/contracts/src/contracts/campaign-info/simulate.ts +++ b/packages/contracts/src/contracts/campaign-info/simulate.ts @@ -206,6 +206,32 @@ export function createCampaignInfoSimulate( ); return toSimulationResult(response); }, + async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { + const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); + const response = await simulateWithErrorDecode(() => + publicClient.simulateContract({ + ...contract, + chain, + account, + functionName: "approve", + args: [to, tokenId], + }), + ); + return toSimulationResult(response); + }, + async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions) { + const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); + const response = await simulateWithErrorDecode(() => + publicClient.simulateContract({ + ...contract, + chain, + account, + functionName: "setApprovalForAll", + args: [operator, approved], + }), + ); + return toSimulationResult(response); + }, }; } diff --git a/packages/contracts/src/contracts/campaign-info/types.ts b/packages/contracts/src/contracts/campaign-info/types.ts index 4f459974..c4240809 100644 --- a/packages/contracts/src/contracts/campaign-info/types.ts +++ b/packages/contracts/src/contracts/campaign-info/types.ts @@ -85,6 +85,10 @@ export interface CampaignInfoReads { balanceOf(owner: Address): Promise; /** Returns true if the contract supports the given ERC-165 interface ID. */ supportsInterface(interfaceId: Bytes4): Promise; + /** Returns the approved address for a given token ID, or zero if none. */ + getApproved(tokenId: bigint): Promise
; + /** Returns true if the operator is approved to manage all tokens of the given owner. */ + isApprovedForAll(owner: Address, operator: Address): Promise; } /** Write methods for a CampaignInfo contract instance. */ @@ -117,6 +121,10 @@ export interface CampaignInfoWrites { transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; /** Renounces contract ownership permanently. */ renounceOwnership(options?: CallSignerOptions): Promise; + /** Approves an address to transfer a specific pledge NFT. */ + approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + /** Grants or revokes operator approval for all tokens owned by the caller. */ + setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; } /** Simulate counterparts for CampaignInfo write methods. */ @@ -149,6 +157,10 @@ export interface CampaignInfoSimulate { transferOwnership(newOwner: Address, options?: CallSignerOptions): Promise; /** Simulates renounceOwnership; returns a SimulationResult on success, throws a typed error on revert. */ renounceOwnership(options?: CallSignerOptions): Promise; + /** Simulates approve; returns a SimulationResult on success, throws a typed error on revert. */ + approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; + /** Simulates setApprovalForAll; returns a SimulationResult on success, throws a typed error on revert. */ + setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; } /** Event helpers for a CampaignInfo contract instance. */ diff --git a/packages/contracts/src/contracts/campaign-info/writes.ts b/packages/contracts/src/contracts/campaign-info/writes.ts index d9161f73..5bffadb6 100644 --- a/packages/contracts/src/contracts/campaign-info/writes.ts +++ b/packages/contracts/src/contracts/campaign-info/writes.ts @@ -75,5 +75,13 @@ export function createCampaignInfoWrites( const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); return signer.writeContract({ ...contract, chain, account, functionName: "renounceOwnership", args: [] }); }, + async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { + const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); + return signer.writeContract({ ...contract, chain, account, functionName: "approve", args: [to, tokenId] }); + }, + async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions) { + const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); + return signer.writeContract({ ...contract, chain, account, functionName: "setApprovalForAll", args: [operator, approved] }); + }, }; } diff --git a/packages/contracts/src/contracts/keep-whats-raised/abi.ts b/packages/contracts/src/contracts/keep-whats-raised/abi.ts index 791a1449..994759ea 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/abi.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/abi.ts @@ -81,26 +81,6 @@ export const KEEP_WHATS_RAISED_ABI = [ { inputs: [], name: "TreasuryCampaignInfoIsPaused", type: "error" }, { inputs: [], name: "TreasuryFeeNotDisbursed", type: "error" }, { inputs: [], name: "TreasuryTransferFailed", type: "error" }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "address", name: "owner", type: "address" }, - { indexed: true, internalType: "address", name: "approved", type: "address" }, - { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "Approval", - type: "event", - }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "address", name: "owner", type: "address" }, - { indexed: true, internalType: "address", name: "operator", type: "address" }, - { indexed: false, internalType: "bool", name: "approved", type: "bool" }, - ], - name: "ApprovalForAll", - type: "event", - }, { anonymous: false, inputs: [ @@ -255,16 +235,6 @@ export const KEEP_WHATS_RAISED_ABI = [ name: "WithdrawalWithFeeSuccessful", type: "event", }, - { - anonymous: false, - inputs: [ - { indexed: true, internalType: "address", name: "from", type: "address" }, - { indexed: true, internalType: "address", name: "to", type: "address" }, - { indexed: true, internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "Transfer", - type: "event", - }, { anonymous: false, inputs: [ @@ -342,23 +312,6 @@ export const KEEP_WHATS_RAISED_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [ - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "approve", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [{ internalType: "address", name: "owner", type: "address" }], - name: "balanceOf", - outputs: [{ internalType: "uint256", name: "", type: "uint256" }], - stateMutability: "view", - type: "function", - }, { inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], name: "claimRefund", @@ -373,13 +326,6 @@ export const KEEP_WHATS_RAISED_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], - name: "getApproved", - outputs: [{ internalType: "address", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, { inputs: [], name: "getAvailableRaisedAmount", @@ -571,30 +517,6 @@ export const KEEP_WHATS_RAISED_ABI = [ stateMutability: "view", type: "function", }, - { - inputs: [ - { internalType: "address", name: "owner", type: "address" }, - { internalType: "address", name: "operator", type: "address" }, - ], - name: "isApprovedForAll", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [], - name: "name", - outputs: [{ internalType: "string", name: "", type: "string" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], - name: "ownerOf", - outputs: [{ internalType: "address", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, { inputs: [], name: "paused", @@ -635,71 +557,6 @@ export const KEEP_WHATS_RAISED_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [ - { internalType: "address", name: "from", type: "address" }, - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "safeTransferFrom", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "from", type: "address" }, - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - { internalType: "bytes", name: "data", type: "bytes" }, - ], - name: "safeTransferFrom", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "operator", type: "address" }, - { internalType: "bool", name: "approved", type: "bool" }, - ], - name: "setApprovalForAll", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, - { - inputs: [{ internalType: "bytes4", name: "interfaceId", type: "bytes4" }], - name: "supportsInterface", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [], - name: "symbol", - outputs: [{ internalType: "string", name: "", type: "string" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], - name: "tokenURI", - outputs: [{ internalType: "string", name: "", type: "string" }], - stateMutability: "view", - type: "function", - }, - { - inputs: [ - { internalType: "address", name: "from", type: "address" }, - { internalType: "address", name: "to", type: "address" }, - { internalType: "uint256", name: "tokenId", type: "uint256" }, - ], - name: "transferFrom", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [ { internalType: "address", name: "token", type: "address" }, diff --git a/packages/contracts/src/contracts/keep-whats-raised/events.ts b/packages/contracts/src/contracts/keep-whats-raised/events.ts index 936f78a1..cbdc3e58 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/events.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/events.ts @@ -97,15 +97,6 @@ export function createKeepWhatsRaisedEvents( async getCancelledLogs(options) { return fetchEventLogs(publicClient, address, "Cancelled", options); }, - async getTransferLogs(options) { - return fetchEventLogs(publicClient, address, "Transfer", options); - }, - async getApprovalLogs(options) { - return fetchEventLogs(publicClient, address, "Approval", options); - }, - async getApprovalForAllLogs(options) { - return fetchEventLogs(publicClient, address, "ApprovalForAll", options); - }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); }, @@ -157,14 +148,5 @@ export function createKeepWhatsRaisedEvents( watchCancelled(onLogs) { return createWatcher(publicClient, address, "Cancelled", onLogs); }, - watchTransfer(onLogs) { - return createWatcher(publicClient, address, "Transfer", onLogs); - }, - watchApproval(onLogs) { - return createWatcher(publicClient, address, "Approval", onLogs); - }, - watchApprovalForAll(onLogs) { - return createWatcher(publicClient, address, "ApprovalForAll", onLogs); - }, }; } diff --git a/packages/contracts/src/contracts/keep-whats-raised/reads.ts b/packages/contracts/src/contracts/keep-whats-raised/reads.ts index b04bef99..41666800 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/reads.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/reads.ts @@ -1,7 +1,7 @@ import type { Address, Hex, PublicClient } from "../../lib"; import { KEEP_WHATS_RAISED_ABI } from "./abi"; import type { KeepWhatsRaisedReads } from "./types"; -import type { Bytes4, TieredReward } from "../../types/structs"; +import type { TieredReward } from "../../types/structs"; /** * Builds read methods for a KeepWhatsRaised treasury contract instance. @@ -77,53 +77,5 @@ export function createKeepWhatsRaisedReads( async cancelled() { return publicClient.readContract({ ...contract, functionName: "cancelled" }); }, - async balanceOf(owner: Address) { - return publicClient.readContract({ - ...contract, - functionName: "balanceOf", - args: [owner], - }); - }, - async ownerOf(tokenId: bigint) { - return publicClient.readContract({ - ...contract, - functionName: "ownerOf", - args: [tokenId], - }); - }, - async tokenURI(tokenId: bigint) { - return publicClient.readContract({ - ...contract, - functionName: "tokenURI", - args: [tokenId], - }); - }, - async name() { - return publicClient.readContract({ ...contract, functionName: "name" }); - }, - async symbol() { - return publicClient.readContract({ ...contract, functionName: "symbol" }); - }, - async getApproved(tokenId: bigint) { - return publicClient.readContract({ - ...contract, - functionName: "getApproved", - args: [tokenId], - }); - }, - async isApprovedForAll(owner: Address, operator: Address) { - return publicClient.readContract({ - ...contract, - functionName: "isApprovedForAll", - args: [owner, operator], - }); - }, - async supportsInterface(interfaceId: Bytes4) { - return publicClient.readContract({ - ...contract, - functionName: "supportsInterface", - args: [interfaceId], - }); - }, }; } From 0296eaed0a6d6b29a7303b4d71839b5f4a592b4a Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 20:26:32 +0600 Subject: [PATCH 70/86] refactor: remove token management functions from KeepWhatsRaised contract - Eliminate unused functions related to token management (approve, setApprovalForAll, safeTransferFrom, transferFrom) from the KeepWhatsRaised contract to streamline the codebase. - Update the corresponding simulation and write interfaces to reflect these removals, enhancing clarity and maintainability of the contract's API. - Ensure that the remaining functionalities of the contract remain intact and operational. --- .../contracts/keep-whats-raised/simulate.ts | 52 ------------------- .../src/contracts/keep-whats-raised/types.ts | 46 +--------------- .../src/contracts/keep-whats-raised/writes.ts | 40 -------------- 3 files changed, 1 insertion(+), 137 deletions(-) diff --git a/packages/contracts/src/contracts/keep-whats-raised/simulate.ts b/packages/contracts/src/contracts/keep-whats-raised/simulate.ts index 1afd4e63..f79b7f6d 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/simulate.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/simulate.ts @@ -327,57 +327,5 @@ export function createKeepWhatsRaisedSimulate( ); return toSimulationResult(response); }, - async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "approve", - args: [to, tokenId], - }), - ); - return toSimulationResult(response); - }, - async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "setApprovalForAll", - args: [operator, approved], - }), - ); - return toSimulationResult(response); - }, - async safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "safeTransferFrom", - args: [from, to, tokenId], - }), - ); - return toSimulationResult(response); - }, - async transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - const response = await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...contract, - chain, - account, - functionName: "transferFrom", - args: [from, to, tokenId], - }), - ); - return toSimulationResult(response); - }, }; } diff --git a/packages/contracts/src/contracts/keep-whats-raised/types.ts b/packages/contracts/src/contracts/keep-whats-raised/types.ts index 8d215022..65bb5943 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/types.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/types.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from "../../lib"; -import type { Bytes4, TieredReward, CampaignData } from "../../types/structs"; +import type { TieredReward, CampaignData } from "../../types/structs"; import type { KeepWhatsRaisedConfig, KeepWhatsRaisedFeeKeys, KeepWhatsRaisedFeeValues } from "../../types/params"; import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; @@ -36,22 +36,6 @@ export interface KeepWhatsRaisedReads { paused(): Promise; /** Returns true if the treasury has been cancelled. */ cancelled(): Promise; - /** Returns the number of pledge NFT tokens held by the given owner. */ - balanceOf(owner: Address): Promise; - /** Returns the owner address of the pledge NFT with the given token ID. */ - ownerOf(tokenId: bigint): Promise
; - /** Returns the metadata URI for the pledge NFT with the given token ID. */ - tokenURI(tokenId: bigint): Promise; - /** Returns the ERC-721 collection name. */ - name(): Promise; - /** Returns the ERC-721 collection symbol. */ - symbol(): Promise; - /** Returns the address approved to transfer the given token ID. */ - getApproved(tokenId: bigint): Promise
; - /** Returns true if the operator is approved to manage all tokens of the given owner. */ - isApprovedForAll(owner: Address, operator: Address): Promise; - /** Returns true if the contract implements the given ERC-165 interface ID. */ - supportsInterface(interfaceId: Bytes4): Promise; } /** Write methods for KeepWhatsRaised treasury. */ @@ -122,14 +106,6 @@ export interface KeepWhatsRaisedWrites { updateDeadline(deadline: bigint, options?: CallSignerOptions): Promise; /** Updates the campaign funding goal amount. */ updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions): Promise; - /** Approves an address to transfer a specific pledge NFT token. */ - approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Grants or revokes operator approval for all tokens owned by the caller. */ - setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; - /** Safely transfers a pledge NFT, calling onERC721Received on the recipient. */ - safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Transfers a pledge NFT without the ERC-721 receiver check. */ - transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; } /** Simulate counterparts for KeepWhatsRaised write methods. */ @@ -200,14 +176,6 @@ export interface KeepWhatsRaisedSimulate { updateDeadline(deadline: bigint, options?: CallSignerOptions): Promise; /** Simulates updateGoalAmount; returns a SimulationResult on success, throws a typed error on revert. */ updateGoalAmount(goalAmount: bigint, options?: CallSignerOptions): Promise; - /** Simulates approve; returns a SimulationResult on success, throws a typed error on revert. */ - approve(to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates setApprovalForAll; returns a SimulationResult on success, throws a typed error on revert. */ - setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions): Promise; - /** Simulates safeTransferFrom; returns a SimulationResult on success, throws a typed error on revert. */ - safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; - /** Simulates transferFrom; returns a SimulationResult on success, throws a typed error on revert. */ - transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions): Promise; } /** Event helpers for KeepWhatsRaised. */ @@ -244,12 +212,6 @@ export interface KeepWhatsRaisedEvents { getUnpausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Cancelled event logs. */ getCancelledLogs(options?: EventFilterOptions): Promise; - /** Returns decoded Transfer event logs. */ - getTransferLogs(options?: EventFilterOptions): Promise; - /** Returns decoded Approval event logs. */ - getApprovalLogs(options?: EventFilterOptions): Promise; - /** Returns decoded ApprovalForAll event logs. */ - getApprovalForAllLogs(options?: EventFilterOptions): Promise; /** Decodes a raw log entry against all known KeepWhatsRaised events. */ decodeLog(log: RawLog): DecodedEventLog; /** Watches for Receipt events in real time. Returns an unwatch function. */ @@ -284,12 +246,6 @@ export interface KeepWhatsRaisedEvents { watchUnpaused(onLogs: EventWatchHandler): () => void; /** Watches for Cancelled events in real time. Returns an unwatch function. */ watchCancelled(onLogs: EventWatchHandler): () => void; - /** Watches for Transfer events in real time. Returns an unwatch function. */ - watchTransfer(onLogs: EventWatchHandler): () => void; - /** Watches for Approval events in real time. Returns an unwatch function. */ - watchApproval(onLogs: EventWatchHandler): () => void; - /** Watches for ApprovalForAll events in real time. Returns an unwatch function. */ - watchApprovalForAll(onLogs: EventWatchHandler): () => void; } /** Full KeepWhatsRaised treasury entity (reads, writes, simulate, events). */ diff --git a/packages/contracts/src/contracts/keep-whats-raised/writes.ts b/packages/contracts/src/contracts/keep-whats-raised/writes.ts index 93615153..5cd3ccef 100644 --- a/packages/contracts/src/contracts/keep-whats-raised/writes.ts +++ b/packages/contracts/src/contracts/keep-whats-raised/writes.ts @@ -269,45 +269,5 @@ export function createKeepWhatsRaisedWrites( args: [goalAmount], }); }, - async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ - ...contract, - chain, - account, - functionName: "approve", - args: [to, tokenId], - }); - }, - async setApprovalForAll(operator: Address, approved: boolean, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ - ...contract, - chain, - account, - functionName: "setApprovalForAll", - args: [operator, approved], - }); - }, - async safeTransferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ - ...contract, - chain, - account, - functionName: "safeTransferFrom", - args: [from, to, tokenId], - }); - }, - async transferFrom(from: Address, to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - return signer.writeContract({ - ...contract, - chain, - account, - functionName: "transferFrom", - args: [from, to, tokenId], - }); - }, }; } From 80c2d1a1cd1672a9396769f8094a729ff1458efb Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 20:26:51 +0600 Subject: [PATCH 71/86] refactor: streamline contract tests by removing unused token management functions - Remove tests for token management functions (balanceOf, ownerOf, tokenURI, approve, setApprovalForAll, safeTransferFrom, transferFrom) from AllOrNothing, CampaignInfo, and KeepWhatsRaised contracts to enhance test clarity and maintainability. - Update related test cases to ensure they accurately reflect the current contract functionalities, improving overall test coverage and reliability. --- .../integration/all-or-nothing.test.ts | 13 ----- .../integration/campaign-info.test.ts | 8 +++ .../integration/keep-whats-raised.test.ts | 12 ----- .../__tests__/unit/contract-entities.test.ts | 52 +++---------------- 4 files changed, 14 insertions(+), 71 deletions(-) diff --git a/packages/contracts/__tests__/integration/all-or-nothing.test.ts b/packages/contracts/__tests__/integration/all-or-nothing.test.ts index aacdcea1..a59989d9 100644 --- a/packages/contracts/__tests__/integration/all-or-nothing.test.ts +++ b/packages/contracts/__tests__/integration/all-or-nothing.test.ts @@ -15,14 +15,6 @@ describe("AllOrNothing — reads (may revert on uninitialized implementation)", it("getPlatformFeePercent", async () => { try { expect(typeof (await aon.getPlatformFeePercent())).toBe("bigint"); } catch { /* implementation revert */ } }); it("paused", async () => { try { expect(typeof (await aon.paused())).toBe("boolean"); } catch { /* implementation revert */ } }); it("cancelled", async () => { try { expect(typeof (await aon.cancelled())).toBe("boolean"); } catch { /* implementation revert */ } }); - it("balanceOf", async () => { try { expect(typeof (await aon.balanceOf(ZERO_ADDR))).toBe("bigint"); } catch { /* implementation revert */ } }); - it("ownerOf (may revert)", async () => { try { await aon.ownerOf(0n); } catch { /* expected */ } }); - it("tokenURI (may revert)", async () => { try { await aon.tokenURI(0n); } catch { /* expected */ } }); - it("name", async () => { try { expect(typeof (await aon.name())).toBe("string"); } catch { /* implementation revert */ } }); - it("symbol", async () => { try { expect(typeof (await aon.symbol())).toBe("string"); } catch { /* implementation revert */ } }); - it("getApproved (may revert)", async () => { try { await aon.getApproved(0n); } catch { /* expected */ } }); - it("isApprovedForAll", async () => { try { expect(typeof (await aon.isApprovedForAll(ZERO_ADDR, ZERO_ADDR))).toBe("boolean"); } catch { /* implementation revert */ } }); - it("supportsInterface", async () => { try { expect(typeof (await aon.supportsInterface("0x80ac58cd"))).toBe("boolean"); } catch { /* implementation revert */ } }); }); describe("AllOrNothing — writes (may revert)", () => { @@ -40,11 +32,6 @@ describe("AllOrNothing — writes (may revert)", () => { it("claimRefund", async () => { try { await aon.claimRefund(0n); } catch { /* expected */ } }); it("disburseFees", async () => { try { await aon.disburseFees(); } catch { /* expected */ } }); it("withdraw", async () => { try { await aon.withdraw(); } catch { /* expected */ } }); - it("burn", async () => { try { await aon.burn(0n); } catch { /* expected */ } }); - it("approve", async () => { try { await aon.approve(ZERO_ADDR, 0n); } catch { /* expected */ } }); - it("setApprovalForAll", async () => { try { await aon.setApprovalForAll(ZERO_ADDR, true); } catch { /* expected */ } }); - it("safeTransferFrom", async () => { try { await aon.safeTransferFrom(ZERO_ADDR, ZERO_ADDR, 0n); } catch { /* expected */ } }); - it("transferFrom", async () => { try { await aon.transferFrom(ZERO_ADDR, ZERO_ADDR, 0n); } catch { /* expected */ } }); }); describe("AllOrNothing — simulate (may throw)", () => { diff --git a/packages/contracts/__tests__/integration/campaign-info.test.ts b/packages/contracts/__tests__/integration/campaign-info.test.ts index f7bcc72b..2eb58a25 100644 --- a/packages/contracts/__tests__/integration/campaign-info.test.ts +++ b/packages/contracts/__tests__/integration/campaign-info.test.ts @@ -97,6 +97,10 @@ describe("CampaignInfo — reads (may revert on uninitialized implementation)", it("paused", async () => { try { expect(typeof (await ci.paused())).toBe("boolean"); } catch { /* implementation revert */ } }); + it("getApproved (may revert)", async () => { try { await ci.getApproved(0n); } catch { /* expected */ } }); + it("isApprovedForAll", async () => { + try { expect(typeof (await ci.isApprovedForAll(ZERO_ADDR, ZERO_ADDR))).toBe("boolean"); } catch { /* implementation revert */ } + }); }); describe("CampaignInfo — writes (may revert)", () => { @@ -112,6 +116,8 @@ describe("CampaignInfo — writes (may revert)", () => { it("unpauseCampaign", async () => { try { await ci.unpauseCampaign(BYTES32_ZERO); } catch { /* expected */ } }); it("cancelCampaign", async () => { try { await ci.cancelCampaign(BYTES32_ZERO); } catch { /* expected */ } }); it("setPlatformInfo", async () => { try { await ci.setPlatformInfo(BYTES32_ZERO, ZERO_ADDR); } catch { /* expected */ } }); + it("approve", async () => { try { await ci.approve(ZERO_ADDR, 0n); } catch { /* expected */ } }); + it("setApprovalForAll", async () => { try { await ci.setApprovalForAll(ZERO_ADDR, true); } catch { /* expected */ } }); it("transferOwnership", async () => { try { await ci.transferOwnership(ZERO_ADDR); } catch { /* expected */ } }); it("renounceOwnership", async () => { try { await ci.renounceOwnership(); } catch { /* expected */ } }); }); @@ -124,6 +130,8 @@ describe("CampaignInfo — simulate (may throw)", () => { it("simulate.mintNFTForPledge", async () => { try { await ci.simulate.mintNFTForPledge(ZERO_ADDR, BYTES32_ZERO, ZERO_ADDR, 100n, 0n, 0n); } catch { /* expected */ } }); it("simulate.pauseCampaign", async () => { try { await ci.simulate.pauseCampaign(BYTES32_ZERO); } catch { /* expected */ } }); it("simulate.cancelCampaign", async () => { try { await ci.simulate.cancelCampaign(BYTES32_ZERO); } catch { /* expected */ } }); + it("simulate.approve", async () => { try { await ci.simulate.approve(ZERO_ADDR, 0n); } catch { /* expected */ } }); + it("simulate.setApprovalForAll", async () => { try { await ci.simulate.setApprovalForAll(ZERO_ADDR, true); } catch { /* expected */ } }); }); describe("CampaignInfo — events", () => { diff --git a/packages/contracts/__tests__/integration/keep-whats-raised.test.ts b/packages/contracts/__tests__/integration/keep-whats-raised.test.ts index 7af452ae..c17529ba 100644 --- a/packages/contracts/__tests__/integration/keep-whats-raised.test.ts +++ b/packages/contracts/__tests__/integration/keep-whats-raised.test.ts @@ -22,14 +22,6 @@ describe("KeepWhatsRaised — reads (may revert on uninitialized implementation) it("getFeeValue", async () => { try { expect(typeof (await kwr.getFeeValue(BYTES32_ZERO))).toBe("bigint"); } catch { /* implementation revert */ } }); it("paused", async () => { try { expect(typeof (await kwr.paused())).toBe("boolean"); } catch { /* implementation revert */ } }); it("cancelled", async () => { try { expect(typeof (await kwr.cancelled())).toBe("boolean"); } catch { /* implementation revert */ } }); - it("balanceOf", async () => { try { expect(typeof (await kwr.balanceOf(ZERO_ADDR))).toBe("bigint"); } catch { /* implementation revert */ } }); - it("ownerOf (may revert)", async () => { try { await kwr.ownerOf(0n); } catch { /* expected */ } }); - it("tokenURI (may revert)", async () => { try { await kwr.tokenURI(0n); } catch { /* expected */ } }); - it("name", async () => { try { expect(typeof (await kwr.name())).toBe("string"); } catch { /* implementation revert */ } }); - it("symbol", async () => { try { expect(typeof (await kwr.symbol())).toBe("string"); } catch { /* implementation revert */ } }); - it("getApproved (may revert)", async () => { try { await kwr.getApproved(0n); } catch { /* expected */ } }); - it("isApprovedForAll", async () => { try { expect(typeof (await kwr.isApprovedForAll(ZERO_ADDR, ZERO_ADDR))).toBe("boolean"); } catch { /* implementation revert */ } }); - it("supportsInterface", async () => { try { expect(typeof (await kwr.supportsInterface("0x80ac58cd"))).toBe("boolean"); } catch { /* implementation revert */ } }); }); describe("KeepWhatsRaised — writes (may revert)", () => { @@ -62,10 +54,6 @@ describe("KeepWhatsRaised — writes (may revert)", () => { it("withdraw", async () => { try { await kwr.withdraw(ZERO_ADDR, 0n); } catch { /* expected */ } }); it("updateDeadline", async () => { try { await kwr.updateDeadline(9999999999n); } catch { /* expected */ } }); it("updateGoalAmount", async () => { try { await kwr.updateGoalAmount(1000n); } catch { /* expected */ } }); - it("approve", async () => { try { await kwr.approve(ZERO_ADDR, 0n); } catch { /* expected */ } }); - it("setApprovalForAll", async () => { try { await kwr.setApprovalForAll(ZERO_ADDR, true); } catch { /* expected */ } }); - it("safeTransferFrom", async () => { try { await kwr.safeTransferFrom(ZERO_ADDR, ZERO_ADDR, 0n); } catch { /* expected */ } }); - it("transferFrom", async () => { try { await kwr.transferFrom(ZERO_ADDR, ZERO_ADDR, 0n); } catch { /* expected */ } }); }); describe("KeepWhatsRaised — simulate (may throw)", () => { diff --git a/packages/contracts/__tests__/unit/contract-entities.test.ts b/packages/contracts/__tests__/unit/contract-entities.test.ts index e77180c7..c9136373 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -346,6 +346,8 @@ describe("CampaignInfo entity", () => { it("ownerOf", async () => { await entity.ownerOf(0n); }); it("balanceOf", async () => { await entity.balanceOf(ADDR); }); it("supportsInterface", async () => { await entity.supportsInterface(B32.slice(0, 10) as Bytes4); }); + it("getApproved", async () => { await entity.getApproved(0n); }); + it("isApprovedForAll", async () => { await entity.isApprovedForAll(ADDR, ADDR); }); }); describe("writes", () => { @@ -363,6 +365,8 @@ describe("CampaignInfo entity", () => { it("setPlatformInfo", async () => { await entity.setPlatformInfo(B32, ADDR); }); it("transferOwnership", async () => { await entity.transferOwnership(ADDR); }); it("renounceOwnership", async () => { await entity.renounceOwnership(); }); + it("approve", async () => { await entity.approve(ADDR, 0n); }); + it("setApprovalForAll", async () => { await entity.setApprovalForAll(ADDR, true); }); }); describe("simulate", () => { @@ -380,6 +384,8 @@ describe("CampaignInfo entity", () => { it("setPlatformInfo", async () => { await entity.simulate.setPlatformInfo(B32, ADDR); }); it("transferOwnership", async () => { await entity.simulate.transferOwnership(ADDR); }); it("renounceOwnership", async () => { await entity.simulate.renounceOwnership(); }); + it("approve", async () => { await entity.simulate.approve(ADDR, 0n); }); + it("setApprovalForAll", async () => { await entity.simulate.setApprovalForAll(ADDR, true); }); }); describe("events", () => { @@ -571,14 +577,6 @@ describe("AllOrNothing entity", () => { it("getPlatformFeePercent", async () => { await entity.getPlatformFeePercent(); }); it("paused", async () => { await entity.paused(); }); it("cancelled", async () => { await entity.cancelled(); }); - it("balanceOf", async () => { await entity.balanceOf(ADDR); }); - it("ownerOf", async () => { await entity.ownerOf(0n); }); - it("tokenURI", async () => { await entity.tokenURI(0n); }); - it("name", async () => { await entity.name(); }); - it("symbol", async () => { await entity.symbol(); }); - it("getApproved", async () => { await entity.getApproved(0n); }); - it("isApprovedForAll", async () => { await entity.isApprovedForAll(ADDR, ADDR); }); - it("supportsInterface", async () => { await entity.supportsInterface("0x80ac58cd" as Bytes4); }); }); describe("writes", () => { @@ -592,11 +590,6 @@ describe("AllOrNothing entity", () => { it("claimRefund", async () => { await entity.claimRefund(0n); }); it("disburseFees", async () => { await entity.disburseFees(); }); it("withdraw", async () => { await entity.withdraw(); }); - it("burn", async () => { await entity.burn(0n); }); - it("approve", async () => { await entity.approve(ADDR, 0n); }); - it("setApprovalForAll", async () => { await entity.setApprovalForAll(ADDR, true); }); - it("safeTransferFrom", async () => { await entity.safeTransferFrom(ADDR, ADDR, 0n); }); - it("transferFrom", async () => { await entity.transferFrom(ADDR, ADDR, 0n); }); }); describe("simulate", () => { @@ -610,11 +603,6 @@ describe("AllOrNothing entity", () => { it("claimRefund", async () => { await entity.simulate.claimRefund(0n); }); it("disburseFees", async () => { await entity.simulate.disburseFees(); }); it("withdraw", async () => { await entity.simulate.withdraw(); }); - it("burn", async () => { await entity.simulate.burn(0n); }); - it("approve", async () => { await entity.simulate.approve(ADDR, 0n); }); - it("setApprovalForAll", async () => { await entity.simulate.setApprovalForAll(ADDR, true); }); - it("safeTransferFrom", async () => { await entity.simulate.safeTransferFrom(ADDR, ADDR, 0n); }); - it("transferFrom", async () => { await entity.simulate.transferFrom(ADDR, ADDR, 0n); }); }); describe("events", () => { @@ -627,10 +615,7 @@ describe("AllOrNothing entity", () => { it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); - it("getTransferLogs", async () => { await entity.events.getTransferLogs(); }); it("getSuccessConditionNotFulfilledLogs", async () => { await entity.events.getSuccessConditionNotFulfilledLogs(); }); - it("getApprovalLogs", async () => { await entity.events.getApprovalLogs(); }); - it("getApprovalForAllLogs", async () => { await entity.events.getApprovalForAllLogs(); }); it("watchReceipt", () => { entity.events.watchReceipt(() => {}); expect(pub.watchContractEvent).toHaveBeenCalled(); }); it("watchRefundClaimed", () => { entity.events.watchRefundClaimed(() => {}); }); it("watchWithdrawalSuccessful", () => { entity.events.watchWithdrawalSuccessful(() => {}); }); @@ -640,10 +625,7 @@ describe("AllOrNothing entity", () => { it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); - it("watchTransfer", () => { entity.events.watchTransfer(() => {}); }); it("watchSuccessConditionNotFulfilled", () => { entity.events.watchSuccessConditionNotFulfilled(() => {}); }); - it("watchApproval", () => { entity.events.watchApproval(() => {}); }); - it("watchApprovalForAll", () => { entity.events.watchApprovalForAll(() => {}); }); it("decodeLog decodes a SuccessConditionNotFulfilled event", () => { const sig = keccak256(toHex("SuccessConditionNotFulfilled()")); const result = entity.events.decodeLog({ topics: [sig], data: "0x" as `0x${string}` }); @@ -698,14 +680,6 @@ describe("KeepWhatsRaised entity", () => { it("getFeeValue", async () => { await entity.getFeeValue(B32); }); it("paused", async () => { await entity.paused(); }); it("cancelled", async () => { await entity.cancelled(); }); - it("balanceOf", async () => { await entity.balanceOf(ADDR); }); - it("ownerOf", async () => { await entity.ownerOf(0n); }); - it("tokenURI", async () => { await entity.tokenURI(0n); }); - it("name", async () => { await entity.name(); }); - it("symbol", async () => { await entity.symbol(); }); - it("getApproved", async () => { await entity.getApproved(0n); }); - it("isApprovedForAll", async () => { await entity.isApprovedForAll(ADDR, ADDR); }); - it("supportsInterface", async () => { await entity.supportsInterface("0x80ac58cd" as Bytes4); }); }); describe("writes", () => { @@ -734,10 +708,6 @@ describe("KeepWhatsRaised entity", () => { it("withdraw", async () => { await entity.withdraw(ADDR, 0n); }); it("updateDeadline", async () => { await entity.updateDeadline(0n); }); it("updateGoalAmount", async () => { await entity.updateGoalAmount(0n); }); - it("approve", async () => { await entity.approve(ADDR, 0n); }); - it("setApprovalForAll", async () => { await entity.setApprovalForAll(ADDR, true); }); - it("safeTransferFrom", async () => { await entity.safeTransferFrom(ADDR, ADDR, 0n); }); - it("transferFrom", async () => { await entity.transferFrom(ADDR, ADDR, 0n); }); }); describe("simulate", () => { @@ -766,10 +736,6 @@ describe("KeepWhatsRaised entity", () => { it("withdraw", async () => { await entity.simulate.withdraw(ADDR, 0n); }); it("updateDeadline", async () => { await entity.simulate.updateDeadline(0n); }); it("updateGoalAmount", async () => { await entity.simulate.updateGoalAmount(0n); }); - it("approve", async () => { await entity.simulate.approve(ADDR, 0n); }); - it("setApprovalForAll", async () => { await entity.simulate.setApprovalForAll(ADDR, true); }); - it("safeTransferFrom", async () => { await entity.simulate.safeTransferFrom(ADDR, ADDR, 0n); }); - it("transferFrom", async () => { await entity.simulate.transferFrom(ADDR, ADDR, 0n); }); }); describe("events", () => { @@ -789,9 +755,6 @@ describe("KeepWhatsRaised entity", () => { it("getPausedLogs", async () => { await entity.events.getPausedLogs(); }); it("getUnpausedLogs", async () => { await entity.events.getUnpausedLogs(); }); it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); - it("getTransferLogs", async () => { await entity.events.getTransferLogs(); }); - it("getApprovalLogs", async () => { await entity.events.getApprovalLogs(); }); - it("getApprovalForAllLogs", async () => { await entity.events.getApprovalForAllLogs(); }); it("watchReceipt", () => { entity.events.watchReceipt(() => {}); expect(pub.watchContractEvent).toHaveBeenCalled(); }); it("watchRefundClaimed", () => { entity.events.watchRefundClaimed(() => {}); }); it("watchWithdrawalWithFeeSuccessful", () => { entity.events.watchWithdrawalWithFeeSuccessful(() => {}); }); @@ -808,9 +771,6 @@ describe("KeepWhatsRaised entity", () => { it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); - it("watchTransfer", () => { entity.events.watchTransfer(() => {}); }); - it("watchApproval", () => { entity.events.watchApproval(() => {}); }); - it("watchApprovalForAll", () => { entity.events.watchApprovalForAll(() => {}); }); it("decodeLog decodes a WithdrawalApproved event", () => { const sig = keccak256(toHex("WithdrawalApproved()")); const result = entity.events.decodeLog({ topics: [sig], data: "0x" as `0x${string}` }); From 769650b6948b178100672ea940ed3811484163d8 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 20:42:48 +0600 Subject: [PATCH 72/86] refactor: remove unused event names from AllOrNothing and KeepWhatsRaised contracts - Eliminate redundant event names (Approval, ApprovalForAll, Transfer) from the AllOrNothing and KeepWhatsRaised contracts to streamline the event definitions. - Update the event constants accordingly to enhance code clarity and maintainability while ensuring the remaining functionalities of the contracts remain intact. --- packages/contracts/src/constants/events.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/contracts/src/constants/events.ts b/packages/contracts/src/constants/events.ts index 47d37232..d1bd0fa3 100644 --- a/packages/contracts/src/constants/events.ts +++ b/packages/contracts/src/constants/events.ts @@ -66,8 +66,6 @@ export const TREASURY_FACTORY_EVENTS = { /** Event names emitted by the AllOrNothing treasury contract. */ export const ALL_OR_NOTHING_EVENTS = { - Approval: "Approval", - ApprovalForAll: "ApprovalForAll", Cancelled: "Cancelled", FeesDisbursed: "FeesDisbursed", Paused: "Paused", @@ -76,15 +74,12 @@ export const ALL_OR_NOTHING_EVENTS = { RewardsAdded: "RewardsAdded", RewardRemoved: "RewardRemoved", SuccessConditionNotFulfilled: "SuccessConditionNotFulfilled", - Transfer: "Transfer", Unpaused: "Unpaused", WithdrawalSuccessful: "WithdrawalSuccessful", } as const; /** Event names emitted by the KeepWhatsRaised treasury contract. */ export const KEEP_WHATS_RAISED_EVENTS = { - Approval: "Approval", - ApprovalForAll: "ApprovalForAll", Cancelled: "Cancelled", DeadlineUpdated: "KeepWhatsRaisedDeadlineUpdated", FeesDisbursed: "FeesDisbursed", @@ -97,7 +92,6 @@ export const KEEP_WHATS_RAISED_EVENTS = { RewardsAdded: "RewardsAdded", RewardRemoved: "RewardRemoved", TipClaimed: "TipClaimed", - Transfer: "Transfer", TreasuryConfigured: "TreasuryConfigured", Unpaused: "Unpaused", WithdrawalApproved: "WithdrawalApproved", From 5de9160dc103c490c8aac0832be470109eb99f3e Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 21:05:24 +0600 Subject: [PATCH 73/86] refactor: update NFT handling across campaign examples to use CampaignInfo contract - Modified the pledge and refund processes in the AllOrNothing, KeepWhatsRaised, and PaymentTreasury examples to reflect that pledge NFTs are managed through the CampaignInfo contract instead of the treasury contracts. - Updated relevant comments and documentation to clarify the approval process for NFTs, ensuring that users understand the need to approve the CampaignInfo contract before calling refund functions. - Enhanced overall clarity and maintainability of the code by consolidating NFT management logic. --- .../06-backer-pledge.ts | 9 +- .../09b-failure-refund.ts | 14 +-- .../11-claim-refund.ts | 14 +-- .../06-handle-refunds.ts | 91 +++++++++++++------ .../03-campaign-payment-treasury/README.md | 15 +-- 5 files changed, 93 insertions(+), 50 deletions(-) diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts index 7c36c9a2..c8b2bea3 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts @@ -35,7 +35,9 @@ const alexOak = createOakContractsClient({ }); const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; const alexTreasury = alexOak.allOrNothingTreasury(treasuryAddress); +const alexCampaign = alexOak.campaignInfo(campaignInfoAddress); const pledgeToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; const shippingFee = 5_000_000n; // $5 shipping @@ -51,7 +53,8 @@ const pledgeTxHash = await alexTreasury.pledgeForAReward( const pledgeReceipt = await alexOak.waitForReceipt(pledgeTxHash); console.log(`Alex pledged for "Signed Print" at block ${pledgeReceipt.blockNumber}`); -const alexBalance = await alexTreasury.balanceOf(process.env.ALEX_ADDRESS! as `0x${string}`); +// Pledge NFTs live on the CampaignInfo contract, not the treasury +const alexBalance = await alexCampaign.balanceOf(process.env.ALEX_ADDRESS! as `0x${string}`); console.log("Alex's NFT balance:", alexBalance); // 1n // --- Pledge WITHOUT a reward --- @@ -67,6 +70,7 @@ const samOak = createOakContractsClient({ }); const samTreasury = samOak.allOrNothingTreasury(treasuryAddress); +const samCampaign = samOak.campaignInfo(campaignInfoAddress); const samPledgeTxHash = await samTreasury.pledgeWithoutAReward( process.env.SAM_ADDRESS! as `0x${string}`, @@ -77,5 +81,6 @@ const samPledgeTxHash = await samTreasury.pledgeWithoutAReward( await samOak.waitForReceipt(samPledgeTxHash); console.log("Sam pledged $50 (no reward)"); -const samBalance = await samTreasury.balanceOf(process.env.SAM_ADDRESS! as `0x${string}`); +// Pledge NFTs live on the CampaignInfo contract, not the treasury +const samBalance = await samCampaign.balanceOf(process.env.SAM_ADDRESS! as `0x${string}`); console.log("Sam's NFT balance:", samBalance); // 1n diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts index 45ae086d..dd8d4230 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts @@ -12,10 +12,10 @@ * trigger refunds on behalf of all backers. * * Prerequisite: the backer must approve the treasury contract to - * manage their pledge NFT before calling `claimRefund`. The treasury - * is an ERC-721 itself, so `approve` is called directly on the - * treasury entity (not on a separate NFT contract). Use `approve` - * for a single token or `setApprovalForAll` for all tokens at once. + * manage their pledge NFT before calling `claimRefund`. Pledge NFTs + * live on the **CampaignInfo** contract, so `approve` is called on + * the CampaignInfo entity (not the treasury). Use `approve` for a + * single token or `setApprovalForAll` for all tokens at once. * * The contract does two things in a single transaction: * @@ -36,7 +36,9 @@ const alexOak = createOakContractsClient({ }); const treasuryAddress = process.env.ALL_OR_NOTHING_ADDRESS! as `0x${string}`; +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; const treasury = alexOak.allOrNothingTreasury(treasuryAddress); +const campaign = alexOak.campaignInfo(campaignInfoAddress); const pledgeTokenIdEnv = process.env.ALEX_PLEDGE_TOKEN_ID?.trim(); if (!pledgeTokenIdEnv) { @@ -45,8 +47,8 @@ if (!pledgeTokenIdEnv) { const tokenId = BigInt(pledgeTokenIdEnv); // Approve the treasury to burn this pledge NFT. -// The AllOrNothing treasury IS the ERC-721, so approve is called on the treasury itself. -const approveTxHash = await treasury.approve(treasuryAddress, tokenId); +// Pledge NFTs live on CampaignInfo, so approve is called on the CampaignInfo entity. +const approveTxHash = await campaign.approve(treasuryAddress, tokenId); await alexOak.waitForReceipt(approveTxHash); const refundTxHash = await treasury.claimRefund(tokenId); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts index 189956d5..a9e151c0 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts @@ -7,10 +7,10 @@ * to the NFT owner's wallet in a single transaction. * * Prerequisite: the backer must approve the treasury contract to - * manage their pledge NFT before calling `claimRefund`. The KWR - * treasury is an ERC-721 itself, so `approve` is called directly on - * the treasury entity (not on a separate NFT contract). Use `approve` - * for a single token or `setApprovalForAll` for all tokens at once. + * manage their pledge NFT before calling `claimRefund`. Pledge NFTs + * live on the **CampaignInfo** contract, so `approve` is called on + * the CampaignInfo entity (not the treasury). Use `approve` for a + * single token or `setApprovalForAll` for all tokens at once. * * Refund eligibility timing: * @@ -32,7 +32,9 @@ const backerOak = createOakContractsClient({ }); const treasuryAddress = process.env.KEEP_WHATS_RAISED_ADDRESS! as `0x${string}`; +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; const treasury = backerOak.keepWhatsRaisedTreasury(treasuryAddress); +const campaign = backerOak.campaignInfo(campaignInfoAddress); const pledgeTokenIdEnv = process.env.BACKER_PLEDGE_TOKEN_ID?.trim(); if (!pledgeTokenIdEnv) { @@ -41,8 +43,8 @@ if (!pledgeTokenIdEnv) { const tokenId = BigInt(pledgeTokenIdEnv); // Approve the treasury to burn this pledge NFT. -// The KWR treasury IS the ERC-721, so approve is called on the treasury itself. -const approveTxHash = await treasury.approve(treasuryAddress, tokenId); +// Pledge NFTs live on CampaignInfo, so approve is called on the CampaignInfo entity. +const approveTxHash = await campaign.approve(treasuryAddress, tokenId); await backerOak.waitForReceipt(approveTxHash); const refundTxHash = await treasury.claimRefund(tokenId); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts index cf7954a9..8cdd3211 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts @@ -2,26 +2,38 @@ * Step 6: Handle Refunds (Platform Admin / Buyer) * * Suppose the vase arrives damaged. Sam contacts CeloMarket's support - * team, and they decide to issue a refund. The mechanism depends on - * how the payment was originally made: + * team, and they decide to issue a refund. Three distinct paths exist: * - * **For off-chain payments (`createPayment`):** + * **A) Cancel an unconfirmed off-chain payment:** * - * 1. The platform admin cancels the payment (`cancelPayment`) - * 2. The platform admin directs the refund to a specific address - * using `claimRefund(paymentId, refundAddress)` — this is for - * non-NFT payments only (the contract verifies `tokenId == 0`) + * - `cancelPayment(paymentId)` — Platform Admin only. Works only on + * unconfirmed, non-expired, non-crypto payments. Deletes the on-chain + * record. No funds are returned because off-chain payments haven't + * transferred any tokens to the contract. * - * **For on-chain crypto payments (`processCryptoPayment`):** + * **B) Refund a confirmed off-chain payment (non-NFT):** * - * 1. The buyer (NFT owner) calls `claimRefundSelf(paymentId)` — the - * contract looks up the NFT minted at payment time, verifies the - * caller owns it, burns the NFT, and sends the refundable amount - * to the current NFT owner + * - `claimRefund(paymentId, refundAddress)` — Platform Admin only. + * Refunds a confirmed payment where no NFT was minted (tokenId == 0). + * Sends refundable line items to the specified address. * - * In both cases, only line items marked as `canRefund: true` at - * creation time are returned. Non-refundable line items (e.g., - * shipping) are not included in the refund amount. + * **C) Refund a crypto payment (NFT):** + * + * - `claimRefundSelf(paymentId)` — Any caller (NFT owner). Crypto + * payments are auto-confirmed on creation, so no prior + * `cancelPayment` is needed (and would revert if attempted). + * The contract looks up the NFT owner, burns the NFT, and sends + * the refundable amount to that owner. + * + * Prerequisite: the NFT owner must approve the treasury contract + * to manage the NFT beforehand — the treasury calls `INFO.burn()`, + * which requires approval. Since all pledge NFTs live on the + * CampaignInfo contract (not the treasury itself), approval is + * done via `campaignInfo.approve(treasuryAddress, tokenId)`. + * + * For B and C, only line items marked as `canRefund: true` at creation + * time are included. Non-refundable line items (e.g., shipping) are + * excluded from the refund. * * Note: `claimRefundSelf` burns the NFT automatically — there is * no need to call burn separately. @@ -34,9 +46,13 @@ import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwo // ============================================================ // // Steps 2–3 processed order-12345 as a crypto payment, which minted -// an NFT to Sam. To claim a refund, Sam (the NFT owner) calls -// `claimRefundSelf`. The contract verifies NFT ownership, burns the -// NFT, and sends the refundable amount back to Sam's wallet. +// an NFT to Sam on the CampaignInfo contract. Crypto payments are +// auto-confirmed on creation, so no `cancelPayment` is needed (and +// would revert if attempted). +// +// Before calling `claimRefundSelf`, Sam must approve the treasury +// contract to manage his NFT. All pledge NFTs live on the +// CampaignInfo contract, so approval uses the CampaignInfo SDK entity. const samOak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -44,24 +60,32 @@ const samOak = createOakContractsClient({ privateKey: process.env.SAM_PRIVATE_KEY! as `0x${string}`, }); -const samTreasury = samOak.paymentTreasury( - process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, +const treasuryAddress = process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`; +const samTreasury = samOak.paymentTreasury(treasuryAddress); +const samCampaign = samOak.campaignInfo( + process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`, ); const paymentId = keccak256(toHex("order-12345")); +const tokenId = /* NFT token ID from the PaymentCreated event */ 1n; + +// Approve the treasury to burn this pledge NFT via the CampaignInfo entity +const approveTxHash = await samCampaign.approve(treasuryAddress, tokenId); +await samOak.waitForReceipt(approveTxHash); const selfRefundTxHash = await samTreasury.claimRefundSelf(paymentId); await samOak.waitForReceipt(selfRefundTxHash); console.log("NFT burned + refund claimed by Sam"); // ============================================================ -// B. Off-chain payment refund (Platform Admin) — Alternative +// B. Cancel an unconfirmed off-chain payment (Platform Admin) // ============================================================ // -// For payments created via `createPayment` only (no NFT minted), -// the platform admin cancels the payment and directs the refund -// to a specific address. This path does NOT apply to crypto -// payments — use `claimRefundSelf` above instead. +// For unconfirmed payments created via `createPayment`, the platform +// admin can cancel the on-chain record. Since no real funds were +// transferred for off-chain payments, `cancelPayment` simply deletes +// the record. Any off-chain refund (credit card reversal, etc.) is +// handled by the platform outside the contract. // const oak = createOakContractsClient({ // chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -75,14 +99,23 @@ console.log("NFT burned + refund claimed by Sam"); // // const offchainPaymentId = keccak256(toHex("offchain-order-67890")); // -// // Step 1: Cancel the payment // const cancelTxHash = await paymentTreasury.cancelPayment(offchainPaymentId); // await oak.waitForReceipt(cancelTxHash); -// console.log("Payment cancelled"); +// console.log("Unconfirmed payment record deleted"); + +// ============================================================ +// C. Refund a confirmed off-chain payment (Platform Admin) +// ============================================================ +// +// For confirmed off-chain payments (no NFT minted, i.e. `confirmPayment` +// was called with `buyerAddress = address(0)`), the platform admin can +// refund on-chain funds to a specified address. This path verifies +// the payment is confirmed and has `tokenId == 0`. + +// const confirmedPaymentId = keccak256(toHex("confirmed-order-99999")); // -// // Step 2: Direct the refund to the buyer's address // const refundTxHash = await paymentTreasury.claimRefund( -// offchainPaymentId, +// confirmedPaymentId, // process.env.SAM_ADDRESS! as `0x${string}`, // ); // await oak.waitForReceipt(refundTxHash); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md index cf13d1f7..48d34d05 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md @@ -25,7 +25,7 @@ Every payment record includes **`paymentToken`**. The treasury only accepts toke 4. **CeloMarket** verifies the order (inventory check, fraud review) and confirms the payment. Batch confirmation is available for multiple payments. 5. **Anyone** can read payment data and treasury balances — buyer address, amount, confirmation status, expected pending amount, and line item breakdown -6. If something goes wrong (wrong item shipped, order cancelled), a refund is issued. For off-chain payments the **platform admin** cancels and directs the refund to an address (`claimRefund`). For on-chain crypto payments the **buyer (NFT owner)** calls `claimRefundSelf` — the contract verifies NFT ownership, burns the NFT, and sends refundable line items back. +6. If something goes wrong, three separate cancellation/refund paths exist: **a)** the **platform admin** cancels an unconfirmed off-chain payment via `cancelPayment` (deletes the record; no on-chain refund since no funds were transferred); **b)** the **platform admin** refunds a confirmed non-NFT payment via `claimRefund(paymentId, refundAddress)`; **c)** for crypto payments, anyone can call `claimRefundSelf` — the contract looks up the NFT owner, burns the NFT, and sends refundable line items to that owner (no `cancelPayment` needed — crypto payments are auto-confirmed and `cancelPayment` rejects them). 7. **Anyone** disburses accumulated protocol and platform fees 8. **CeloMarket or the Creator** withdraws confirmed funds to the campaign owner's wallet 9. For TimeConstrainedPaymentTreasury: the platform claims all remaining balances after the deadline + claim delay @@ -33,15 +33,16 @@ Every payment record includes **`paymentToken`**. The treasury only accepts toke 11. **CeloMarket** can pause and unpause the treasury during an investigation 12. **CeloMarket or the Creator** can permanently cancel the treasury in extreme cases -## NFT Handling in PaymentTreasury +## NFT Handling -Unlike the AllOrNothing and KeepWhatsRaised scenarios — where the treasury contract **is** an ERC-721 itself and exposes NFT functions directly (e.g., `treasury.ownerOf(...)`, `treasury.burn(...)`) — the **PaymentTreasury does not expose any NFT methods**. NFT minting for crypto payments is delegated to the **CampaignInfo** contract via `INFO.mintNFTForPledge(...)` internally. +All pledge/payment NFTs across **every treasury type** (AllOrNothing, KeepWhatsRaised, PaymentTreasury) live on the **CampaignInfo** contract. No treasury contract is an ERC-721 itself — they all delegate NFT operations (`mint`, `burn`, `ownerOf`) to CampaignInfo internally. This means: -- There is no `paymentTreasury.ownerOf(...)` or `paymentTreasury.approve(...)`. -- NFT reads/writes for PaymentTreasury NFTs go through the **CampaignInfo** entity instead. -- `claimRefundSelf(paymentId)` is the only PaymentTreasury function that interacts with NFTs — it verifies the caller is the current NFT owner, sends the refundable amount to them, and burns the NFT automatically. +- There is no `treasury.ownerOf(...)` or `treasury.approve(...)` on **any** treasury type. +- NFT reads (`ownerOf`, `balanceOf`, `tokenURI`, `getPledgeData`) and writes (`approve`, `setApprovalForAll`) go through the **CampaignInfo** entity: `oak.campaignInfo(address)`. +- **Before calling any refund function** that burns an NFT (`claimRefund` on AON/KWR, `claimRefundSelf` on PaymentTreasury), the NFT owner must approve the treasury contract to manage the NFT via `campaignInfo.approve(treasuryAddress, tokenId)`. See [Step 6](./06-handle-refunds.ts) for the full code. +- `claimRefundSelf(paymentId)` is the only PaymentTreasury function that interacts with NFTs — it looks up the current NFT owner, burns the NFT, and sends the refundable amount to that owner. Any caller can trigger it; the refund always goes to the NFT owner. - `claimRefund(paymentId, refundAddress)` is for **non-NFT payments** (off-chain `createPayment` where no NFT was minted) and can only be called by the platform admin. ## PaymentTreasury vs. TimeConstrainedPaymentTreasury @@ -82,7 +83,7 @@ Which variant your platform uses depends on the treasury implementation register | `processCryptoPayment` | Anyone (buyer) | (no role modifier) | | `confirmPayment` / `confirmPaymentBatch` | Platform Admin | `onlyPlatformAdmin` | | `cancelPayment` | Platform Admin | `onlyPlatformAdmin` | -| `claimRefundSelf(paymentId)` | NFT Owner (crypto payments only — verifies ownership, burns NFT) | (no role modifier) | +| `claimRefundSelf(paymentId)` | Anyone (crypto payments only — burns NFT, refund goes to NFT owner) | (no role modifier) | | `claimRefund(paymentId, refundAddress)` | Platform Admin (off-chain payments only — `tokenId == 0`) | `onlyPlatformAdmin` | | `disburseFees` | Anyone | (no role modifier) | | `withdraw` | Platform Admin or Creator | `onlyPlatformAdminOrCampaignOwner` | From e8848d7468424a46ad72604059d345bb321c4480 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 21:05:44 +0600 Subject: [PATCH 74/86] refactor: update refund processes to clarify NFT management through CampaignInfo contract - Revised documentation across crowdfunding, escrow, and marketplace use cases to reflect that pledge NFTs are managed via the CampaignInfo contract instead of the treasury contracts. - Enhanced comments to specify the approval process for NFTs, ensuring users understand the need to approve the CampaignInfo contract before invoking refund functions. - Improved clarity and consistency in refund flow descriptions, detailing the distinct paths for cancellation and refunds based on payment types. --- .../crowdfunding/creative-campaign.md | 11 +++++--- .../src/use-cases/escrow/healthcare-escrow.md | 28 +++++++++++++------ .../flexible-funding/community-project.md | 7 +++-- .../marketplace/ecommerce-marketplace.md | 28 +++++++++++++------ .../prepayment/automotive-prepayment.md | 15 ++++++---- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md index 02faa045..bcb12ea3 100644 --- a/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md +++ b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md @@ -318,11 +318,12 @@ await oak.waitForReceipt(txHash); If the deadline passes and the goal was not reached, each backer can claim a refund by providing their pledge NFT token ID. The NFT is burned during the refund. -Before calling `claimRefund`, the backer must approve the treasury to manage their pledge NFT. The AllOrNothing treasury **is** the ERC-721 contract itself, so `approve` is called directly on the treasury entity: +Before calling `claimRefund`, the backer must approve the treasury to manage their pledge NFT. Pledge NFTs live on the **CampaignInfo** contract, so `approve` is called on the CampaignInfo entity: ```typescript -// Approve the treasury to burn this pledge NFT -await aonTreasury.approve(AON_TREASURY_ADDRESS, tokenId); +// Approve the treasury to burn this pledge NFT (NFTs live on CampaignInfo) +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); +await campaign.approve(AON_TREASURY_ADDRESS, tokenId); // Claim the refund — burns the NFT and returns pledged tokens const txHash = await aonTreasury.claimRefund(tokenId); @@ -372,11 +373,13 @@ const isCancelled = await aonTreasury.cancelled(); > **Role: Any caller** — all read functions are public. -Pledge NFTs are standard ERC-721 tokens minted by the CampaignInfo contract. Backers can transfer or manage them using standard ERC-721 operations (`safeTransferFrom`, `approve`, `setApprovalForAll`). If a pledge NFT is transferred, the new owner becomes eligible to claim the refund (on failure) or holds the reward entitlement. +Pledge NFTs are standard ERC-721 tokens minted by the CampaignInfo contract — **not** by the treasury. All NFT operations (`ownerOf`, `approve`, `balanceOf`, `tokenURI`, etc.) go through the CampaignInfo entity. Backers can manage them using `campaignInfo.approve(...)` and `campaignInfo.setApprovalForAll(...)`. If a pledge NFT is transferred, the new owner becomes eligible to claim the refund (on failure) or holds the reward entitlement. Each pledge NFT stores on-chain metadata accessible through CampaignInfo: ```typescript +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); + const pledgeData = await campaign.getPledgeData(tokenId); // pledgeData.backer — backer wallet address // pledgeData.reward — selected reward (bytes32) diff --git a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md index b4282bf4..67a7f9db 100644 --- a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -191,26 +191,36 @@ const txHash = await treasury.withdraw(); await oak.waitForReceipt(txHash); ``` -### Alternative: Cancel and refund flow +### Alternative: Cancellation and refund flows -> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, refundAddress)`. **Buyer (NFT owner)** for `claimRefundSelf(paymentId)` (crypto / NFT payments — refund to current NFT owner). +> Three distinct paths exist depending on payment state and type: -If Sarah needs to cancel the appointment before the doctor confirms delivery, MedConnect cancels the payment and initiates a refund. +**A) Cancel an unconfirmed off-chain payment (before `confirmPayment`):** -**For off-chain payments (no NFT minted):** +> **Role: Platform Admin** — `cancelPayment` works only on unconfirmed, non-expired, non-crypto payments. No on-chain funds were transferred for off-chain payments, so the on-chain record is simply deleted. Any off-chain refund is handled by MedConnect outside the contract. ```typescript -// Platform cancels the unconfirmed payment await treasury.cancelPayment(paymentId); +``` + +**B) Refund a confirmed off-chain payment (non-NFT):** -// Platform initiates refund to Sarah's address +> **Role: Platform Admin** — `claimRefund(paymentId, refundAddress)` refunds a confirmed payment where no NFT was minted. The contract verifies the payment is confirmed and has `tokenId == 0`. + +```typescript await treasury.claimRefund(paymentId, SARAH_WALLET_ADDRESS); ``` -**For crypto payments (NFT was minted via `processCryptoPayment`):** +**C) Refund a crypto payment (NFT was minted via `processCryptoPayment`):** + +> **Role: Any caller (NFT owner)** — `claimRefundSelf(paymentId)` is for crypto payments (auto-confirmed on creation). The contract looks up the NFT owner, burns the NFT, and sends the refundable amount to that owner. No prior `cancelPayment` is needed — crypto payments cannot be cancelled via `cancelPayment`. + +Before calling `claimRefundSelf`, the NFT owner must approve the treasury to manage the NFT. All pledge NFTs live on the **CampaignInfo** contract (not the treasury itself), so approval uses the CampaignInfo SDK entity: ```typescript -// Anyone can trigger the refund — funds go to the current NFT owner, and the NFT is burned +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); +await campaign.approve(TREASURY_ADDRESS, tokenId); + await treasury.claimRefundSelf(paymentId); ``` @@ -367,7 +377,7 @@ Patient (Sarah) MedConnect (Platform Admin) PaymentTreasur - **Multi-token** — `paymentToken` must be on the campaign’s accepted list; balances and refunds are tracked per ERC-20 (each token’s decimals) - **Funds are never at risk** — they stay locked in the smart contract until service is confirmed - **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner -- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefundSelf(paymentId)` for NFT payments (signer must be NFT owner; burns pledge NFT) +- **Three cancellation/refund paths** — `cancelPayment` deletes unconfirmed off-chain records (no on-chain refund); `claimRefund(paymentId, address)` refunds confirmed non-NFT payments (platform admin); `claimRefundSelf(paymentId)` refunds crypto/NFT payments directly (NFT owner, no prior cancel needed; burns pledge NFT — requires prior ERC-721 approval on the CampaignInfo contract) - **Line items** allow granular tracking (consultation vs. lab work) with configurable goal-counting, fees, and refund rules - **Non-goal line items** (e.g., platform commission) can be claimed separately via `claimNonGoalLineItems` - **Batch operations** — `createPaymentBatch` and `confirmPaymentBatch` for high-volume platforms diff --git a/packages/contracts/src/use-cases/flexible-funding/community-project.md b/packages/contracts/src/use-cases/flexible-funding/community-project.md index d06e062b..85ec1579 100644 --- a/packages/contracts/src/use-cases/flexible-funding/community-project.md +++ b/packages/contracts/src/use-cases/flexible-funding/community-project.md @@ -332,13 +332,14 @@ await oak.waitForReceipt(txHash); If a backer wants a refund, they can claim one — but only after the deadline + the configured refund delay (14 days in this example). -Before calling `claimRefund`, the backer must approve the treasury to manage their pledge NFT. The KWR treasury **is** the ERC-721 contract itself, so `approve` is called directly on the treasury entity: +Before calling `claimRefund`, the backer must approve the treasury to manage their pledge NFT. Pledge NFTs live on the **CampaignInfo** contract, so `approve` is called on the CampaignInfo entity: ```typescript // After deadline + 14-day refund delay -// Approve the treasury to burn this pledge NFT -await kwrTreasury.approve(KWR_TREASURY_ADDRESS, backerTokenId); +// Approve the treasury to burn this pledge NFT (NFTs live on CampaignInfo) +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); +await campaign.approve(KWR_TREASURY_ADDRESS, backerTokenId); // Claim the refund — burns the NFT and returns pledged tokens const txHash = await kwrTreasury.claimRefund(backerTokenId); diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md index 663ee38a..6171a9f0 100644 --- a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -192,26 +192,36 @@ const txHash = await treasury.withdraw(); await oak.waitForReceipt(txHash); ``` -### Alternative: Buyer refund before shipment +### Alternative: Cancellation and refund flows -> **Role: Platform Admin** for `cancelPayment` and `claimRefund(paymentId, refundAddress)`. **Buyer (NFT owner)** for `claimRefundSelf(paymentId)` (crypto / NFT payments). +> Three distinct paths exist depending on payment state and type: -If the buyer cancels before the seller ships, CeloMarket cancels the payment and the buyer gets a refund. +**A) Cancel an unconfirmed off-chain payment (before `confirmPayment`):** -**For off-chain payments (no NFT minted):** +> **Role: Platform Admin** — `cancelPayment` works only on unconfirmed, non-expired, non-crypto payments. No on-chain funds were transferred for off-chain payments, so the on-chain record is simply deleted. Any off-chain refund (credit card reversal, etc.) is handled by the platform outside the contract. ```typescript -// Platform cancels the unconfirmed payment await treasury.cancelPayment(orderId); +``` + +**B) Refund a confirmed off-chain payment (non-NFT):** -// Platform initiates refund to the buyer's address +> **Role: Platform Admin** — `claimRefund(paymentId, refundAddress)` refunds a confirmed payment where no NFT was minted (`confirmPayment` was called without a `buyerAddress`, or `buyerAddress` was `address(0)`). The contract verifies the payment is confirmed and has `tokenId == 0`. + +```typescript await treasury.claimRefund(orderId, BUYER_ADDRESS); ``` -**For crypto payments (NFT was minted):** +**C) Refund a crypto payment (NFT was minted):** + +> **Role: Any caller (NFT owner)** — `claimRefundSelf(paymentId)` is for crypto payments (auto-confirmed on creation). The contract looks up the NFT owner, burns the NFT, and sends the refundable amount to that owner. No prior `cancelPayment` is needed — crypto payments cannot be cancelled via `cancelPayment`. + +Before calling `claimRefundSelf`, the NFT owner must approve the treasury to manage the NFT. All pledge NFTs live on the **CampaignInfo** contract (not the treasury itself), so approval uses the CampaignInfo SDK entity: ```typescript -// Anyone can trigger the refund — funds go to the current NFT owner, and the NFT is burned +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); +await campaign.approve(TREASURY_ADDRESS, tokenId); + await treasury.claimRefundSelf(orderId); ``` @@ -322,7 +332,7 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain - **ERC-20 approval is required** — the buyer must `approve` the treasury contract before `processCryptoPayment` can transfer tokens - **Multi-token** — orders can settle in any **accepted** `paymentToken`; treasury accounting is per token address - **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner -- **Two refund models** — `claimRefund(paymentId, address)` for non-NFT payments (platform admin only) and `claimRefundSelf(paymentId)` for NFT payments (signer must be NFT owner) +- **Three cancellation/refund paths** — `cancelPayment` deletes unconfirmed off-chain records (no on-chain refund); `claimRefund(paymentId, address)` refunds confirmed non-NFT payments (platform admin); `claimRefundSelf(paymentId)` refunds crypto/NFT payments directly (NFT owner, no prior cancel needed; requires prior ERC-721 approval on CampaignInfo) - **Line items** separate product cost, shipping, and commission with configurable goal-counting, fees, and refund rules - **Non-goal line items** (e.g., platform commission) can be claimed separately via `claimNonGoalLineItems` - **Batch operations** (`createPaymentBatch`, `confirmPaymentBatch`) enable efficient end-of-day settlement diff --git a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md index 0a8a9806..faf68195 100644 --- a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md +++ b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md @@ -201,19 +201,24 @@ This is the core value of the **TimeConstrainedPaymentTreasury** — the **claim await treasury.cancelPayment(orderId); ``` -**B) Refund after confirmation — non-NFT payment (no pledge NFT minted):** +**B) Refund a confirmed off-chain payment (non-NFT):** -> **Role: Platform Admin** for `claimRefund(paymentId, refundAddress)` (after `launchTime`). +> **Role: Platform Admin** for `claimRefund(paymentId, refundAddress)` (after `launchTime`). This refunds a confirmed payment where no NFT was minted. The contract verifies the payment is confirmed and has `tokenId == 0`. ```typescript await treasury.claimRefund(orderId, JAMES_WALLET_ADDRESS); ``` -**C) Refund after confirmation — NFT-backed crypto payment:** +**C) Refund — NFT-backed crypto payment:** -> **Role: Buyer (NFT owner)** — `claimRefundSelf(paymentId)` burns the NFT and sends the refund to the **current NFT owner** (after `launchTime`). +> **Role: Any caller (NFT owner)** — `claimRefundSelf(paymentId)` looks up the current NFT owner, burns the NFT, and sends the refundable amount to that owner (after `launchTime`). No prior `cancelPayment` is needed — crypto payments cannot be cancelled via `cancelPayment` (they are auto-confirmed on creation). + +Before calling `claimRefundSelf`, the NFT owner must approve the treasury to manage the NFT. All pledge NFTs live on the **CampaignInfo** contract (not the treasury itself), so approval uses the CampaignInfo SDK entity: ```typescript +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); +await campaign.approve(TIME_CONSTRAINED_TREASURY_ADDRESS, tokenId); + await treasury.claimRefundSelf(orderId); ``` @@ -330,7 +335,7 @@ Customer (James) Karma (Platform Admin) TimeConstrainedTreasu - **Same SDK interface** as PaymentTreasury — `oak.paymentTreasury()` works for both; behavior differs in the deployed contract bytecode - **`claimExpiredFunds()`** is platform-admin-only and only after `deadline + platformClaimDelay`; on-chain recipients are the platform and protocol admins — align customer refunds with your product policy - **Role-based access** — matches PaymentTreasury for admin-only writes; `withdraw` is platform admin or campaign owner; `disburseFees` is permissionless -- **Two refund models** — `claimRefund(paymentId, address)` (platform admin, non-NFT) vs `claimRefundSelf(paymentId)` (signer must be NFT owner) +- **Three cancellation/refund paths** — `cancelPayment` deletes unconfirmed off-chain records (no on-chain refund); `claimRefund(paymentId, address)` refunds confirmed non-NFT payments (platform admin); `claimRefundSelf(paymentId)` refunds crypto/NFT payments directly (NFT owner, no prior cancel needed; requires prior ERC-721 approval on CampaignInfo) - **High-value transactions** benefit from deterministic rules instead of informal wire holds - **Line items** provide a clear audit trail (base price vs. options vs. delivery) - **Batch, pause, cancel, and `claimNonGoalLineItems`** behave like PaymentTreasury but inherit the same time checks from `TimeConstrainedPaymentTreasury` From c7678b7fa15fb15edeeed1fef2ba3c1ba21e6eb7 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 21:41:32 +0600 Subject: [PATCH 75/86] refactor: restructure payment treasury examples for clarity and functionality - Updated the README and code examples for the Payment Treasury to enhance clarity on the payment processes, including both off-chain and on-chain flows. - Introduced new files for creating campaigns, deploying treasuries, processing payments, and handling refunds, ensuring a comprehensive guide for users. - Clarified the roles of platform admins and creators in managing campaigns and treasuries, and detailed the steps for fee disbursement and fund withdrawal. - Improved documentation to reflect the distinct paths for payment processing and refund handling, emphasizing the NFT management through the CampaignInfo contract. --- .../01-create-campaign.ts | 39 ++++++++- .../01-create-campaign.ts | 46 ++++++++-- .../01-create-campaign.ts | 85 +++++++++++++++++++ .../01-setup-treasury.ts | 25 ------ .../02-deploy-treasury.ts | 60 +++++++++++++ ...create-payment.ts => 03-create-payment.ts} | 4 +- ...ayment.ts => 04-process-crypto-payment.ts} | 4 +- ...nfirm-payment.ts => 05-confirm-payment.ts} | 2 +- ...ayment-data.ts => 06-read-payment-data.ts} | 2 +- ...handle-refunds.ts => 07-handle-refunds.ts} | 2 +- ...7-disburse-fees.ts => 08-disburse-fees.ts} | 2 +- ...withdraw-funds.ts => 09-withdraw-funds.ts} | 4 +- ...red-funds.ts => 10-claim-expired-funds.ts} | 2 +- ...ems.ts => 11-claim-non-goal-line-items.ts} | 2 +- ...easury.ts => 12-pause-unpause-treasury.ts} | 2 +- ...ncel-treasury.ts => 13-cancel-treasury.ts} | 2 +- .../03-campaign-payment-treasury/README.md | 52 ++++++------ packages/contracts/src/examples/README.md | 25 +++--- 18 files changed, 276 insertions(+), 84 deletions(-) create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/01-create-campaign.ts delete mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts create mode 100644 packages/contracts/src/examples/03-campaign-payment-treasury/02-deploy-treasury.ts rename packages/contracts/src/examples/03-campaign-payment-treasury/{02-create-payment.ts => 03-create-payment.ts} (96%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{03-process-crypto-payment.ts => 04-process-crypto-payment.ts} (94%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{04-confirm-payment.ts => 05-confirm-payment.ts} (97%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{05-read-payment-data.ts => 06-read-payment-data.ts} (98%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{06-handle-refunds.ts => 07-handle-refunds.ts} (99%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{07-disburse-fees.ts => 08-disburse-fees.ts} (95%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{08-withdraw-funds.ts => 09-withdraw-funds.ts} (92%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{09-claim-expired-funds.ts => 10-claim-expired-funds.ts} (96%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{10-claim-non-goal-line-items.ts => 11-claim-non-goal-line-items.ts} (95%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{11-pause-unpause-treasury.ts => 12-pause-unpause-treasury.ts} (95%) rename packages/contracts/src/examples/03-campaign-payment-treasury/{12-cancel-treasury.ts => 13-cancel-treasury.ts} (95%) diff --git a/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts b/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts index 1119a1fb..fc89fadc 100644 --- a/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts @@ -14,8 +14,14 @@ * ERC-20 addresses on-chain; later pledges must use `pledgeToken` in that * whitelist (`CampaignInfo.isTokenAccepted`). This example uses one token. * - * The factory assigns a unique contract address to the campaign, which - * Maya will look up in the next step. + * After creation the factory emits a CampaignCreated event that contains + * the deployed CampaignInfo address. We show two ways to discover it: + * + * 1. **Receipt-based (recommended)** — decode the event from the + * transaction receipt. This is deterministic and works immediately. + * 2. **Lookup-based (convenience)** — call `identifierToCampaignInfo` + * on the factory. Note: on some RPC providers the state may not be + * indexed instantly, so this can briefly return a zero address. */ import { @@ -25,6 +31,7 @@ import { getCurrentTimestamp, addDays, CHAIN_IDS, + CAMPAIGN_INFO_FACTORY_EVENTS, } from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ @@ -60,3 +67,31 @@ const txHash = await factory.createCampaign({ const receipt = await oak.waitForReceipt(txHash); console.log(`Campaign created at block ${receipt.blockNumber}`); + +// ── Approach 1: Decode CampaignCreated from the receipt (recommended) ── +let campaignInfoAddress: `0x${string}` | undefined; + +for (const log of receipt.logs) { + try { + const decoded = factory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + + if (decoded.eventName === CAMPAIGN_INFO_FACTORY_EVENTS.CampaignCreated) { + campaignInfoAddress = decoded.args?.campaignInfoAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} + +console.log("CampaignInfo (from receipt):", campaignInfoAddress); + +// ── Approach 2: Lookup via identifierToCampaignInfo (convenience) ── +// Handy when you only have the identifier and did not keep the receipt. +// On some RPC providers this may briefly return the zero address right +// after the transaction — prefer Approach 1 when the receipt is available. +const lookedUp = await factory.identifierToCampaignInfo(identifierHash); +console.log("CampaignInfo (from lookup):", lookedUp); diff --git a/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts index f795db92..5ce0b068 100644 --- a/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts @@ -5,9 +5,16 @@ * open-source code review tool. They create the campaign through * the CampaignInfoFactory on the ArtFund platform. * - * After creation, they immediately look up the deployed CampaignInfo - * contract address using the identifier hash — this address is needed - * for all subsequent steps (deploying the treasury, adding rewards, etc.). + * After creation we discover the deployed CampaignInfo address — this + * address is needed for all subsequent steps (deploying the treasury, + * adding rewards, etc.). Two approaches are shown: + * + * 1. **Receipt-based (recommended)** — decode the CampaignCreated + * event from the transaction receipt. Deterministic, works + * immediately regardless of RPC indexing lag. + * 2. **Lookup-based (convenience)** — call `identifierToCampaignInfo`. + * Note: on some RPC providers the state may not be indexed + * instantly after the transaction, briefly returning a zero address. * * Multi-token: the campaign `currency` resolves to accepted ERC-20 * addresses; pledges and `withdraw(token, amount)` use tokens from that @@ -21,6 +28,7 @@ import { getCurrentTimestamp, addDays, CHAIN_IDS, + CAMPAIGN_INFO_FACTORY_EVENTS, } from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ @@ -54,7 +62,33 @@ const createTxHash = await factory.createCampaign({ contractURI: "ipfs://QmAbc.../metadata.json", }); -await oak.waitForReceipt(createTxHash); +const createReceipt = await oak.waitForReceipt(createTxHash); +console.log(`Campaign created at block ${createReceipt.blockNumber}`); + +// ── Approach 1: Decode CampaignCreated from the receipt (recommended) ── +let campaignInfoAddress: `0x${string}` | undefined; + +for (const log of createReceipt.logs) { + try { + const decoded = factory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + + if (decoded.eventName === CAMPAIGN_INFO_FACTORY_EVENTS.CampaignCreated) { + campaignInfoAddress = decoded.args?.campaignInfoAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} + +console.log("CampaignInfo (from receipt):", campaignInfoAddress); -const campaignInfoAddress = await factory.identifierToCampaignInfo(identifierHash); -console.log("Campaign at:", campaignInfoAddress); +// ── Approach 2: Lookup via identifierToCampaignInfo (convenience) ── +// Handy when you only have the identifier and did not keep the receipt. +// On some RPC providers this may briefly return the zero address right +// after the transaction — prefer Approach 1 when the receipt is available. +const lookedUp = await factory.identifierToCampaignInfo(identifierHash); +console.log("CampaignInfo (from lookup):", lookedUp); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/01-create-campaign.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/01-create-campaign.ts new file mode 100644 index 00000000..8bcd3e84 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/01-create-campaign.ts @@ -0,0 +1,85 @@ +/** + * Step 1: Create a Campaign (Platform Admin / Creator) + * + * Before deploying a PaymentTreasury, CeloMarket needs a CampaignInfo + * contract. The CampaignInfoFactory deploys one and links it to the + * platform — it will later hold NFT receipts for crypto payments. + * + * The factory emits a CampaignCreated event containing the deployed + * CampaignInfo address. Two ways to discover it: + * + * 1. **Receipt-based (recommended)** — decode the event from the + * transaction receipt. Deterministic, works immediately. + * 2. **Lookup-based (convenience)** — call `identifierToCampaignInfo`. + * On some RPC providers the state may not be indexed instantly, + * so this can briefly return a zero address. + */ + +import { + createOakContractsClient, + keccak256, + toHex, + getCurrentTimestamp, + addDays, + CHAIN_IDS, + CAMPAIGN_INFO_FACTORY_EVENTS, +} from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.CELOMARKET_ADMIN_PRIVATE_KEY! as `0x${string}`, +}); + +const factory = oak.campaignInfoFactory( + process.env.CAMPAIGN_INFO_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("celomarket")); +const identifierHash = keccak256(toHex("celomarket-storefront-2026")); +const currency = toHex("USD", { size: 32 }); +const now = getCurrentTimestamp(); + +const txHash = await factory.createCampaign({ + creator: process.env.CELOMARKET_ADMIN_ADDRESS! as `0x${string}`, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now, + deadline: addDays(now, 365), // storefront open for 1 year + goalAmount: 0n, // no funding goal for e-commerce + currency, + }, + nftName: "CeloMarket Receipts", + nftSymbol: "CMR", + nftImageURI: "ipfs://QmXyz.../celomarket-receipt.png", + contractURI: "ipfs://QmXyz.../metadata.json", +}); + +const receipt = await oak.waitForReceipt(txHash); +console.log(`Campaign created at block ${receipt.blockNumber}`); + +// ── Approach 1: Decode CampaignCreated from the receipt (recommended) ── +let campaignInfoAddress: `0x${string}` | undefined; + +for (const log of receipt.logs) { + try { + const decoded = factory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + + if (decoded.eventName === CAMPAIGN_INFO_FACTORY_EVENTS.CampaignCreated) { + campaignInfoAddress = decoded.args?.campaignInfoAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} + +console.log("CampaignInfo (from receipt):", campaignInfoAddress); + +// ── Approach 2: Lookup via identifierToCampaignInfo (convenience) ── +const lookedUp = await factory.identifierToCampaignInfo(identifierHash); +console.log("CampaignInfo (from lookup):", lookedUp); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts deleted file mode 100644 index cd006b13..00000000 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/01-setup-treasury.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Step 1: Connect to the Payment Treasury (Anyone) - * - * CeloMarket's backend connects to its deployed PaymentTreasury contract - * and reads back the platform configuration — the platform hash it belongs - * to and the fee percent. This is typically done once at application startup - * to verify the treasury is correctly linked to the platform. - */ - -import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; - -const oak = createOakContractsClient({ - chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, - rpcUrl: process.env.RPC_URL!, -}); - -const paymentTreasury = oak.paymentTreasury( - process.env.PAYMENT_TREASURY_ADDRESS! as `0x${string}`, -); - -const platformHash = await paymentTreasury.getPlatformHash(); -console.log("Treasury's platform:", platformHash); - -const feePercent = await paymentTreasury.getPlatformFeePercent(); -console.log("Platform fee:", Number(feePercent), "bps"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/02-deploy-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/02-deploy-treasury.ts new file mode 100644 index 00000000..beac4a37 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/02-deploy-treasury.ts @@ -0,0 +1,60 @@ +/** + * Step 2: Deploy a Payment Treasury (Platform Admin / Creator) + * + * CeloMarket deploys a PaymentTreasury through the TreasuryFactory, + * linking it to the CampaignInfo contract created in Step 1. The + * factory creates a new treasury clone and emits a TreasuryDeployed + * event containing the treasury address. + * + * The `implementationId` determines the treasury variant: + * - PaymentTreasury: standard, no time restrictions + * - TimeConstrainedPaymentTreasury: enforces launch time + deadline + * + * The implementation must have been registered and approved during + * platform onboarding (see Scenario 0, Steps 3–4). + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS, TREASURY_FACTORY_EVENTS } from "@oaknetwork/contracts-sdk"; + +const oak = createOakContractsClient({ + chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.CELOMARKET_ADMIN_PRIVATE_KEY! as `0x${string}`, +}); + +const treasuryFactory = oak.treasuryFactory( + process.env.TREASURY_FACTORY_ADDRESS! as `0x${string}`, +); + +const platformHash = keccak256(toHex("celomarket")); +const campaignInfoAddress = process.env.CAMPAIGN_INFO_ADDRESS! as `0x${string}`; +const paymentTreasuryImplementationId = 2n; + +const deployTxHash = await treasuryFactory.deploy( + platformHash, + campaignInfoAddress, + paymentTreasuryImplementationId, +); + +const deployReceipt = await oak.waitForReceipt(deployTxHash); +console.log(`Treasury deployed at block ${deployReceipt.blockNumber}`); + +let treasuryAddress: `0x${string}` | undefined; + +for (const log of deployReceipt.logs) { + try { + const decoded = treasuryFactory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + + if (decoded.eventName === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} + +console.log("PaymentTreasury deployed at:", treasuryAddress); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/03-create-payment.ts similarity index 96% rename from packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/03-create-payment.ts index 8be06fd9..e25ba8d4 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/02-create-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/03-create-payment.ts @@ -1,5 +1,5 @@ /** - * Step 2: Create a Payment Record (Platform Admin) — Off-Chain Payment Flow + * Step 3: Create a Payment Record (Platform Admin) — Off-Chain Payment Flow * * Sam has added a handcrafted ceramic vase ($120) to his cart and * proceeds to checkout. CeloMarket creates a payment record on-chain @@ -18,7 +18,7 @@ * This is one of two independent payment flows: * - Flow A (`createPayment` → off-chain payment → `confirmPayment`): * shown here — platform-initiated, no on-chain token transfer. - * - Flow B (`processCryptoPayment`): shown in Step 3 — buyer pays + * - Flow B (`processCryptoPayment`): shown in Step 4 — buyer pays * directly on-chain with ERC-20 tokens in a single transaction. * * For high-volume platforms, `createPaymentBatch` is available to diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/04-process-crypto-payment.ts similarity index 94% rename from packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/04-process-crypto-payment.ts index df7e1623..52ac1719 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/03-process-crypto-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/04-process-crypto-payment.ts @@ -1,7 +1,7 @@ /** - * Step 3: Process a Crypto Payment (Buyer) — Independent On-Chain Flow + * Step 4: Process a Crypto Payment (Buyer) — Independent On-Chain Flow * - * This is an alternative to Step 2's off-chain `createPayment` flow. + * This is an alternative to Step 3's off-chain `createPayment` flow. * `processCryptoPayment` is a standalone operation that creates the * payment record AND transfers ERC-20 tokens to the treasury in a * single transaction. It does NOT require or complete a prior diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/04-confirm-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/05-confirm-payment.ts similarity index 97% rename from packages/contracts/src/examples/03-campaign-payment-treasury/04-confirm-payment.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/05-confirm-payment.ts index de7c9f85..737369f8 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/04-confirm-payment.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/05-confirm-payment.ts @@ -1,5 +1,5 @@ /** - * Step 4: Confirm the Payment (Platform Admin) + * Step 5: Confirm the Payment (Platform Admin) * * After Sam's tokens arrive in the treasury, CeloMarket performs its * off-chain verification — checking inventory, running fraud detection, diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/05-read-payment-data.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/06-read-payment-data.ts similarity index 98% rename from packages/contracts/src/examples/03-campaign-payment-treasury/05-read-payment-data.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/06-read-payment-data.ts index 9bbef1eb..8657e12a 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/05-read-payment-data.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/06-read-payment-data.ts @@ -1,5 +1,5 @@ /** - * Step 5: Read Payment and Treasury Data (Anyone) + * Step 6: Read Payment and Treasury Data (Anyone) * * All payment and treasury data is stored on-chain and publicly * readable. No wallet connection is required — just an RPC endpoint. diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/07-handle-refunds.ts similarity index 99% rename from packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/07-handle-refunds.ts index 8cdd3211..4c7a084a 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/06-handle-refunds.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/07-handle-refunds.ts @@ -1,5 +1,5 @@ /** - * Step 6: Handle Refunds (Platform Admin / Buyer) + * Step 7: Handle Refunds (Platform Admin / Buyer) * * Suppose the vase arrives damaged. Sam contacts CeloMarket's support * team, and they decide to issue a refund. Three distinct paths exist: diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/07-disburse-fees.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/08-disburse-fees.ts similarity index 95% rename from packages/contracts/src/examples/03-campaign-payment-treasury/07-disburse-fees.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/08-disburse-fees.ts index 693cd0ad..c8c59d29 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/07-disburse-fees.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/08-disburse-fees.ts @@ -1,5 +1,5 @@ /** - * Step 7: Disburse Protocol and Platform Fees (Anyone) + * Step 8: Disburse Protocol and Platform Fees (Anyone) * * `disburseFees()` transfers all accumulated protocol and platform * fees from the treasury to their respective recipients. There is diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/09-withdraw-funds.ts similarity index 92% rename from packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/09-withdraw-funds.ts index a7c15066..0d899eca 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/08-withdraw-funds.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/09-withdraw-funds.ts @@ -1,7 +1,7 @@ /** - * Step 8: Withdraw Confirmed Funds (Platform Admin or Creator) + * Step 9: Withdraw Confirmed Funds (Platform Admin or Creator) * - * After fees have been disbursed (Step 7), the platform admin or the + * After fees have been disbursed (Step 8), the platform admin or the * campaign owner withdraws all remaining confirmed funds from the * treasury. The funds are transferred to the campaign owner's wallet. * diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/09-claim-expired-funds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-expired-funds.ts similarity index 96% rename from packages/contracts/src/examples/03-campaign-payment-treasury/09-claim-expired-funds.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-expired-funds.ts index a47921f9..f2e10f3a 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/09-claim-expired-funds.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-expired-funds.ts @@ -1,5 +1,5 @@ /** - * Step 9: Claim Expired Funds (Platform Admin) + * Step 10: Claim Expired Funds (Platform Admin) * * If a TimeConstrainedPaymentTreasury is used, the platform admin can * sweep all remaining balances after the campaign deadline plus the diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/11-claim-non-goal-line-items.ts similarity index 95% rename from packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/11-claim-non-goal-line-items.ts index 6dcc2ff3..9acde8c1 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-non-goal-line-items.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/11-claim-non-goal-line-items.ts @@ -1,5 +1,5 @@ /** - * Step 10: Claim Non-Goal Line Items (Platform Admin) + * Step 11: Claim Non-Goal Line Items (Platform Admin) * * Some line item types are configured as "non-goal" — they do not * count toward the campaign's fundraising goal. Common examples diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/11-pause-unpause-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/12-pause-unpause-treasury.ts similarity index 95% rename from packages/contracts/src/examples/03-campaign-payment-treasury/11-pause-unpause-treasury.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/12-pause-unpause-treasury.ts index 7212a1b0..c1e139f8 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/11-pause-unpause-treasury.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/12-pause-unpause-treasury.ts @@ -1,5 +1,5 @@ /** - * Step 11: Pause and Unpause the Treasury (Platform Admin) — Optional + * Step 12: Pause and Unpause the Treasury (Platform Admin) — Optional * * ⚙️ THIS STEP IS OPTIONAL — use only when an investigation or * compliance review requires freezing all treasury activity. diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/12-cancel-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/13-cancel-treasury.ts similarity index 95% rename from packages/contracts/src/examples/03-campaign-payment-treasury/12-cancel-treasury.ts rename to packages/contracts/src/examples/03-campaign-payment-treasury/13-cancel-treasury.ts index 9088a64e..5b530cf1 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/12-cancel-treasury.ts +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/13-cancel-treasury.ts @@ -1,5 +1,5 @@ /** - * Step 12: Cancel the Treasury (Platform Admin or Creator) — Optional + * Step 13: Cancel the Treasury (Platform Admin or Creator) — Optional * * ⚙️ THIS STEP IS OPTIONAL — cancellation is permanent and * irreversible. Use only for fraud, terms violation, or shutdown. diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md index 48d34d05..735954a7 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md @@ -14,24 +14,25 @@ Every payment record includes **`paymentToken`**. The treasury only accepts toke ## How It Unfolds -1. **CeloMarket (Platform Admin)** connects to its deployed PaymentTreasury contract and reads back the platform configuration +1. **CeloMarket (Platform Admin / Creator)** creates a CampaignInfo contract via the CampaignInfoFactory — this holds NFT receipts for crypto payments +2. **CeloMarket** deploys a PaymentTreasury through the TreasuryFactory, linking it to the CampaignInfo contract from Step 1 **Two independent payment flows** — a platform uses one or both depending on its business model: -2. **Flow A — Off-chain / fiat payment:** **CeloMarket** creates a payment record for Sam's order via `createPayment`. This records the intent on-chain (total amount, line items, external fees, expiration) but **no funds move**. A buyer pays through off-chain rails (credit card, bank transfer, etc.) and the platform later calls `confirmPayment` after verifying receipt. -3. **Flow B — On-chain crypto payment:** **Sam (Buyer)** pays directly on-chain via `processCryptoPayment`. This is a **standalone operation** — it creates the payment record AND transfers ERC-20 tokens to the treasury in a single transaction. It does **not** require a prior `createPayment` call. An NFT is minted to the buyer as proof of payment. +3. **Flow A — Off-chain / fiat payment:** **CeloMarket** creates a payment record for Sam's order via `createPayment`. This records the intent on-chain (total amount, line items, external fees, expiration) but **no funds move**. A buyer pays through off-chain rails (credit card, bank transfer, etc.) and the platform later calls `confirmPayment` after verifying receipt. +4. **Flow B — On-chain crypto payment:** **Sam (Buyer)** pays directly on-chain via `processCryptoPayment`. This is a **standalone operation** — it creates the payment record AND transfers ERC-20 tokens to the treasury in a single transaction. It does **not** require a prior `createPayment` call. An NFT is minted to the buyer as proof of payment. > **These are two separate flows, not sequential steps.** `processCryptoPayment` does not "complete" a pending `createPayment` — it is an independent entry point for on-chain payments. -4. **CeloMarket** verifies the order (inventory check, fraud review) and confirms the payment. Batch confirmation is available for multiple payments. -5. **Anyone** can read payment data and treasury balances — buyer address, amount, confirmation status, expected pending amount, and line item breakdown -6. If something goes wrong, three separate cancellation/refund paths exist: **a)** the **platform admin** cancels an unconfirmed off-chain payment via `cancelPayment` (deletes the record; no on-chain refund since no funds were transferred); **b)** the **platform admin** refunds a confirmed non-NFT payment via `claimRefund(paymentId, refundAddress)`; **c)** for crypto payments, anyone can call `claimRefundSelf` — the contract looks up the NFT owner, burns the NFT, and sends refundable line items to that owner (no `cancelPayment` needed — crypto payments are auto-confirmed and `cancelPayment` rejects them). -7. **Anyone** disburses accumulated protocol and platform fees -8. **CeloMarket or the Creator** withdraws confirmed funds to the campaign owner's wallet -9. For TimeConstrainedPaymentTreasury: the platform claims all remaining balances after the deadline + claim delay -10. **CeloMarket** claims non-goal line item accumulations (e.g., shipping fees) per token -11. **CeloMarket** can pause and unpause the treasury during an investigation -12. **CeloMarket or the Creator** can permanently cancel the treasury in extreme cases +5. **CeloMarket** verifies the order (inventory check, fraud review) and confirms the payment. Batch confirmation is available for multiple payments. +6. **Anyone** can read payment data and treasury balances — buyer address, amount, confirmation status, expected pending amount, and line item breakdown +7. If something goes wrong, three separate cancellation/refund paths exist: **a)** the **platform admin** cancels an unconfirmed off-chain payment via `cancelPayment` (deletes the record; no on-chain refund since no funds were transferred); **b)** the **platform admin** refunds a confirmed non-NFT payment via `claimRefund(paymentId, refundAddress)`; **c)** for crypto payments, anyone can call `claimRefundSelf` — the contract looks up the NFT owner, burns the NFT, and sends refundable line items to that owner (no `cancelPayment` needed — crypto payments are auto-confirmed and `cancelPayment` rejects them). +8. **Anyone** disburses accumulated protocol and platform fees +9. **CeloMarket or the Creator** withdraws confirmed funds to the campaign owner's wallet +10. For TimeConstrainedPaymentTreasury: the platform claims all remaining balances after the deadline + claim delay +11. **CeloMarket** claims non-goal line item accumulations (e.g., shipping fees) per token +12. **CeloMarket** can pause and unpause the treasury during an investigation +13. **CeloMarket or the Creator** can permanently cancel the treasury in extreme cases ## NFT Handling @@ -41,7 +42,7 @@ This means: - There is no `treasury.ownerOf(...)` or `treasury.approve(...)` on **any** treasury type. - NFT reads (`ownerOf`, `balanceOf`, `tokenURI`, `getPledgeData`) and writes (`approve`, `setApprovalForAll`) go through the **CampaignInfo** entity: `oak.campaignInfo(address)`. -- **Before calling any refund function** that burns an NFT (`claimRefund` on AON/KWR, `claimRefundSelf` on PaymentTreasury), the NFT owner must approve the treasury contract to manage the NFT via `campaignInfo.approve(treasuryAddress, tokenId)`. See [Step 6](./06-handle-refunds.ts) for the full code. +- **Before calling any refund function** that burns an NFT (`claimRefund` on AON/KWR, `claimRefundSelf` on PaymentTreasury), the NFT owner must approve the treasury contract to manage the NFT via `campaignInfo.approve(treasuryAddress, tokenId)`. See [Step 7](./07-handle-refunds.ts) for the full code. - `claimRefundSelf(paymentId)` is the only PaymentTreasury function that interacts with NFTs — it looks up the current NFT owner, burns the NFT, and sends the refundable amount to that owner. Any caller can trigger it; the refund always goes to the NFT owner. - `claimRefund(paymentId, refundAddress)` is for **non-NFT payments** (off-chain `createPayment` where no NFT was minted) and can only be called by the platform admin. @@ -62,18 +63,19 @@ Which variant your platform uses depends on the treasury implementation register | Step | File | Role | Description | Required? | | --- | --- | --- | --- | --- | -| 1 | `01-setup-treasury.ts` | Platform Admin | Connect to the PaymentTreasury and read platform config | Required | -| 2 | `02-create-payment.ts` | Platform Admin | Flow A: Create an off-chain payment record with line items (single + batch) | Required | -| 3 | `03-process-crypto-payment.ts` | Buyer | Flow B: Pay on-chain — creates the payment AND transfers ERC-20 tokens in one step (independent of Step 2) | Required | -| 4 | `04-confirm-payment.ts` | Platform Admin | Confirm the payment after order verification (single + batch) | Required | -| 5 | `05-read-payment-data.ts` | Anyone | Read payment details and treasury dashboard | Required | -| 6 | `06-handle-refunds.ts` | Platform Admin / Buyer | Cancel a payment and claim a refund (self or admin-directed) | Required | -| 7 | `07-disburse-fees.ts` | Anyone | Disburse accumulated protocol and platform fees | Required | -| 8 | `08-withdraw-funds.ts` | Platform Admin or Creator | Withdraw confirmed funds to the campaign owner's wallet | Required | -| 9 | `09-claim-expired-funds.ts` | Platform Admin | Sweep remaining balances after deadline + claim delay (TimeConstrained only) | Required | -| 10 | `10-claim-non-goal-line-items.ts` | Platform Admin | Claim non-goal line item accumulations per token | Required | -| 11 | `11-pause-unpause-treasury.ts` | Platform Admin | Temporarily freeze and resume treasury operations | (Optional) | -| 12 | `12-cancel-treasury.ts` | Platform Admin or Creator | Permanently cancel a treasury | (Optional) | +| 1 | `01-create-campaign.ts` | Platform Admin / Creator | Create a CampaignInfo contract via the factory (holds NFT receipts) | Required | +| 2 | `02-deploy-treasury.ts` | Platform Admin / Creator | Deploy a PaymentTreasury via TreasuryFactory, linked to the CampaignInfo | Required | +| 3 | `03-create-payment.ts` | Platform Admin | Flow A: Create an off-chain payment record with line items (single + batch) | Required | +| 4 | `04-process-crypto-payment.ts` | Buyer | Flow B: Pay on-chain — creates the payment AND transfers ERC-20 tokens in one step (independent of Step 3) | Required | +| 5 | `05-confirm-payment.ts` | Platform Admin | Confirm the payment after order verification (single + batch) | Required | +| 6 | `06-read-payment-data.ts` | Anyone | Read payment details and treasury dashboard | Required | +| 7 | `07-handle-refunds.ts` | Platform Admin / Buyer | Cancel a payment and claim a refund (self or admin-directed) | Required | +| 8 | `08-disburse-fees.ts` | Anyone | Disburse accumulated protocol and platform fees | Required | +| 9 | `09-withdraw-funds.ts` | Platform Admin or Creator | Withdraw confirmed funds to the campaign owner's wallet | Required | +| 10 | `10-claim-expired-funds.ts` | Platform Admin | Sweep remaining balances after deadline + claim delay (TimeConstrained only) | Required | +| 11 | `11-claim-non-goal-line-items.ts` | Platform Admin | Claim non-goal line item accumulations per token | Required | +| 12 | `12-pause-unpause-treasury.ts` | Platform Admin | Temporarily freeze and resume treasury operations | (Optional) | +| 13 | `13-cancel-treasury.ts` | Platform Admin or Creator | Permanently cancel a treasury | (Optional) | ## Role Reference (from the Smart Contract) diff --git a/packages/contracts/src/examples/README.md b/packages/contracts/src/examples/README.md index d838023f..14500e60 100644 --- a/packages/contracts/src/examples/README.md +++ b/packages/contracts/src/examples/README.md @@ -81,18 +81,19 @@ examples/ │ ├── 03-campaign-payment-treasury/ ← E-commerce payment processing │ ├── README.md -│ ├── 01-setup-treasury.ts -│ ├── 02-create-payment.ts ← single + batch -│ ├── 03-process-crypto-payment.ts -│ ├── 04-confirm-payment.ts ← single + batch -│ ├── 05-read-payment-data.ts ← payment details + treasury dashboard -│ ├── 06-handle-refunds.ts ← cancel + self/admin refund -│ ├── 07-disburse-fees.ts -│ ├── 08-withdraw-funds.ts -│ ├── 09-claim-expired-funds.ts ← TimeConstrained only -│ ├── 10-claim-non-goal-line-items.ts -│ ├── 11-pause-unpause-treasury.ts ← OPTIONAL -│ └── 12-cancel-treasury.ts ← OPTIONAL +│ ├── 01-create-campaign.ts +│ ├── 02-deploy-treasury.ts +│ ├── 03-create-payment.ts ← single + batch +│ ├── 04-process-crypto-payment.ts +│ ├── 05-confirm-payment.ts ← single + batch +│ ├── 06-read-payment-data.ts ← payment details + treasury dashboard +│ ├── 07-handle-refunds.ts ← cancel + self/admin refund +│ ├── 08-disburse-fees.ts +│ ├── 09-withdraw-funds.ts +│ ├── 10-claim-expired-funds.ts ← TimeConstrained only +│ ├── 11-claim-non-goal-line-items.ts +│ ├── 12-pause-unpause-treasury.ts ← OPTIONAL +│ └── 13-cancel-treasury.ts ← OPTIONAL │ ├── 04-event-monitoring/ ← Dashboards, analytics, real-time feeds │ ├── README.md From d5176b85280faca09f8ac67d72c17c508e9b3294 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 21:44:45 +0600 Subject: [PATCH 76/86] refactor: update use case documentation to incorporate CampaignInfo contract - Revised the README files for Escrow, Marketplace, and Prepayment use cases to reflect the integration of the CampaignInfo contract in the payment processes. - Clarified the roles of CampaignInfoFactory and TreasuryFactory in creating and deploying contracts, enhancing the understanding of the overall flow. - Improved the integration flow steps to detail the creation of CampaignInfo contracts before deploying PaymentTreasury and TimeConstrainedPaymentTreasury. - Enhanced clarity on the NFT management process and the distinct roles of platform admins and campaign creators in managing funds and payments. --- packages/contracts/src/use-cases/README.md | 14 +-- .../src/use-cases/escrow/healthcare-escrow.md | 107 +++++++++++++++--- .../marketplace/ecommerce-marketplace.md | 100 ++++++++++++++-- .../prepayment/automotive-prepayment.md | 102 ++++++++++++++--- 4 files changed, 276 insertions(+), 47 deletions(-) diff --git a/packages/contracts/src/use-cases/README.md b/packages/contracts/src/use-cases/README.md index 7828efe2..a74e4261 100644 --- a/packages/contracts/src/use-cases/README.md +++ b/packages/contracts/src/use-cases/README.md @@ -16,9 +16,9 @@ Every pledge or payment specifies **`pledgeToken` / `paymentToken`**; treasuries | Use Case | Demo | Contract(s) Used | Business Story | |----------|------|-------------------|----------------| -| **Escrow** | [Healthcare Escrow](escrow/healthcare-escrow.md) | PaymentTreasury | MedConnect holds patient payments until a doctor confirms service delivery | -| **Marketplace** | [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) | PaymentTreasury | CeloMarket locks buyer funds until seller ships; on-chain escrow with line items | -| **Prepayment** | [Automotive Prepayment](prepayment/automotive-prepayment.md) | TimeConstrainedPaymentTreasury | Karma Automotive holds vehicle deposits with time-based expiry; expired funds are swept to the platform/protocol, and end-customer refunds are handled per Karma's policy | +| **Escrow** | [Healthcare Escrow](escrow/healthcare-escrow.md) | CampaignInfoFactory + PaymentTreasury | MedConnect holds patient payments until a doctor confirms service delivery | +| **Marketplace** | [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) | CampaignInfoFactory + PaymentTreasury | CeloMarket locks buyer funds until seller ships; on-chain escrow with line items | +| **Prepayment** | [Automotive Prepayment](prepayment/automotive-prepayment.md) | CampaignInfoFactory + TimeConstrainedPaymentTreasury | Karma Automotive holds vehicle deposits with time-based expiry; expired funds are swept to the platform/protocol, and end-customer refunds are handled per Karma's policy | | **Flexible Funding** | [Community Project](flexible-funding/community-project.md) | CampaignInfoFactory + KeepWhatsRaised | TechForge runs keep-what's-raised campaigns with partial withdrawals, tips, and gateway fees | | **Crowdfunding** | [Creative Campaign](crowdfunding/creative-campaign.md) | CampaignInfoFactory + AllOrNothing | ArtFund runs all-or-nothing campaigns with NFT-backed pledges and reward tiers | @@ -38,20 +38,20 @@ Each guide follows the same structure: Understanding which Oak contract to use for your business: -### PaymentTreasury +### CampaignInfoFactory + PaymentTreasury Best for: **escrow**, **marketplace**, **service payments** -Funds are held until the platform confirms delivery/service. Supports line items, external fees, batch operations, and refund flows. +Create a CampaignInfo contract first (holds NFT receipts, accepted token list), then deploy a PaymentTreasury via TreasuryFactory. Funds are held until the platform confirms delivery/service. Supports line items, external fees, batch operations, and refund flows. - [Healthcare Escrow](escrow/healthcare-escrow.md) — service escrow - [E-Commerce Marketplace](marketplace/ecommerce-marketplace.md) — product escrow with line items -### TimeConstrainedPaymentTreasury +### CampaignInfoFactory + TimeConstrainedPaymentTreasury Best for: **prepayments**, **deposits**, **time-bound commitments** -Same interface as PaymentTreasury, but with on-chain time windows. After the campaign deadline plus the platform claim delay, the platform admin can call `claimExpiredFunds()` to sweep idle balances on-chain (recipients are defined by the contract); align end-customer refunds with your product policy. +Same setup and interface as PaymentTreasury, but with on-chain time windows. After the campaign deadline plus the platform claim delay, the platform admin can call `claimExpiredFunds()` to sweep idle balances on-chain (recipients are defined by the contract); align end-customer refunds with your product policy. - [Automotive Prepayment](prepayment/automotive-prepayment.md) — vehicle deposit with 6-month delivery window diff --git a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md index 67a7f9db..2919ca0e 100644 --- a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -16,7 +16,11 @@ MedConnect needs a trustless escrow mechanism that: ## Oak Contract Used -**PaymentTreasury** — a smart contract that holds funds until the platform confirms service delivery. Supports line items, external fees, and refund flows. +| Contract | Purpose | +|----------|---------| +| **CampaignInfoFactory** | Creates the CampaignInfo contract that holds NFT receipts and the accepted token list | +| **TreasuryFactory** | Deploys the PaymentTreasury clone linked to the CampaignInfo | +| **PaymentTreasury** | Holds patient funds until the platform confirms service delivery. Supports line items, external fees, and refund flows | ### Multi-token support @@ -34,14 +38,17 @@ Payments specify **`paymentToken`**; the contract reverts unless **`CampaignInfo ## Integration Flow -### Step 1: Connect to the deployed PaymentTreasury +### Step 1: Create a CampaignInfo contract -> **Role: Any caller** — connecting and reading treasury state is public. +> **Role: Any caller** — `createCampaign` is permissionless. -MedConnect has a PaymentTreasury deployed for its healthcare escrow pool. The backend connects using the SDK. +Before deploying a PaymentTreasury, MedConnect needs a CampaignInfo contract. This holds NFT receipts for crypto payments and defines the accepted token list. ```typescript -import { createOakContractsClient, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; +import { + createOakContractsClient, keccak256, toHex, + getCurrentTimestamp, addDays, CHAIN_IDS, CAMPAIGN_INFO_FACTORY_EVENTS, +} from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -49,14 +56,82 @@ const oak = createOakContractsClient({ privateKey: process.env.PLATFORM_PRIVATE_KEY as `0x${string}`, }); -const treasury = oak.paymentTreasury(TREASURY_ADDRESS); +const factory = oak.campaignInfoFactory(CAMPAIGN_INFO_FACTORY_ADDRESS); + +const platformHash = keccak256(toHex("medconnect")); +const identifierHash = keccak256(toHex("medconnect-escrow-2026")); +const now = getCurrentTimestamp(); + +const txHash = await factory.createCampaign({ + creator: PLATFORM_ADMIN_ADDRESS, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now, + deadline: addDays(now, 365), + goalAmount: 0n, + currency: toHex("USD", { size: 32 }), + }, + nftName: "MedConnect Receipts", + nftSymbol: "MCR", + nftImageURI: "ipfs://QmXyz.../medconnect-receipt.png", + contractURI: "ipfs://QmXyz.../metadata.json", +}); -// Verify the treasury is operational -const isPaused = await treasury.paused(); -const isCancelled = await treasury.cancelled(); +const receipt = await oak.waitForReceipt(txHash); + +let campaignInfoAddress: `0x${string}` | undefined; +for (const log of receipt.logs) { + try { + const decoded = factory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + if (decoded.eventName === CAMPAIGN_INFO_FACTORY_EVENTS.CampaignCreated) { + campaignInfoAddress = decoded.args?.campaignInfoAddress as `0x${string}`; + break; + } + } catch { /* log from a different contract */ } +} +``` + +### Step 2: Deploy the PaymentTreasury + +> **Role: Any caller** — `deploy` on TreasuryFactory is permissionless (the implementation must have been registered and approved during platform onboarding). + +MedConnect deploys a PaymentTreasury linked to the CampaignInfo from Step 1. + +```typescript +import { TREASURY_FACTORY_EVENTS } from "@oaknetwork/contracts-sdk"; + +const treasuryFactory = oak.treasuryFactory(TREASURY_FACTORY_ADDRESS); + +const deployTxHash = await treasuryFactory.deploy( + platformHash, + campaignInfoAddress!, + 2n, // PaymentTreasury implementation ID +); + +const deployReceipt = await oak.waitForReceipt(deployTxHash); + +let treasuryAddress: `0x${string}` | undefined; +for (const log of deployReceipt.logs) { + try { + const decoded = treasuryFactory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + if (decoded.eventName === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { /* log from a different contract */ } +} + +const treasury = oak.paymentTreasury(treasuryAddress!); ``` -### Step 2: Patient books appointment — two independent payment flows +### Step 3: Patient books appointment — two independent payment flows Sarah books a cardiology consultation with Dr. Rivera. The appointment costs 150 USDC broken down into two line items: consultation (120 USDC) and lab work (30 USDC). @@ -139,7 +214,7 @@ await oak.waitForReceipt(txHash); Funds are now **locked in the treasury**. Sarah cannot withdraw them, and neither can Dr. Rivera — only the platform can release them by confirming delivery. -### Step 3: Doctor confirms service delivery +### Step 4: Doctor confirms service delivery > **Role: Platform Admin** — only the platform admin can confirm payments. @@ -154,7 +229,7 @@ await oak.waitForReceipt(txHash); The payment status is now **confirmed**. Funds are settled and ready for fee disbursement and withdrawal. -### Step 4: Read the final treasury state +### Step 5: Read the final treasury state > **Role: Any caller** — all read functions are public. @@ -169,7 +244,7 @@ const [raised, available, lifetime, refunded] = await oak.multicall([ ]); ``` -### Step 5: Disburse fees +### Step 6: Disburse fees > **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. @@ -180,7 +255,7 @@ const txHash = await treasury.disburseFees(); await oak.waitForReceipt(txHash); ``` -### Step 6: Withdraw settled funds +### Step 7: Withdraw settled funds > **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (Dr. Rivera's clinic). @@ -224,7 +299,7 @@ await campaign.approve(TREASURY_ADDRESS, tokenId); await treasury.claimRefundSelf(paymentId); ``` -### Step 7: Claim non-goal line items +### Step 8: Claim non-goal line items > **Role: Platform Admin** — only the platform admin can claim non-goal line items. @@ -235,7 +310,7 @@ const txHash = await treasury.claimNonGoalLineItems(USDC_TOKEN_ADDRESS); await oak.waitForReceipt(txHash); ``` -### Step 8: Pause, unpause, or cancel the treasury +### Step 9: Pause, unpause, or cancel the treasury **Pause the treasury:** diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md index 6171a9f0..0dd34169 100644 --- a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -13,10 +13,12 @@ CeloMarket needs: - **Fee transparency** — protocol and platform fees are tracked and disbursed on-chain - **Fiat-to-fiat UX** — end users see USD prices; crypto conversion happens behind the scenes -## Oak Contract Used +## Oak Contracts Used | Contract | Purpose | |----------|---------| +| **CampaignInfoFactory** | Creates the CampaignInfo contract that holds NFT receipts and the accepted token list | +| **TreasuryFactory** | Deploys the PaymentTreasury clone linked to the CampaignInfo | | **PaymentTreasury** | Holds buyer funds until delivery is confirmed | ## Multi-token support @@ -35,14 +37,17 @@ CeloMarket needs: ## Integration Flow -### Step 1: Connect to the PaymentTreasury +### Step 1: Create a CampaignInfo contract -> **Role: Any caller** — connecting and reading state is public. +> **Role: Any caller** — `createCampaign` is permissionless. -CeloMarket's backend connects to the deployed PaymentTreasury contract. +Before deploying a PaymentTreasury, CeloMarket needs a CampaignInfo contract. This holds NFT receipts for crypto payments and defines the accepted token list. ```typescript -import { createOakContractsClient, CHAIN_IDS, toHex } from "@oaknetwork/contracts-sdk"; +import { + createOakContractsClient, keccak256, toHex, + getCurrentTimestamp, addDays, CHAIN_IDS, CAMPAIGN_INFO_FACTORY_EVENTS, +} from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -50,10 +55,83 @@ const oak = createOakContractsClient({ privateKey: process.env.PLATFORM_PRIVATE_KEY as `0x${string}`, }); -const treasury = oak.paymentTreasury(TREASURY_ADDRESS); +const factory = oak.campaignInfoFactory(CAMPAIGN_INFO_FACTORY_ADDRESS); + +const platformHash = keccak256(toHex("celomarket")); +const identifierHash = keccak256(toHex("celomarket-storefront-2026")); +const now = getCurrentTimestamp(); + +const txHash = await factory.createCampaign({ + creator: PLATFORM_ADMIN_ADDRESS, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now, + deadline: addDays(now, 365), + goalAmount: 0n, + currency: toHex("USD", { size: 32 }), + }, + nftName: "CeloMarket Receipts", + nftSymbol: "CMR", + nftImageURI: "ipfs://QmXyz.../celomarket-receipt.png", + contractURI: "ipfs://QmXyz.../metadata.json", +}); + +const receipt = await oak.waitForReceipt(txHash); + +// Decode the CampaignCreated event from the receipt (recommended) +let campaignInfoAddress: `0x${string}` | undefined; +for (const log of receipt.logs) { + try { + const decoded = factory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + if (decoded.eventName === CAMPAIGN_INFO_FACTORY_EVENTS.CampaignCreated) { + campaignInfoAddress = decoded.args?.campaignInfoAddress as `0x${string}`; + break; + } + } catch { /* log from a different contract */ } +} +``` + +### Step 2: Deploy the PaymentTreasury + +> **Role: Any caller** — `deploy` on TreasuryFactory is permissionless (the implementation must have been registered and approved during platform onboarding). + +CeloMarket deploys a PaymentTreasury linked to the CampaignInfo from Step 1. + +```typescript +import { TREASURY_FACTORY_EVENTS } from "@oaknetwork/contracts-sdk"; + +const treasuryFactory = oak.treasuryFactory(TREASURY_FACTORY_ADDRESS); + +const deployTxHash = await treasuryFactory.deploy( + platformHash, + campaignInfoAddress!, + 2n, // PaymentTreasury implementation ID +); + +const deployReceipt = await oak.waitForReceipt(deployTxHash); + +let treasuryAddress: `0x${string}` | undefined; +for (const log of deployReceipt.logs) { + try { + const decoded = treasuryFactory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + if (decoded.eventName === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { /* log from a different contract */ } +} + +const treasury = oak.paymentTreasury(treasuryAddress!); ``` -### Step 2: Buyer places order — two independent payment flows +### Step 3: Buyer places order — two independent payment flows CeloMarket supports two payment methods. A platform uses one or both depending on its business model — they are **not** sequential steps. @@ -127,7 +205,7 @@ await oak.waitForReceipt(txHash); Funds are now **locked** — the seller cannot access them until CeloMarket confirms shipment. -### Step 3: Seller ships — platform confirms payment +### Step 4: Seller ships — platform confirms payment > **Role: Platform Admin** — only the platform admin can confirm payments. @@ -150,7 +228,7 @@ const txHash = await treasury.confirmPaymentBatch(orderIds, buyerAddresses); await oak.waitForReceipt(txHash); ``` -### Step 4: Read order state — dashboard view +### Step 5: Read order state — dashboard view > **Role: Any caller** — all read functions are public. @@ -170,7 +248,7 @@ const [raised, available, refunded, expected] = await oak.multicall([ ]); ``` -### Step 5: Fee disbursement +### Step 6: Fee disbursement > **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. @@ -181,7 +259,7 @@ const txHash = await treasury.disburseFees(); await oak.waitForReceipt(txHash); ``` -### Step 6: Seller withdrawal +### Step 7: Seller withdrawal > **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (the seller). diff --git a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md index faf68195..d1e5d79f 100644 --- a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md +++ b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md @@ -16,7 +16,11 @@ Karma Automotive needs: ## Oak Contract Used -**TimeConstrainedPaymentTreasury** — identical to PaymentTreasury in its SDK interface (both use `oak.paymentTreasury()`), but the smart contract enforces launch-time and deadline constraints on-chain. After the deadline passes and the claim delay expires, `claimExpiredFunds` becomes callable. +| Contract | Purpose | +|----------|---------| +| **CampaignInfoFactory** | Creates the CampaignInfo contract that holds NFT receipts and the accepted token list | +| **TreasuryFactory** | Deploys the TimeConstrainedPaymentTreasury clone linked to the CampaignInfo | +| **TimeConstrainedPaymentTreasury** | Identical to PaymentTreasury in its SDK interface (both use `oak.paymentTreasury()`), but enforces launch-time and deadline constraints on-chain. After the deadline passes and the claim delay expires, `claimExpiredFunds` becomes callable | ### Multi-token support @@ -37,14 +41,17 @@ Like **PaymentTreasury**, the time-constrained variant is **multi-token**: **`pa ## Integration Flow -### Step 1: Connect to the TimeConstrainedPaymentTreasury +### Step 1: Create a CampaignInfo contract -> **Role: Any caller** — connecting and reading treasury state is public. +> **Role: Any caller** — `createCampaign` is permissionless. -Karma's order management system connects to their deployed treasury. +Before deploying a TimeConstrainedPaymentTreasury, Karma needs a CampaignInfo contract. This holds NFT receipts for crypto payments and defines the accepted token list. ```typescript -import { createOakContractsClient, CHAIN_IDS, toHex } from "@oaknetwork/contracts-sdk"; +import { + createOakContractsClient, keccak256, toHex, + getCurrentTimestamp, addDays, CHAIN_IDS, CAMPAIGN_INFO_FACTORY_EVENTS, +} from "@oaknetwork/contracts-sdk"; const oak = createOakContractsClient({ chainId: CHAIN_IDS.CELO_TESTNET_SEPOLIA, @@ -52,14 +59,83 @@ const oak = createOakContractsClient({ privateKey: process.env.KARMA_PLATFORM_KEY as `0x${string}`, }); -// TimeConstrainedPaymentTreasury uses the same SDK entity as PaymentTreasury -const treasury = oak.paymentTreasury(TIME_CONSTRAINED_TREASURY_ADDRESS); +const factory = oak.campaignInfoFactory(CAMPAIGN_INFO_FACTORY_ADDRESS); + +const platformHash = keccak256(toHex("karma-automotive")); +const identifierHash = keccak256(toHex("karma-gs6-preorders-2026")); +const now = getCurrentTimestamp(); + +const txHash = await factory.createCampaign({ + creator: KARMA_ADMIN_ADDRESS, + identifierHash, + selectedPlatformHash: [platformHash], + campaignData: { + launchTime: now, + deadline: addDays(now, 180), // 6-month delivery window + goalAmount: 0n, + currency: toHex("USD", { size: 32 }), + }, + nftName: "Karma GS-6 Deposits", + nftSymbol: "KGS6", + nftImageURI: "ipfs://QmXyz.../karma-gs6.png", + contractURI: "ipfs://QmXyz.../metadata.json", +}); + +const receipt = await oak.waitForReceipt(txHash); + +let campaignInfoAddress: `0x${string}` | undefined; +for (const log of receipt.logs) { + try { + const decoded = factory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + if (decoded.eventName === CAMPAIGN_INFO_FACTORY_EVENTS.CampaignCreated) { + campaignInfoAddress = decoded.args?.campaignInfoAddress as `0x${string}`; + break; + } + } catch { /* log from a different contract */ } +} +``` + +### Step 2: Deploy the TimeConstrainedPaymentTreasury + +> **Role: Any caller** — `deploy` on TreasuryFactory is permissionless (the implementation must have been registered and approved during platform onboarding). + +Karma deploys a TimeConstrainedPaymentTreasury linked to the CampaignInfo from Step 1. The time constraints (launch time and deadline) are enforced on-chain by the contract. -const isPaused = await treasury.paused(); -const isCancelled = await treasury.cancelled(); +```typescript +import { TREASURY_FACTORY_EVENTS } from "@oaknetwork/contracts-sdk"; + +const treasuryFactory = oak.treasuryFactory(TREASURY_FACTORY_ADDRESS); + +const deployTxHash = await treasuryFactory.deploy( + platformHash, + campaignInfoAddress!, + 3n, // TimeConstrainedPaymentTreasury implementation ID +); + +const deployReceipt = await oak.waitForReceipt(deployTxHash); + +let treasuryAddress: `0x${string}` | undefined; +for (const log of deployReceipt.logs) { + try { + const decoded = treasuryFactory.events.decodeLog({ + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: log.data as `0x${string}`, + }); + if (decoded.eventName === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { /* log from a different contract */ } +} + +// TimeConstrainedPaymentTreasury uses the same SDK entity as PaymentTreasury +const treasury = oak.paymentTreasury(treasuryAddress!); ``` -### Step 2: Customer orders a vehicle — two independent payment flows +### Step 3: Customer orders a vehicle — two independent payment flows James orders a Karma GS-6 electric sedan with the Performance Package. The total prepayment is $52,500 broken down into line items. @@ -136,7 +212,7 @@ await oak.waitForReceipt(txHash); Funds are now **locked in the time-constrained treasury**. The clock is ticking toward the 6-month delivery deadline. -### Step 3: Monitor the order status +### Step 4: Monitor the order status > **Role: Any caller** — all read functions are public. @@ -156,7 +232,7 @@ const [raised, available, expected] = await oak.multicall([ ]); ``` -### Step 4 (Success): Vehicle delivered — confirm and withdraw +### Step 5 (Success): Vehicle delivered — confirm and withdraw > **Role: Platform Admin** for `confirmPayment` (must still be within the launch…deadline+buffer window). **Any caller** for `disburseFees` (after `launchTime`). **Platform Admin or Campaign Owner** for `withdraw` (after `launchTime`). @@ -177,7 +253,7 @@ const withdrawTx = await treasury.withdraw(); await oak.waitForReceipt(withdrawTx); ``` -### Step 4 (Failure): Claim window after deadline — platform sweeps expired funds +### Step 5 (Failure): Claim window after deadline — platform sweeps expired funds > **Role: Platform Admin** — only the platform admin can call `claimExpiredFunds`. Callable only after `campaignDeadline + platformClaimDelay`, and only after `launchTime` (time-constrained variant). From 9ec38c56b43593b919eacda78e8a363c7285cb16 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 21:57:49 +0600 Subject: [PATCH 77/86] refactor: enhance treasury registration verification examples - Updated the verification process for treasury registrations to filter and confirm implementations specific to the NovaPay platform, avoiding false positives from shared deployments. - Added detailed logging for registered implementations and their approval status, ensuring clarity on which implementations are approved. - Improved comments to clarify the purpose of each verification step, specifically for NovaPay, enhancing overall code readability and maintainability. --- .../00-platform-enlistment/05-verify-setup.ts | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts index 0e2d9cb1..fe692b7b 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts @@ -41,15 +41,52 @@ console.log("Admin:", adminAddress); console.log("Fee:", Number(feePercent), "bps"); console.log("Claim delay:", Number(claimDelay), "seconds"); -// 2. Confirm treasury registrations via events +// 2. Confirm treasury registrations for THIS platform only. +// On a shared deployment other platforms may have their own registrations +// — filtering by platformHash avoids false positives. const registeredLogs = await treasuryFactory.events.getImplementationRegisteredLogs(); -const approvalLogs = await treasuryFactory.events.getImplementationApprovalLogs(); +const novaPayRegistrations = registeredLogs.filter( + (log) => log.args?.platformHash === platformHash, +); console.log("\n=== TreasuryFactory ==="); -console.log("Registered implementations:", registeredLogs.length); -console.log("Approval events:", approvalLogs.length); +console.log("Registered implementations (NovaPay):", novaPayRegistrations.length); + +for (const reg of novaPayRegistrations) { + console.log( + ` Slot ${reg.args?.implementationId} → ${reg.args?.implementation}`, + ); +} + +// TreasuryImplementationApproval events are keyed by implementation address, +// not by platform. Cross-reference: check that each implementation address +// NovaPay registered has a corresponding approval with isApproved === true. +const approvalLogs = await treasuryFactory.events.getImplementationApprovalLogs(); + +const approvedAddresses = new Set( + approvalLogs + .filter((log) => log.args?.isApproved === true) + .map((log) => (log.args?.implementation as string)?.toLowerCase()), +); + +const allApproved = novaPayRegistrations.every((reg) => + approvedAddresses.has((reg.args?.implementation as string)?.toLowerCase()), +); + +console.log("All NovaPay implementations approved:", allApproved); + +if (!allApproved) { + for (const reg of novaPayRegistrations) { + const addr = (reg.args?.implementation as string)?.toLowerCase(); + if (!approvedAddresses.has(addr)) { + console.error( + ` ✗ Slot ${reg.args?.implementationId} (${reg.args?.implementation}) — NOT approved`, + ); + } + } +} -// 3. Confirm enlistment event was emitted +// 3. Confirm enlistment event was emitted for NovaPay specifically const enlistmentLogs = await globalParams.events.getPlatformEnlistedLogs(); const novaPayLog = enlistmentLogs.find( (log) => log.args?.platformBytes === platformHash, From b6107932f347350a19b971f679976a68cd1136aa Mon Sep 17 00:00:00 2001 From: tahseen-ccprotocol Date: Fri, 17 Apr 2026 22:06:59 +0600 Subject: [PATCH 78/86] chore: added changeset --- .changeset/bumpy-games-chew.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bumpy-games-chew.md diff --git a/.changeset/bumpy-games-chew.md b/.changeset/bumpy-games-chew.md new file mode 100644 index 00000000..5e55ce46 --- /dev/null +++ b/.changeset/bumpy-games-chew.md @@ -0,0 +1,5 @@ +--- +"@oaknetwork/contracts-sdk": minor +--- + +Update event log fetching documentation and improve code consistency From 744016551008aa0a2abdf79e35398a6c8e2a53a2 Mon Sep 17 00:00:00 2001 From: tahseen-ccprotocol Date: Fri, 17 Apr 2026 22:08:55 +0600 Subject: [PATCH 79/86] chore: added changeset --- .changeset/huge-meals-smash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/huge-meals-smash.md diff --git a/.changeset/huge-meals-smash.md b/.changeset/huge-meals-smash.md new file mode 100644 index 00000000..ee7f0db4 --- /dev/null +++ b/.changeset/huge-meals-smash.md @@ -0,0 +1,5 @@ +--- +"@oaknetwork/contracts-sdk": minor +--- + +Add missing events, reads, errors, type-safe constants, simulation results, transaction preparation, and getReceipt From 5a2434a3d0b523c977bba6cf4cff996e9cd4328b Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 17 Apr 2026 22:41:14 +0600 Subject: [PATCH 80/86] docs(contracts): align PaymentTreasury use cases and examples with on-chain payment flow --- .../03-campaign-payment-treasury/README.md | 33 ++++---- .../src/use-cases/escrow/healthcare-escrow.md | 71 ++++++++-------- .../marketplace/ecommerce-marketplace.md | 83 ++++++++++--------- .../prepayment/automotive-prepayment.md | 62 ++++++++------ 4 files changed, 132 insertions(+), 117 deletions(-) diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md index 735954a7..85370f1b 100644 --- a/packages/contracts/src/examples/03-campaign-payment-treasury/README.md +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md @@ -4,9 +4,9 @@ **CeloMarket** is an online marketplace where independent artisans sell handcrafted goods. Unlike the crowdfunding scenarios (Scenarios 1 and 2), CeloMarket does not run time-bound campaigns with pledges and rewards. Instead, it processes individual **e-commerce transactions** — a buyer selects a product, pays with cryptocurrency, and the platform fulfills the order. -CeloMarket uses the **PaymentTreasury** model, which works like a traditional payment processor but entirely on-chain. Every payment is broken down into **line items** (product price, shipping, tax) and follows a two-step flow: the buyer pays, and the platform confirms after verifying the order. +CeloMarket uses the **PaymentTreasury** model, which works like a traditional payment processor but entirely on-chain. Every payment is broken down into **line items** (product price, shipping, tax) and follows a two-step flow for the off-chain path: the buyer pays, the treasury is funded, and the platform confirms after verifying the order. Direct on-chain checkout uses `processCryptoPayment` instead. -In this scenario, a buyer named **Sam** purchases a handcrafted ceramic vase for **$120** with **$15 shipping**. The payment flows through the treasury, gets confirmed by the platform, and the funds become available for withdrawal. +In this scenario, a buyer named **Sam** purchases a handcrafted ceramic vase for **$120** with **$15 shipping**. The payment flows through the treasury, gets confirmed by the platform (off-chain path) or settles in one transaction (`processCryptoPayment`), and the funds become available for withdrawal after fees are disbursed. ## Multi-token support @@ -19,20 +19,19 @@ Every payment record includes **`paymentToken`**. The treasury only accepts toke **Two independent payment flows** — a platform uses one or both depending on its business model: -3. **Flow A — Off-chain / fiat payment:** **CeloMarket** creates a payment record for Sam's order via `createPayment`. This records the intent on-chain (total amount, line items, external fees, expiration) but **no funds move**. A buyer pays through off-chain rails (credit card, bank transfer, etc.) and the platform later calls `confirmPayment` after verifying receipt. -4. **Flow B — On-chain crypto payment:** **Sam (Buyer)** pays directly on-chain via `processCryptoPayment`. This is a **standalone operation** — it creates the payment record AND transfers ERC-20 tokens to the treasury in a single transaction. It does **not** require a prior `createPayment` call. An NFT is minted to the buyer as proof of payment. +3. **Flow A — Off-chain / fiat payment:** **CeloMarket** creates a payment record via `createPayment`, Sam pays through off-chain rails, the treasury is funded with the payment token, then CeloMarket calls `confirmPayment` after verifying receipt. +4. **Flow B — On-chain crypto payment:** **Sam (Buyer)** pays via `processCryptoPayment` in one transaction (no prior `createPayment`). An NFT is minted as proof of payment. > **These are two separate flows, not sequential steps.** `processCryptoPayment` does not "complete" a pending `createPayment` — it is an independent entry point for on-chain payments. -5. **CeloMarket** verifies the order (inventory check, fraud review) and confirms the payment. Batch confirmation is available for multiple payments. -6. **Anyone** can read payment data and treasury balances — buyer address, amount, confirmation status, expected pending amount, and line item breakdown -7. If something goes wrong, three separate cancellation/refund paths exist: **a)** the **platform admin** cancels an unconfirmed off-chain payment via `cancelPayment` (deletes the record; no on-chain refund since no funds were transferred); **b)** the **platform admin** refunds a confirmed non-NFT payment via `claimRefund(paymentId, refundAddress)`; **c)** for crypto payments, anyone can call `claimRefundSelf` — the contract looks up the NFT owner, burns the NFT, and sends refundable line items to that owner (no `cancelPayment` needed — crypto payments are auto-confirmed and `cancelPayment` rejects them). -8. **Anyone** disburses accumulated protocol and platform fees -9. **CeloMarket or the Creator** withdraws confirmed funds to the campaign owner's wallet -10. For TimeConstrainedPaymentTreasury: the platform claims all remaining balances after the deadline + claim delay -11. **CeloMarket** claims non-goal line item accumulations (e.g., shipping fees) per token -12. **CeloMarket** can pause and unpause the treasury during an investigation -13. **CeloMarket or the Creator** can permanently cancel the treasury in extreme cases +5. **Anyone** can read payment data and treasury balances — buyer address, amount, confirmation status, expected pending amount, and line item breakdown +6. If something goes wrong, three separate cancellation/refund paths exist: **a)** the **platform admin** cancels an unconfirmed off-chain payment via `cancelPayment` (clears pending accounting; **does not** automatically return ERC-20 already sent to the treasury—handle recovery operationally); **b)** the **platform admin** refunds a confirmed non-NFT payment via `claimRefund(paymentId, refundAddress)`; **c)** for crypto payments, anyone can call `claimRefundSelf` — the contract looks up the NFT owner, burns the NFT, and sends refundable line items to that owner (no `cancelPayment` needed — crypto payments are auto-confirmed and `cancelPayment` rejects them). +7. **Anyone** disburses accumulated protocol and platform fees +8. **CeloMarket or the Creator** withdraws confirmed funds to the campaign owner's wallet +9. For TimeConstrainedPaymentTreasury: the platform claims all remaining balances after the deadline + claim delay +10. **CeloMarket** claims non-goal line item accumulations (e.g., shipping fees) per token +11. **CeloMarket** can pause and unpause the treasury during an investigation +12. **CeloMarket or the Creator** can permanently cancel the treasury in extreme cases ## NFT Handling @@ -65,9 +64,9 @@ Which variant your platform uses depends on the treasury implementation register | --- | --- | --- | --- | --- | | 1 | `01-create-campaign.ts` | Platform Admin / Creator | Create a CampaignInfo contract via the factory (holds NFT receipts) | Required | | 2 | `02-deploy-treasury.ts` | Platform Admin / Creator | Deploy a PaymentTreasury via TreasuryFactory, linked to the CampaignInfo | Required | -| 3 | `03-create-payment.ts` | Platform Admin | Flow A: Create an off-chain payment record with line items (single + batch) | Required | -| 4 | `04-process-crypto-payment.ts` | Buyer | Flow B: Pay on-chain — creates the payment AND transfers ERC-20 tokens in one step (independent of Step 3) | Required | -| 5 | `05-confirm-payment.ts` | Platform Admin | Confirm the payment after order verification (single + batch) | Required | +| 3 | `03-create-payment.ts` | Platform Admin | Flow A: Create an off-chain payment record with line items (single + batch) | Required for Flow A | +| 4 | `04-process-crypto-payment.ts` | Buyer | Flow B: Pay on-chain — creates the payment, pulls ERC-20, confirms, mints NFT in one tx (independent of Step 3) | Required for Flow B | +| 5 | `05-confirm-payment.ts` | Platform Admin | Flow A only: Confirm after tokens are in the treasury (single + batch). Omit if using Flow B | Required for Flow A | | 6 | `06-read-payment-data.ts` | Anyone | Read payment details and treasury dashboard | Required | | 7 | `07-handle-refunds.ts` | Platform Admin / Buyer | Cancel a payment and claim a refund (self or admin-directed) | Required | | 8 | `08-disburse-fees.ts` | Anyone | Disburse accumulated protocol and platform fees | Required | @@ -83,7 +82,7 @@ Which variant your platform uses depends on the treasury implementation register | --- | --- | --- | | `createPayment` / `createPaymentBatch` | Platform Admin | `onlyPlatformAdmin` | | `processCryptoPayment` | Anyone (buyer) | (no role modifier) | -| `confirmPayment` / `confirmPaymentBatch` | Platform Admin | `onlyPlatformAdmin` | +| `confirmPayment` / `confirmPaymentBatch` | Platform Admin | `onlyPlatformAdmin` (for `createPayment` records only) | | `cancelPayment` | Platform Admin | `onlyPlatformAdmin` | | `claimRefundSelf(paymentId)` | Anyone (crypto payments only — burns NFT, refund goes to NFT owner) | (no role modifier) | | `claimRefund(paymentId, refundAddress)` | Platform Admin (off-chain payments only — `tokenId == 0`) | `onlyPlatformAdmin` | diff --git a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md index 2919ca0e..bc4dfca2 100644 --- a/packages/contracts/src/use-cases/escrow/healthcare-escrow.md +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -141,7 +141,7 @@ MedConnect supports two payment methods — they are **not** sequential steps: > **Role: Platform Admin** — only the platform admin can create payment records. -MedConnect creates a payment record on-chain. **No funds move** — Sarah pays through off-chain rails (credit card, insurance billing, etc.) and MedConnect calls `confirmPayment` after verifying receipt. +MedConnect creates a payment record on-chain. The **`createPayment` transaction does not pull ERC-20 from the buyer’s wallet** — it only records the obligation and pending accounting. Sarah pays through off-chain rails (credit card, insurance billing, etc.). Before MedConnect can call `confirmPayment`, **the treasury must actually hold enough of the payment token on-chain** (for example the platform deposits USDC after fiat settlement). The contract checks the treasury’s ERC-20 balance when confirming; if the tokens are not there, `confirmPayment` reverts. ```typescript import { toHex } from "@oaknetwork/contracts-sdk"; @@ -177,7 +177,22 @@ const txHash = await treasury.createPayment( await oak.waitForReceipt(txHash); ``` -At this point the payment record exists on-chain, but **no funds have moved yet**. Sarah pays through off-chain channels. +At this point the payment record exists on-chain and pending amounts are tracked, but **no ERC-20 was transferred in this transaction**. Sarah completes payment off-chain; your operations then **fund the treasury** with the agreed token amount before you confirm (how you bridge or deposit is product-specific). + +##### Confirm payment (platform admin) + +> **Role: Platform Admin** — only the platform admin can call `confirmPayment` for payments created with `createPayment`. + +Dr. Rivera completes the consultation and marks it as delivered. After off-chain verification **and** once the treasury holds the required ERC-20 balance, the backend calls `confirmPayment` to move accounting from pending to confirmed (and optionally mint an NFT if you pass Sarah’s wallet as `buyerAddress`). + +```typescript +await treasury.simulate.confirmPayment(paymentId, SARAH_WALLET_ADDRESS); + +const txHash = await treasury.confirmPayment(paymentId, SARAH_WALLET_ADDRESS); +await oak.waitForReceipt(txHash); +``` + +The payment status is now **confirmed**. Funds are ready for fee disbursement and withdrawal. #### Flow B: On-chain crypto payment (`processCryptoPayment`) @@ -212,24 +227,7 @@ const txHash = await treasury.processCryptoPayment( await oak.waitForReceipt(txHash); ``` -Funds are now **locked in the treasury**. Sarah cannot withdraw them, and neither can Dr. Rivera — only the platform can release them by confirming delivery. - -### Step 4: Doctor confirms service delivery - -> **Role: Platform Admin** — only the platform admin can confirm payments. - -Dr. Rivera completes the consultation and marks it as delivered in the MedConnect dashboard. The backend calls `confirmPayment` to release the escrowed funds. - -```typescript -await treasury.simulate.confirmPayment(paymentId, SARAH_WALLET_ADDRESS); - -const txHash = await treasury.confirmPayment(paymentId, SARAH_WALLET_ADDRESS); -await oak.waitForReceipt(txHash); -``` - -The payment status is now **confirmed**. Funds are settled and ready for fee disbursement and withdrawal. - -### Step 5: Read the final treasury state +### Step 4: Read the final treasury state > **Role: Any caller** — all read functions are public. @@ -244,7 +242,7 @@ const [raised, available, lifetime, refunded] = await oak.multicall([ ]); ``` -### Step 6: Disburse fees +### Step 5: Disburse fees > **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. @@ -255,7 +253,7 @@ const txHash = await treasury.disburseFees(); await oak.waitForReceipt(txHash); ``` -### Step 7: Withdraw settled funds +### Step 6: Withdraw settled funds > **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (Dr. Rivera's clinic). @@ -272,7 +270,7 @@ await oak.waitForReceipt(txHash); **A) Cancel an unconfirmed off-chain payment (before `confirmPayment`):** -> **Role: Platform Admin** — `cancelPayment` works only on unconfirmed, non-expired, non-crypto payments. No on-chain funds were transferred for off-chain payments, so the on-chain record is simply deleted. Any off-chain refund is handled by MedConnect outside the contract. +> **Role: Platform Admin** — `cancelPayment` works only on unconfirmed, non-expired, non-crypto payments. The transaction **removes the pending payment record** from contract accounting; it **does not** automatically return ERC-20 that may already sit in the treasury—handle any token recovery operationally if you deposited before cancelling. Off-chain refunds (card reversal, etc.) are handled by MedConnect outside this call. ```typescript await treasury.cancelPayment(paymentId); @@ -299,7 +297,7 @@ await campaign.approve(TREASURY_ADDRESS, tokenId); await treasury.claimRefundSelf(paymentId); ``` -### Step 8: Claim non-goal line items +### Step 7: Claim non-goal line items > **Role: Platform Admin** — only the platform admin can claim non-goal line items. @@ -310,7 +308,7 @@ const txHash = await treasury.claimNonGoalLineItems(USDC_TOKEN_ADDRESS); await oak.waitForReceipt(txHash); ``` -### Step 9: Pause, unpause, or cancel the treasury +### Step 8: Pause, unpause, or cancel the treasury **Pause the treasury:** @@ -406,12 +404,20 @@ Patient (Sarah) MedConnect (Platform Admin) PaymentTreasur | | | | | createPayment(...) | | | [Platform Admin] | - | |------------------------------>| Payment recorded (no funds) + | |------------------------------>| Payment recorded (no pull from buyer) | | | | Pays off-chain | | | (insurance, credit card) | | |------------------------------->| | | | | + | Tokens sent to treasury | (deposit / bridge / ops) | + | |------------------------------>| ERC-20 balance must cover confirm + | | | + | Doctor confirms delivery | + | | confirmPayment(...) | + | | [Platform Admin] | + | |------------------------------>| Pending → confirmed (Flow A only) + | | | | --- FLOW B: On-chain crypto payment --- | | | | | ERC-20 approve() | | @@ -419,14 +425,9 @@ Patient (Sarah) MedConnect (Platform Admin) PaymentTreasur | | | | | processCryptoPayment(...) | | | [Any caller] | - | |------------------------------>| Payment created + funds locked + | |------------------------------>| Pull tokens, already confirmed + NFT | | | - | --- Both flows continue here --- | - | | | - | Doctor confirms delivery | - | | confirmPayment(...) | - | | [Platform Admin] | - | |------------------------------>| Funds settled + | --- Both flows (after Flow A confirm or Flow B) --- | | | | | | claimNonGoalLineItems() | | | [Platform Admin] | @@ -449,8 +450,10 @@ Patient (Sarah) MedConnect (Platform Admin) PaymentTreasur ## Key Takeaways - **ERC-20 approval is required** — the buyer must `approve` the treasury contract before `processCryptoPayment` can transfer tokens +- **`createPayment` path** — the `createPayment` transaction does not pull ERC-20 from the buyer; fund the treasury before `confirmPayment` (the contract checks balance on confirm) +- **`processCryptoPayment` path** — pulls tokens and confirms in one transaction; use `disburseFees` / `withdraw` afterward—do not call `confirmPayment` for these payments - **Multi-token** — `paymentToken` must be on the campaign’s accepted list; balances and refunds are tracked per ERC-20 (each token’s decimals) -- **Funds are never at risk** — they stay locked in the smart contract until service is confirmed +- **Funds stay in the treasury** — held under contract rules until withdrawal, disbursement, or refund flows - **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner - **Three cancellation/refund paths** — `cancelPayment` deletes unconfirmed off-chain records (no on-chain refund); `claimRefund(paymentId, address)` refunds confirmed non-NFT payments (platform admin); `claimRefundSelf(paymentId)` refunds crypto/NFT payments directly (NFT owner, no prior cancel needed; burns pledge NFT — requires prior ERC-721 approval on the CampaignInfo contract) - **Line items** allow granular tracking (consultation vs. lab work) with configurable goal-counting, fees, and refund rules diff --git a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md index 0dd34169..34960ede 100644 --- a/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -139,7 +139,7 @@ CeloMarket supports two payment methods. A platform uses one or both depending o > **Role: Platform Admin** — only the platform admin can create payment records. -A buyer orders wireless headphones for $79.99. CeloMarket's backend creates a payment record on-chain. **No funds move** — the buyer pays through off-chain rails (credit card, bank transfer, etc.) and CeloMarket calls `confirmPayment` after verifying receipt. +A buyer orders wireless headphones for $79.99. CeloMarket's backend creates a payment record on-chain. The **`createPayment` transaction does not pull ERC-20 from the buyer’s wallet** — it records the order and pending accounting. The buyer pays through off-chain rails (credit card, bank transfer, etc.). Before `confirmPayment`, **the treasury must hold enough of the payment token on-chain** (for example after fiat settlement the platform deposits USDC). The contract checks the treasury balance when confirming. ```typescript const orderId = toHex("order-20260415-001", { size: 32 }); @@ -171,6 +171,31 @@ const txHash = await treasury.createPayment( await oak.waitForReceipt(txHash); ``` +After `createPayment`, **fund the treasury** with the agreed token amount before calling `confirmPayment` (operational path is product-specific). + +##### Confirm after shipment (platform admin) + +> **Role: Platform Admin** — `confirmPayment` applies only to payments created with `createPayment`. + +The seller uploads a shipping proof (tracking number). After verification **and** once the treasury holds the required ERC-20, CeloMarket confirms the payment on-chain. + +```typescript +await treasury.simulate.confirmPayment(orderId, BUYER_ADDRESS); + +const txHash = await treasury.confirmPayment(orderId, BUYER_ADDRESS); +await oak.waitForReceipt(txHash); +``` + +For batch order processing (e.g. end-of-day settlement): + +```typescript +const orderIds = [orderId1, orderId2, orderId3]; +const buyerAddresses = [buyer1, buyer2, buyer3]; + +const txHash = await treasury.confirmPaymentBatch(orderIds, buyerAddresses); +await oak.waitForReceipt(txHash); +``` + #### Flow B: On-chain crypto payment (`processCryptoPayment`) > **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. @@ -203,32 +228,7 @@ const txHash = await treasury.processCryptoPayment( await oak.waitForReceipt(txHash); ``` -Funds are now **locked** — the seller cannot access them until CeloMarket confirms shipment. - -### Step 4: Seller ships — platform confirms payment - -> **Role: Platform Admin** — only the platform admin can confirm payments. - -The seller uploads a shipping proof (tracking number). CeloMarket's backend verifies and confirms the payment. - -```typescript -await treasury.simulate.confirmPayment(orderId, BUYER_ADDRESS); - -const txHash = await treasury.confirmPayment(orderId, BUYER_ADDRESS); -await oak.waitForReceipt(txHash); -``` - -For batch order processing (e.g. end-of-day settlement): - -```typescript -const orderIds = [orderId1, orderId2, orderId3]; -const buyerAddresses = [buyer1, buyer2, buyer3]; - -const txHash = await treasury.confirmPaymentBatch(orderIds, buyerAddresses); -await oak.waitForReceipt(txHash); -``` - -### Step 5: Read order state — dashboard view +### Step 4: Read order state — dashboard view > **Role: Any caller** — all read functions are public. @@ -248,7 +248,7 @@ const [raised, available, refunded, expected] = await oak.multicall([ ]); ``` -### Step 6: Fee disbursement +### Step 5: Fee disbursement > **Role: Any caller** — `disburseFees` is permissionless. Fees are sent to the Protocol Admin and Platform Admin automatically. @@ -259,7 +259,7 @@ const txHash = await treasury.disburseFees(); await oak.waitForReceipt(txHash); ``` -### Step 7: Seller withdrawal +### Step 6: Seller withdrawal > **Role: Platform Admin or Campaign Owner** — either party can trigger withdrawal. Funds are always sent to the campaign owner (the seller). @@ -276,7 +276,7 @@ await oak.waitForReceipt(txHash); **A) Cancel an unconfirmed off-chain payment (before `confirmPayment`):** -> **Role: Platform Admin** — `cancelPayment` works only on unconfirmed, non-expired, non-crypto payments. No on-chain funds were transferred for off-chain payments, so the on-chain record is simply deleted. Any off-chain refund (credit card reversal, etc.) is handled by the platform outside the contract. +> **Role: Platform Admin** — `cancelPayment` works only on unconfirmed, non-expired, non-crypto payments. The transaction **drops pending accounting**; it **does not** automatically return ERC-20 already sent to the treasury—recover tokens operationally if needed. Off-chain refunds (credit card reversal, etc.) are handled outside this call. ```typescript await treasury.cancelPayment(orderId); @@ -365,12 +365,20 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain | | | | | createPayment() | | | [Platform Admin] | - | |------------------------------->| Order recorded (no funds) + | |------------------------------->| Order recorded (no pull from buyer) | | | | Buyer pays off-chain | | | (credit card, etc.) | | |------------------------->| | | | | + | Tokens to treasury | (deposit / bridge / ops) | + | |------------------------------->| Balance must cover confirm + | | | + | Seller ships product | + | | confirmPayment() | + | | [Platform Admin] | + | |------------------------------->| Flow A: pending → confirmed + | | | | --- FLOW B: On-chain crypto payment --- | | | | | ERC-20 approve() | | @@ -378,14 +386,9 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain | | | | | processCryptoPayment() | | | [Any caller] | - | |------------------------------->| Payment created + funds locked - | | | - | --- Both flows continue here --- | + | |------------------------------->| Pull + confirmed + NFT | | | - | Seller ships product | - | | confirmPayment() | - | | [Platform Admin] | - | |------------------------------->| Funds settled + | --- Both flows (after Flow A confirm or Flow B) --- | | | | | | claimNonGoalLineItems() | | | [Platform Admin] | @@ -408,6 +411,8 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain ## Key Takeaways - **ERC-20 approval is required** — the buyer must `approve` the treasury contract before `processCryptoPayment` can transfer tokens +- **`createPayment` path** — `createPayment` does not pull tokens from the buyer; fund the treasury before `confirmPayment` +- **`processCryptoPayment` path** — confirms in one transaction; then `disburseFees` / `withdraw`—do not call `confirmPayment` for these payments - **Multi-token** — orders can settle in any **accepted** `paymentToken`; treasury accounting is per token address - **Role-based access** — `createPayment`/`confirmPayment`/`cancelPayment` are platform-admin-only; `processCryptoPayment` and `disburseFees` are permissionless; `withdraw` requires admin or owner - **Three cancellation/refund paths** — `cancelPayment` deletes unconfirmed off-chain records (no on-chain refund); `claimRefund(paymentId, address)` refunds confirmed non-NFT payments (platform admin); `claimRefundSelf(paymentId)` refunds crypto/NFT payments directly (NFT owner, no prior cancel needed; requires prior ERC-721 approval on CampaignInfo) @@ -416,4 +421,4 @@ Buyer (Alex) CeloMarket (Platform Admin) Blockchain - **Batch operations** (`createPaymentBatch`, `confirmPaymentBatch`) enable efficient end-of-day settlement - **Pause / cancel controls** — platform admin can pause; either admin or owner can permanently cancel - **Fiat-to-fiat for users** — buyers and sellers deal in USD; crypto conversion is abstracted away -- **Buyer protection** — funds only released after shipment confirmation, with a full refund path +- **Buyer protection** — funds stay in the treasury under contract rules until withdrawal; refunds use `cancelPayment`, `claimRefund`, or `claimRefundSelf` as applicable diff --git a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md index d1e5d79f..ba81ec7a 100644 --- a/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md +++ b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md @@ -145,7 +145,7 @@ Karma supports two payment methods — they are **not** sequential steps: > **Role: Platform Admin** — only the platform admin can create payment records. Must be called within the time window (`launchTime` to `deadline + bufferTime`). -Karma's system creates a payment record on-chain. **No funds move** — James pays through off-chain rails (wire transfer, dealership financing, etc.) and Karma calls `confirmPayment` after verifying receipt. +Karma's system creates a payment record on-chain. The **`createPayment` transaction does not pull ERC-20 from the buyer’s wallet** — it records the order and pending accounting. James pays through off-chain rails (wire transfer, dealership financing, etc.). Before `confirmPayment`, **the treasury must hold enough of the payment token on-chain** (for example the platform deposits stablecoins after settlement). The contract checks the treasury balance when confirming. ```typescript const orderId = toHex("karma-order-GS6-2026-0415", { size: 32 }); @@ -178,6 +178,20 @@ const txHash = await treasury.createPayment( await oak.waitForReceipt(txHash); ``` +After `createPayment`, **fund the treasury** with the agreed token amount before calling `confirmPayment` (operational path is product-specific). + +##### Confirm after delivery (platform admin) + +> **Role: Platform Admin** — `confirmPayment` must still be within the launch…deadline+buffer window. + +The GS-6 is manufactured and delivered to James. After the treasury holds the required tokens and Karma verifies delivery (still within the time window): + +```typescript +await treasury.simulate.confirmPayment(orderId, JAMES_WALLET_ADDRESS); +const confirmTx = await treasury.confirmPayment(orderId, JAMES_WALLET_ADDRESS); +await oak.waitForReceipt(confirmTx); +``` + #### Flow B: On-chain crypto payment (`processCryptoPayment`) > **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. Must be called within the time window. @@ -210,8 +224,6 @@ const txHash = await treasury.processCryptoPayment( await oak.waitForReceipt(txHash); ``` -Funds are now **locked in the time-constrained treasury**. The clock is ticking toward the 6-month delivery deadline. - ### Step 4: Monitor the order status > **Role: Any caller** — all read functions are public. @@ -221,8 +233,9 @@ Karma's dashboard tracks the prepayment and treasury health. ```typescript // Read the specific order const paymentData = await treasury.getPaymentData(orderId); -// paymentData.isConfirmed === false (not yet delivered) -// paymentData.expiration — the delivery deadline +// Flow A (createPayment): paymentData.isConfirmed === false until Flow A confirm in Step 3 +// Flow B (processCryptoPayment): paymentData.isConfirmed === true after Step 3 Flow B +// paymentData.expiration — the delivery deadline (Flow A); crypto payments use expiration 0 on-chain // Treasury-level metrics const [raised, available, expected] = await oak.multicall([ @@ -232,23 +245,14 @@ const [raised, available, expected] = await oak.multicall([ ]); ``` -### Step 5 (Success): Vehicle delivered — confirm and withdraw - -> **Role: Platform Admin** for `confirmPayment` (must still be within the launch…deadline+buffer window). **Any caller** for `disburseFees` (after `launchTime`). **Platform Admin or Campaign Owner** for `withdraw` (after `launchTime`). +### Step 5 (Success): Vehicle delivered — disburse and withdraw -The GS-6 is manufactured and delivered to James. Karma's system confirms delivery. +> **Any caller** for `disburseFees` (after `launchTime`). **Platform Admin or Campaign Owner** for `withdraw` (after `launchTime`). For **Flow A**, you already called `confirmPayment` under Step 3 after delivery; for **Flow B**, the payment was confirmed when `processCryptoPayment` ran—do not call `confirmPayment` here. ```typescript -// Confirm delivery -await treasury.simulate.confirmPayment(orderId, JAMES_WALLET_ADDRESS); -const confirmTx = await treasury.confirmPayment(orderId, JAMES_WALLET_ADDRESS); -await oak.waitForReceipt(confirmTx); - -// Disburse protocol and platform fees const feeTx = await treasury.disburseFees(); await oak.waitForReceipt(feeTx); -// Withdraw settled funds to the dealer (campaign owner) const withdrawTx = await treasury.withdraw(); await oak.waitForReceipt(withdrawTx); ``` @@ -271,7 +275,7 @@ This is the core value of the **TimeConstrainedPaymentTreasury** — the **claim **A) Cancel unconfirmed off-chain payment (before `confirmPayment`):** -> **Role: Platform Admin** for `cancelPayment` (within the launch…deadline+buffer window). +> **Role: Platform Admin** for `cancelPayment` (within the launch…deadline+buffer window). This clears pending accounting only; **it does not automatically return ERC-20** already sent to the treasury—handle recovery operationally if you deposited before cancelling. ```typescript await treasury.cancelPayment(orderId); @@ -359,12 +363,21 @@ Customer (James) Karma (Platform Admin) TimeConstrainedTreasu | | | | | createPayment(...) | | | [Platform Admin, in window] | - | |------------------------------->| Order recorded (no funds) + | |------------------------------->| Order recorded (no pull from buyer) | | | | Pays off-chain | | | (wire, financing) | | |------------------------>| | | | | + | Tokens to treasury | (deposit / bridge / ops) | + | |------------------------------->| Balance must cover confirm + | | | + | --- SUCCESS PATH (Flow A) --- | + | | | + | Vehicle delivered | confirmPayment(...) | + | | [Platform Admin, in window] | + | |------------------------------->| Pending → confirmed + | | | | --- FLOW B: On-chain crypto payment --- | | | | | ERC-20 approve() | | @@ -372,16 +385,9 @@ Customer (James) Karma (Platform Admin) TimeConstrainedTreasu | | | | | processCryptoPayment(...) | | | [Any caller, in window] | - | |------------------------------->| Payment created + funds locked - | | | - | --- Both flows continue here --- | - | | | - | --- SUCCESS PATH --- | - | | | - | Vehicle delivered | confirmPayment(...) | - | | [Platform Admin, in window] | - | |------------------------------->| Funds settled + | |------------------------------->| Pull + confirmed + NFT | | | + | --- Fees & withdraw (both flows) --- | | | disburseFees() | | | [Any caller, after launch] | | |------------------------------->| Fees → Protocol + Platform @@ -406,6 +412,8 @@ Customer (James) Karma (Platform Admin) TimeConstrainedTreasu ## Key Takeaways - **ERC-20 approval is required** — James must `approve` the treasury before `processCryptoPayment` can pull tokens +- **`createPayment` path** — fund the treasury before `confirmPayment` (`createPayment` does not pull from the buyer) +- **`processCryptoPayment` path** — do not call `confirmPayment` afterward; use `disburseFees` / `withdraw` when appropriate - **Multi-token** — use any **accepted** `paymentToken` for the campaign; balances and sweeps are per ERC-20 - **Time gates are enforced on-chain** — create/confirm/cancel/pay paths must occur within `launchTime` … `deadline + bufferTime`; refunds, fee disbursement, withdrawal, non-goal claims, and expired sweeps require time **after** `launchTime` - **Same SDK interface** as PaymentTreasury — `oak.paymentTreasury()` works for both; behavior differs in the deployed contract bytecode From a47eb4f0bd00620651f3a7edc72a97cddd92c7db Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 17 Apr 2026 22:46:45 +0600 Subject: [PATCH 81/86] fix(contracts-sdk): mark changeset as major and add TAbi generic to prepareContractWrite --- .changeset/huge-meals-smash.md | 2 +- packages/contracts/src/utils/prepare.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/huge-meals-smash.md b/.changeset/huge-meals-smash.md index ee7f0db4..f0391e48 100644 --- a/.changeset/huge-meals-smash.md +++ b/.changeset/huge-meals-smash.md @@ -1,5 +1,5 @@ --- -"@oaknetwork/contracts-sdk": minor +"@oaknetwork/contracts-sdk": major --- Add missing events, reads, errors, type-safe constants, simulation results, transaction preparation, and getReceipt diff --git a/packages/contracts/src/utils/prepare.ts b/packages/contracts/src/utils/prepare.ts index e16d5f3b..e84b6432 100644 --- a/packages/contracts/src/utils/prepare.ts +++ b/packages/contracts/src/utils/prepare.ts @@ -71,9 +71,9 @@ export interface PreparedTransaction { * // tx = { to, data, value, gas } * ``` */ -export async function prepareContractWrite( +export async function prepareContractWrite( publicClient: PublicClient, - options: PrepareWriteOptions, + options: PrepareWriteOptions, ): Promise { const data = encodeFunctionData({ abi: options.abi, From 29c92c55c17d7bf6d8f541282af7b1ac44ddc0b9 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 23:02:32 +0600 Subject: [PATCH 82/86] refactor: enhance treasury registration verification logic - Updated the verification process to derive active treasury registrations by filtering out removed implementations, ensuring accurate tracking of current registrations for the NovaPay platform. - Improved logging to reflect the count of active implementations and their approval status, providing clearer insights into the treasury's state. - Enhanced comments for better understanding of the verification steps and the significance of approval events in the context of treasury management. --- .../00-platform-enlistment/05-verify-setup.ts | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts index fe692b7b..06d3b318 100644 --- a/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts +++ b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts @@ -41,46 +41,60 @@ console.log("Admin:", adminAddress); console.log("Fee:", Number(feePercent), "bps"); console.log("Claim delay:", Number(claimDelay), "seconds"); -// 2. Confirm treasury registrations for THIS platform only. -// On a shared deployment other platforms may have their own registrations -// — filtering by platformHash avoids false positives. +// 2. Derive CURRENT registrations by replaying Registered vs Removed events. +// TreasuryFactory has no read methods, so events are the only data source. +// A slot that was registered then later removed should not count as active. const registeredLogs = await treasuryFactory.events.getImplementationRegisteredLogs(); -const novaPayRegistrations = registeredLogs.filter( - (log) => log.args?.platformHash === platformHash, +const removedLogs = await treasuryFactory.events.getImplementationRemovedLogs(); + +const removedSlots = new Set( + removedLogs + .filter((log) => log.args?.platformHash === platformHash) + .map((log) => String(log.args?.implementationId)), +); + +const activeRegistrations = registeredLogs.filter( + (log) => + log.args?.platformHash === platformHash && + !removedSlots.has(String(log.args?.implementationId)), ); console.log("\n=== TreasuryFactory ==="); -console.log("Registered implementations (NovaPay):", novaPayRegistrations.length); +console.log("Active implementations (NovaPay):", activeRegistrations.length); -for (const reg of novaPayRegistrations) { +for (const reg of activeRegistrations) { console.log( ` Slot ${reg.args?.implementationId} → ${reg.args?.implementation}`, ); } -// TreasuryImplementationApproval events are keyed by implementation address, -// not by platform. Cross-reference: check that each implementation address -// NovaPay registered has a corresponding approval with isApproved === true. +// TreasuryImplementationApproval events are keyed by implementation address +// (not by platform) and can toggle — only the latest event per address +// determines the current state. Build a map of address → latest isApproved. const approvalLogs = await treasuryFactory.events.getImplementationApprovalLogs(); -const approvedAddresses = new Set( - approvalLogs - .filter((log) => log.args?.isApproved === true) - .map((log) => (log.args?.implementation as string)?.toLowerCase()), -); +const latestApproval = new Map(); +for (const log of approvalLogs) { + const addr = (log.args?.implementation as string)?.toLowerCase(); + if (addr) { + latestApproval.set(addr, log.args?.isApproved as boolean); + } +} -const allApproved = novaPayRegistrations.every((reg) => - approvedAddresses.has((reg.args?.implementation as string)?.toLowerCase()), -); +const allApproved = activeRegistrations.every((reg) => { + const addr = (reg.args?.implementation as string)?.toLowerCase(); + return latestApproval.get(addr) === true; +}); console.log("All NovaPay implementations approved:", allApproved); if (!allApproved) { - for (const reg of novaPayRegistrations) { + for (const reg of activeRegistrations) { const addr = (reg.args?.implementation as string)?.toLowerCase(); - if (!approvedAddresses.has(addr)) { + if (latestApproval.get(addr) !== true) { + const status = latestApproval.has(addr) ? "disapproved" : "no approval event"; console.error( - ` ✗ Slot ${reg.args?.implementationId} (${reg.args?.implementation}) — NOT approved`, + ` ✗ Slot ${reg.args?.implementationId} (${reg.args?.implementation}) — ${status}`, ); } } From be936dde3c419c12441868366bee83dcb78174e0 Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Fri, 17 Apr 2026 23:08:30 +0600 Subject: [PATCH 83/86] docs(README): add examples and use cases sections for SDK guidance - Introduced a new "Examples" section detailing scenario-driven TypeScript examples that cover various SDK functionalities, providing users with practical implementation stories. - Added a "Use Cases" section that outlines business-oriented integration guides, illustrating how real companies can utilize the SDK for specific applications. - Enhanced the README to improve navigation and understanding of the SDK's capabilities, directing users to relevant resources for both executable examples and documentation guides. --- packages/contracts/README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/contracts/README.md b/packages/contracts/README.md index b2455763..59dfcf2f 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -411,10 +411,46 @@ See [CLAUDE.md](../../CLAUDE.md) for coding standards including architecture pri --- +## Examples + +The [`src/examples/`](./src/examples/) folder contains scenario-driven, step-by-step TypeScript examples covering the full SDK surface. Each scenario tells a story (platform onboarding, crowdfunding campaign, e-commerce checkout) and implements it with working code. + +| # | Scenario | What you will learn | +| --- | --- | --- | +| 0 | [Platform Enlistment](./src/examples/00-platform-enlistment/) | How a new platform joins Oak Protocol — enlistment, treasury registration, approval, optional configuration | +| 1 | [All-or-Nothing Campaign](./src/examples/01-campaign-all-or-nothing/) | Full crowdfunding lifecycle — campaign creation, pledges, success/failure paths | +| 2 | [Keep-What's-Raised Campaign](./src/examples/02-campaign-keep-whats-raised/) | Flexible funding with partial withdrawals, tips, and configurable fees | +| 3 | [Payment Treasury](./src/examples/03-campaign-payment-treasury/) | E-commerce payment flow with line items, confirmations, and refunds | +| 4 | [Event Monitoring](./src/examples/04-event-monitoring/) | Historical logs, real-time watchers, raw log decoding, and metrics | +| 5 | [Error Handling](./src/examples/05-error-handling/) | Simulation, typed errors, prepared transactions, and safe send patterns | +| 6 | [Advanced Patterns](./src/examples/06-advanced-patterns/) | Multicall batching, signer overrides, item registry, browser/Privy wallets | + +> Start with **Scenario 0** if you are a new platform. Start with **Scenario 1**, **2**, or **3** if your platform is already onboarded. + +--- + +## Use Cases + +The [`src/use-cases/`](./src/use-cases/) folder contains business-oriented integration guides that show how real companies would use the SDK. Each guide tells a complete story — from the business problem to the on-chain solution — with illustrative code snippets. + +| Use Case | Guide | Contract(s) Used | +| --- | --- | --- | +| **Crowdfunding** | [Creative Campaign](./src/use-cases/crowdfunding/creative-campaign.md) | CampaignInfoFactory + AllOrNothing | +| **Flexible Funding** | [Community Project](./src/use-cases/flexible-funding/community-project.md) | CampaignInfoFactory + KeepWhatsRaised | +| **Marketplace** | [E-Commerce Marketplace](./src/use-cases/marketplace/ecommerce-marketplace.md) | CampaignInfoFactory + PaymentTreasury | +| **Escrow** | [Healthcare Escrow](./src/use-cases/escrow/healthcare-escrow.md) | CampaignInfoFactory + PaymentTreasury | +| **Prepayment** | [Automotive Prepayment](./src/use-cases/prepayment/automotive-prepayment.md) | CampaignInfoFactory + TimeConstrainedPaymentTreasury | + +> These are documentation guides, not runnable scripts. For executable examples, see the [`src/examples/`](./src/examples/) folder above. + +--- + ## Documentation - [Full docs](https://oaknetwork.org/docs/contracts-sdk/overview) — oaknetwork.org/docs/contracts-sdk/overview - [Quickstart](https://oaknetwork.org/docs/contracts-sdk/quickstart) — oaknetwork.org/docs/contracts-sdk/quickstart +- [Examples](./src/examples/) — scenario-driven TypeScript examples +- [Use Cases](./src/use-cases/) — business-oriented integration guides - [Monorepo README](../../README.md) — README.md - [Changelog](./CHANGELOG.md) — CHANGELOG.md From 9509b920019ed87122bc8d5dd62af1ff084f5c23 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 17 Apr 2026 23:45:47 +0600 Subject: [PATCH 84/86] fix(contracts): satisfy viem typings for encodeFunctionData in prepareContractWrite --- packages/contracts/src/utils/prepare.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/utils/prepare.ts b/packages/contracts/src/utils/prepare.ts index e84b6432..3d5e0866 100644 --- a/packages/contracts/src/utils/prepare.ts +++ b/packages/contracts/src/utils/prepare.ts @@ -78,8 +78,8 @@ export async function prepareContractWrite( const data = encodeFunctionData({ abi: options.abi, functionName: options.functionName, - args: options.args as unknown[], - }); + args: options.args ?? [], + } as Parameters[0]); const gas = await publicClient.estimateContractGas({ address: options.address, From f8ffd4a6e46445700c365cfff2ab4710db354ae3 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 17 Apr 2026 23:57:33 +0600 Subject: [PATCH 85/86] test(contracts): cover default args branch in prepareContractWrite to restore 100% branch coverage --- .../contracts/__tests__/unit/prepare.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/contracts/__tests__/unit/prepare.test.ts b/packages/contracts/__tests__/unit/prepare.test.ts index fd22da69..cc755549 100644 --- a/packages/contracts/__tests__/unit/prepare.test.ts +++ b/packages/contracts/__tests__/unit/prepare.test.ts @@ -80,6 +80,39 @@ describe("prepareContractWrite", () => { expect(result.value).toBe(500n); }); + + it("defaults args to [] when not provided", async () => { + const NO_ARG_ABI = [ + { + type: "function" as const, + name: "ping", + stateMutability: "nonpayable" as const, + inputs: [], + outputs: [], + }, + ] as const; + + const pub = { + estimateContractGas: jest.fn().mockResolvedValue(21000n), + } as unknown as PublicClient; + + const mockChain = { id: 1, name: "test" } as Chain; + + const result = await prepareContractWrite(pub, { + address: ADDR, + abi: NO_ARG_ABI, + functionName: "ping", + account: ADDR, + chain: mockChain, + }); + + expect(result.to).toBe(ADDR); + expect(result.data).toMatch(/^0x/); + expect(result.value).toBe(0n); + expect(result.gas).toBe(21000n); + const callArgs = (pub.estimateContractGas as jest.Mock).mock.calls[0][0]; + expect(callArgs.args).toBeUndefined(); + }); }); describe("toPreparedTransaction", () => { From a8ad0513ed5fa16a94f3ee8c92a3796542f3a0dd Mon Sep 17 00:00:00 2001 From: Mahabub Alahi Date: Sat, 18 Apr 2026 00:37:52 +0600 Subject: [PATCH 86/86] chore: update changeset --- .changeset/bumpy-games-chew.md | 2 +- .changeset/huge-meals-smash.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/huge-meals-smash.md diff --git a/.changeset/bumpy-games-chew.md b/.changeset/bumpy-games-chew.md index 5e55ce46..15534e93 100644 --- a/.changeset/bumpy-games-chew.md +++ b/.changeset/bumpy-games-chew.md @@ -2,4 +2,4 @@ "@oaknetwork/contracts-sdk": minor --- -Update event log fetching documentation and improve code consistency +Add missing events, reads, errors, type-safe constants, simulation results, transaction preparation, and getReceipt; update event log fetching documentation and improve code consistency diff --git a/.changeset/huge-meals-smash.md b/.changeset/huge-meals-smash.md deleted file mode 100644 index f0391e48..00000000 --- a/.changeset/huge-meals-smash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@oaknetwork/contracts-sdk": major ---- - -Add missing events, reads, errors, type-safe constants, simulation results, transaction preparation, and getReceipt