diff --git a/.changeset/bumpy-games-chew.md b/.changeset/bumpy-games-chew.md new file mode 100644 index 00000000..15534e93 --- /dev/null +++ b/.changeset/bumpy-games-chew.md @@ -0,0 +1,5 @@ +--- +"@oaknetwork/contracts-sdk": minor +--- + +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/packages/contracts/README.md b/packages/contracts/README.md index 51ee1fc7..59dfcf2f 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 -Four 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) @@ -171,712 +177,170 @@ 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. -## Contract Entities - -### GlobalParams - -Protocol-wide configuration registry. Manages platform listings, fee settings, token currencies, line item types, and a general-purpose key-value registry. - -```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 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). +### Transaction Receipts ---- - -### CampaignInfoFactory - -Deploys new CampaignInfo contracts. Each campaign gets its own on-chain CampaignInfo instance with its own address, NFT collection, and configuration. +The client provides two methods for fetching transaction receipts: ```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", -}); - +// Wait for a pending transaction to be mined (blocking) const receipt = await oak.waitForReceipt(txHash); -const campaignAddress = await factory.identifierToCampaignInfo(identifierHash); -``` +console.log(`Mined in block ${receipt.blockNumber}, gas used: ${receipt.gasUsed}`); -> 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); +// 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}`); +} ``` -> For complete details on the Treasury Factory contract entity, please visit the following link: [Treasury Factory](https://oaknetwork.org/docs/contracts-sdk/treasury-factory). +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. --- -### 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); - -// 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); -``` - -> **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). - ---- - -### AllOrNothing Treasury +## Contract Entities -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. +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 aon = oak.allOrNothingTreasury("0x..."); +const gp = oak.globalParams("0x..."); // Reads -const raised = await aon.getRaisedAmount(); -const reward = await aon.getReward(rewardName); +const admin = await gp.getProtocolAdminAddress(); +const fee = await gp.getProtocolFeePercent(); +const isListed = await gp.checkIfPlatformIsListed(platformHash); // 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); -``` - -> 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). - ---- - -### KeepWhatsRaised Treasury - -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. - -```typescript -const kwr = oak.keepWhatsRaisedTreasury("0x..."); +await gp.enlistPlatform(platformHash, adminAddress, feePercent, adapterAddress); +await gp.updateProtocolFeePercent(newFeePercent); -// Reads -const raised = await kwr.getRaisedAmount(); -const available = await kwr.getAvailableRaisedAmount(); -const reward = await kwr.getReward(rewardName); +// Simulate (dry-run a write, returns SimulationResult) +const sim = await gp.simulate.enlistPlatform(hash, admin, fee, adapter); -// 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); +// Events +const logs = await gp.events.getPlatformEnlistedLogs(); +const unwatch = gp.events.watchPlatformEnlisted((logs) => { /* ... */ }); ``` -> 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 - -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); +### Available Entities -// Writes -await ir.addItem(itemId, item); -await ir.addItemsBatch(itemIds, items); -``` +| 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 Item Registry contract entity, please visit the following link: [Item Registry](https://oaknetwork.org/docs/contracts-sdk/item-registry). +> `paymentTreasury()` supports both **PaymentTreasury** and **TimeConstrainedPaymentTreasury** variants — same ABI, same SDK interface. --- -## 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 +## Simulation & Transaction Preparation -Financial aggregation from a deployed CampaignInfo contract: +Simulate methods return a `SimulationResult` with the predicted return value and prepared transaction parameters. On revert, a typed SDK error is thrown. ```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}`); +const sim = await gp.simulate.enlistPlatform(hash, adminAddr, fee, adapter); +// sim.result — contract return value +// sim.request — { to, data, value, gas } ``` -### Treasury Report +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. -Per-treasury financial report for any treasury type: +> Full simulation and transaction preparation docs: [Simulation](https://oaknetwork.org/docs/contracts-sdk/simulation) -```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, +// Watch live events +const unwatch = gp.events.watchPlatformEnlisted((logs) => { + for (const log of logs) console.log(log.args); }); +unwatch(); // stop watching ``` -### 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); +> Full event reference for all contracts: [Events](https://oaknetwork.org/docs/contracts-sdk/events) -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 +## Error Handling -Each event has a `watch*()` method that subscribes to real-time event notifications. The method returns an `unwatch` function to stop listening. +The SDK decodes on-chain revert data into typed error classes with recovery hints. ```typescript -const gp = oak.globalParams("0x..."); +import { parseContractError, getRevertData } from "@oaknetwork/contracts-sdk"; -// Start watching for new PlatformEnlisted events -const unwatch = gp.events.watchPlatformEnlisted((logs) => { - for (const log of logs) { - console.log("New platform enlisted:", log.args); +try { + await factory.createCampaign({ ... }); +} catch (err) { + const revertData = getRevertData(err); + const parsed = parseContractError(revertData ?? ""); + if (parsed) { + console.error(parsed.name, parsed.args, parsed.recoveryHint); } -}); - -// 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); -``` +> Full error handling guide: [Error Handling](https://oaknetwork.org/docs/contracts-sdk/error-handling) -#### 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); -``` +## Multicall -#### CampaignInfo +Batch multiple read calls into a single RPC round-trip: ```typescript +const gp = oak.globalParams("0x..."); 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); -``` - -#### 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, -} 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; +const [platformCount, goalAmount] = await oak.multicall([ + () => gp.getNumberOfListedPlatforms(), + () => ci.getGoalAmount(), +]); ``` -> For complete details on contract events, please visit the following link: [Events](https://oaknetwork.org/docs/contracts-sdk/events). +> Full multicall documentation: [Multicall](https://oaknetwork.org/docs/contracts-sdk/multicall) --- -## 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: - -```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 - 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("Unknown error:", err.message); -} +## Metrics -try { - const txHash = await factory.createCampaign({ ... }); -} catch (err) { - handleError(err); -} -``` +Pre-built aggregation helpers for platform stats, campaign summaries, and treasury reports. Import from `@oaknetwork/contracts-sdk/metrics`. -> See the full error handling guidelines here: [Error handling](https://oaknetwork.org/docs/contracts-sdk/error-handling) +> 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, - parseEther, - formatEther, - parseUnits, - isAddress, - getAddress, - getCurrentTimestamp, - addDays, - getChainFromId, - multicall, - 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) --- @@ -884,60 +348,13 @@ 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 - -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 @@ -994,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 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/client.test.ts b/packages/contracts/__tests__/unit/client.test.ts index 8f6b5944..81d6cb96 100644 --- a/packages/contracts/__tests__/unit/client.test.ts +++ b/packages/contracts/__tests__/unit/client.test.ts @@ -137,6 +137,58 @@ 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, + }); + const notFoundError = new Error("Transaction receipt not found"); + notFoundError.name = "TransactionReceiptNotFoundError"; + ( + client.publicClient as unknown as { getTransactionReceipt: jest.Mock } + ).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, @@ -173,4 +225,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..c9136373 100644 --- a/packages/contracts/__tests__/unit/contract-entities.test.ts +++ b/packages/contracts/__tests__/unit/contract-entities.test.ts @@ -5,17 +5,23 @@ */ 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; 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 { return { readContract: jest.fn().mockResolvedValue(0n), - simulateContract: jest.fn().mockResolvedValue({ result: undefined }), + simulateContract: jest.fn().mockResolvedValue({ + result: undefined, + request: { address: ADDR, abi: MOCK_ABI, functionName: "mock", args: [], value: 0n, gas: 21000n }, + }), getContractEvents: jest.fn().mockResolvedValue([]), watchContractEvent: jest.fn().mockImplementation((_args: WatchContractEventArgs) => () => {}), } as unknown as PublicClient; @@ -54,6 +60,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(); }); }); @@ -116,6 +123,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(() => {}); }); @@ -130,6 +140,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({ @@ -323,6 +336,18 @@ 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 Bytes4); }); + it("getApproved", async () => { await entity.getApproved(0n); }); + it("isApprovedForAll", async () => { await entity.isApprovedForAll(ADDR, ADDR); }); }); describe("writes", () => { @@ -340,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", () => { @@ -357,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", () => { @@ -368,6 +397,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(() => {}); }); @@ -376,6 +409,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}`; @@ -427,6 +464,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", () => { @@ -476,6 +514,9 @@ 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("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(() => {}); }); @@ -486,6 +527,9 @@ 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("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}` }); @@ -533,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"); }); }); describe("writes", () => { @@ -554,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", () => { @@ -572,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", () => { @@ -588,10 +614,8 @@ 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("getTransferLogs", async () => { await entity.events.getTransferLogs(); }); + it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); 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(() => {}); }); @@ -600,10 +624,8 @@ describe("AllOrNothing entity", () => { it("watchRewardRemoved", () => { entity.events.watchRewardRemoved(() => {}); }); it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); - it("watchTransfer", () => { entity.events.watchTransfer(() => {}); }); + it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); 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}` }); @@ -658,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"); }); }); describe("writes", () => { @@ -694,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", () => { @@ -726,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", () => { @@ -748,9 +754,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("getTransferLogs", async () => { await entity.events.getTransferLogs(); }); - it("getApprovalLogs", async () => { await entity.events.getApprovalLogs(); }); - it("getApprovalForAllLogs", async () => { await entity.events.getApprovalForAllLogs(); }); + it("getCancelledLogs", async () => { await entity.events.getCancelledLogs(); }); it("watchReceipt", () => { entity.events.watchReceipt(() => {}); expect(pub.watchContractEvent).toHaveBeenCalled(); }); it("watchRefundClaimed", () => { entity.events.watchRefundClaimed(() => {}); }); it("watchWithdrawalWithFeeSuccessful", () => { entity.events.watchWithdrawalWithFeeSuccessful(() => {}); }); @@ -766,9 +770,7 @@ describe("KeepWhatsRaised entity", () => { it("watchPaymentGatewayFeeSet", () => { entity.events.watchPaymentGatewayFeeSet(() => {}); }); it("watchPaused", () => { entity.events.watchPaused(() => {}); }); it("watchUnpaused", () => { entity.events.watchUnpaused(() => {}); }); - it("watchTransfer", () => { entity.events.watchTransfer(() => {}); }); - it("watchApproval", () => { entity.events.watchApproval(() => {}); }); - it("watchApprovalForAll", () => { entity.events.watchApprovalForAll(() => {}); }); + it("watchCancelled", () => { entity.events.watchCancelled(() => {}); }); it("decodeLog decodes a WithdrawalApproved event", () => { const sig = keccak256(toHex("WithdrawalApproved()")); const result = entity.events.decodeLog({ topics: [sig], data: "0x" as `0x${string}` }); diff --git a/packages/contracts/__tests__/unit/error-parsing.test.ts b/packages/contracts/__tests__/unit/error-parsing.test.ts index 36004319..b5150ba3 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"; @@ -101,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(); }); @@ -150,8 +186,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 +206,60 @@ describe("simulateWithErrorDecode", () => { }); }); +describe("toSimulationResult", () => { + 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: true, + request: { + address: "0x0000000000000000000000000000000000000001" as `0x${string}`, + abi: TEST_ABI, + functionName: "transfer", + args: ["0x0000000000000000000000000000000000000002", 100n] as const, + value: 0n, + gas: 21000n, + }, + }; + const mapped = toSimulationResult(response); + expect(mapped.result).toBe(true); + expect(mapped.request.to).toBe("0x0000000000000000000000000000000000000001"); + 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); + }); + + it("handles undefined value and gas", () => { + const response = { + result: undefined, + request: { + address: "0x0000000000000000000000000000000000000001" as `0x${string}`, + abi: TEST_ABI, + functionName: "transfer", + args: ["0x0000000000000000000000000000000000000002", 1n] as const, + }, + }; + 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(); + }); +}); + describe("parseContractError", () => { it("returns null for empty string", () => { expect(parseContractError("")).toBeNull(); @@ -586,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(); }); 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", () => { diff --git a/packages/contracts/__tests__/unit/metrics.test.ts b/packages/contracts/__tests__/unit/metrics.test.ts index dd1d6055..df7eb585 100644 --- a/packages/contracts/__tests__/unit/metrics.test.ts +++ b/packages/contracts/__tests__/unit/metrics.test.ts @@ -204,3 +204,4 @@ describe("getTreasuryReport", () => { expect(report.cancelled).toBe(true); }); }); + diff --git a/packages/contracts/__tests__/unit/prepare.test.ts b/packages/contracts/__tests__/unit/prepare.test.ts new file mode 100644 index 00000000..cc755549 --- /dev/null +++ b/packages/contracts/__tests__/unit/prepare.test.ts @@ -0,0 +1,150 @@ +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("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), + } 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); + }); + + 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", () => { + 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/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/); 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", diff --git a/packages/contracts/src/client/create.ts b/packages/contracts/src/client/create.ts index 2ac3232b..0607006b 100644 --- a/packages/contracts/src/client/create.ts +++ b/packages/contracts/src/client/create.ts @@ -58,12 +58,32 @@ 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 (error: unknown) { + if (error instanceof Error && error.name === "TransactionReceiptNotFoundError") { + return null; + } + throw error; + } + } + 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 — diff --git a/packages/contracts/src/constants/events.ts b/packages/contracts/src/constants/events.ts new file mode 100644 index 00000000..d1bd0fa3 --- /dev/null +++ b/packages/contracts/src/constants/events.ts @@ -0,0 +1,121 @@ +/** + * 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 = { + Cancelled: "Cancelled", + ContractURIUpdated: "ContractURIUpdated", + DeadlineUpdated: "CampaignInfoDeadlineUpdated", + GoalAmountUpdated: "CampaignInfoGoalAmountUpdated", + ImageURIUpdated: "ImageURIUpdated", + LaunchTimeUpdated: "CampaignInfoLaunchTimeUpdated", + OwnershipTransferred: "OwnershipTransferred", + Paused: "Paused", + PlatformInfoUpdated: "CampaignInfoPlatformInfoUpdated", + PledgeNFTMinted: "PledgeNFTMinted", + SelectedPlatformUpdated: "CampaignInfoSelectedPlatformUpdated", + 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 = { + Cancelled: "Cancelled", + FeesDisbursed: "FeesDisbursed", + Paused: "Paused", + Receipt: "Receipt", + RefundClaimed: "RefundClaimed", + RewardsAdded: "RewardsAdded", + RewardRemoved: "RewardRemoved", + SuccessConditionNotFulfilled: "SuccessConditionNotFulfilled", + Unpaused: "Unpaused", + WithdrawalSuccessful: "WithdrawalSuccessful", +} as const; + +/** Event names emitted by the KeepWhatsRaised treasury contract. */ +export const KEEP_WHATS_RAISED_EVENTS = { + Cancelled: "Cancelled", + DeadlineUpdated: "KeepWhatsRaisedDeadlineUpdated", + FeesDisbursed: "FeesDisbursed", + FundClaimed: "FundClaimed", + GoalAmountUpdated: "KeepWhatsRaisedGoalAmountUpdated", + Paused: "Paused", + PaymentGatewayFeeSet: "KeepWhatsRaisedPaymentGatewayFeeSet", + Receipt: "Receipt", + RefundClaimed: "RefundClaimed", + RewardsAdded: "RewardsAdded", + RewardRemoved: "RewardRemoved", + TipClaimed: "TipClaimed", + TreasuryConfigured: "TreasuryConfigured", + Unpaused: "Unpaused", + WithdrawalApproved: "WithdrawalApproved", + WithdrawalWithFeeSuccessful: "WithdrawalWithFeeSuccessful", +} as const; + +/** Event names emitted by the PaymentTreasury contract. */ +export const PAYMENT_TREASURY_EVENTS = { + Cancelled: "Cancelled", + 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; + +/** 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"; diff --git a/packages/contracts/src/contracts/all-or-nothing/abi.ts b/packages/contracts/src/contracts/all-or-nothing/abi.ts index 5861c7e2..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: [ @@ -141,22 +121,26 @@ export const ALL_OR_NOTHING_ABI = [ { 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" }, + { indexed: false, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "message", type: "bytes32" }, ], - name: "Transfer", + name: "Unpaused", type: "event", }, { anonymous: false, inputs: [ - { indexed: false, internalType: "address", name: "account", type: "address" }, - { indexed: false, internalType: "bytes32", name: "message", type: "bytes32" }, + { indexed: true, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "reason", type: "bytes32" }, ], - name: "Unpaused", + 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: [ @@ -221,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", @@ -259,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", @@ -315,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", @@ -376,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 87f690d5..e821a760 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) => @@ -106,18 +106,12 @@ export function createAllOrNothingEvents( async getUnpausedLogs(options) { return fetchEventLogs(publicClient, address, "Unpaused", options); }, - async getTransferLogs(options) { - return fetchEventLogs(publicClient, address, "Transfer", options); + async getCancelledLogs(options) { + return fetchEventLogs(publicClient, address, "Cancelled", 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 }); }, @@ -145,17 +139,11 @@ export function createAllOrNothingEvents( watchUnpaused(onLogs) { return createWatcher(publicClient, address, "Unpaused", onLogs); }, - watchTransfer(onLogs) { - return createWatcher(publicClient, address, "Transfer", onLogs); + watchCancelled(onLogs) { + return createWatcher(publicClient, address, "Cancelled", 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 9e7acfa8..e628bab1 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() { @@ -41,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: Hex) { - 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 762f1286..90ea2651 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,66 +153,7 @@ export function createAllOrNothingSimulate( args: [], }), ); - }, - async burn(tokenId: bigint, options?: CallSignerOptions): Promise { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...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); - await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...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); - await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...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); - await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...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); - 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 9e63cf66..c300ada9 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. */ @@ -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: Hex): Promise; } /** Write methods for an AllOrNothing treasury contract instance. */ @@ -61,50 +45,30 @@ 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. */ 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; } /** Event helpers for an AllOrNothing treasury contract instance. */ @@ -125,14 +89,10 @@ export interface AllOrNothingEvents { getPausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Unpaused event logs. */ getUnpausedLogs(options?: EventFilterOptions): Promise; - /** Returns decoded Transfer event logs. */ - getTransferLogs(options?: EventFilterOptions): Promise; + /** Returns decoded Cancelled event logs. */ + getCancelledLogs(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. */ @@ -151,14 +111,10 @@ export interface AllOrNothingEvents { watchPaused(onLogs: EventWatchHandler): () => void; /** Watches for Unpaused events in real time. Returns an unwatch function. */ watchUnpaused(onLogs: EventWatchHandler): () => void; - /** Watches for Transfer events in real time. Returns an unwatch function. */ - watchTransfer(onLogs: EventWatchHandler): () => void; + /** Watches for Cancelled events in real time. Returns an unwatch function. */ + watchCancelled(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-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-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/abi.ts b/packages/contracts/src/contracts/campaign-info/abi.ts index c79aa1dd..7f3287ab 100644 --- a/packages/contracts/src/contracts/campaign-info/abi.ts +++ b/packages/contracts/src/contracts/campaign-info/abi.ts @@ -115,6 +115,172 @@ 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: "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 + // 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", diff --git a/packages/contracts/src/contracts/campaign-info/events.ts b/packages/contracts/src/contracts/campaign-info/events.ts index 8620bbb9..84917afc 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 })); } @@ -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..f520e139 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 { Bytes4, LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; /** * Builds read methods for a CampaignInfo contract instance. @@ -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 { @@ -108,5 +111,42 @@ 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: 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 55ae20f9..c197d893 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,33 @@ export function createCampaignInfoSimulate( args: [], }), ); + 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 32c0bd8a..c4240809 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 { Bytes4, LineItemTypeInfo, CampaignConfig, PledgeData } from "../../types/structs"; +import type { DecodedEventLog, EventFilterOptions, EventWatchHandler, RawLog, SimulationResult } from "../../types/events"; import type { CallSignerOptions } from "../../client/types"; /** Read-only methods for a CampaignInfo contract instance. */ @@ -65,6 +65,30 @@ 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: 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. */ @@ -97,38 +121,46 @@ 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. */ 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; + /** 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. */ @@ -149,6 +181,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 +207,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/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/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..b85de10f 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 })); } @@ -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..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) { @@ -56,6 +59,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/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..aecc9ef2 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. */ @@ -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
; } @@ -73,38 +75,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 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 on success, throws a typed error on revert. */ + delistPlatform(platformBytes: Hex, options?: CallSignerOptions): Promise; + /** 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 on success, throws a typed error on revert. */ + updatePlatformClaimDelay(platformBytes: Hex, claimDelay: bigint, options?: CallSignerOptions): Promise; + /** Simulates updateProtocolAdminAddress; returns a SimulationResult on success, throws a typed error on revert. */ + updateProtocolAdminAddress(protocolAdminAddress: Address, options?: CallSignerOptions): Promise; + /** Simulates updateProtocolFeePercent; returns a SimulationResult on success, throws a typed error on revert. */ + updateProtocolFeePercent(protocolFeePercent: bigint, options?: CallSignerOptions): Promise; + /** 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 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 on success, throws a typed error on revert. */ + removePlatformLineItemType(platformHash: Hex, typeId: Hex, options?: CallSignerOptions): Promise; + /** 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 on success, throws a typed error on revert. */ + removeTokenFromCurrency(currency: Hex, token: Address, options?: CallSignerOptions): Promise; + /** 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 on success, throws a typed error on revert. */ + removePlatformData(platformBytes: Hex, platformDataKey: Hex, options?: CallSignerOptions): Promise; + /** 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 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 GlobalParams contract instance. */ @@ -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. */ 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"; 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/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/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/abi.ts b/packages/contracts/src/contracts/keep-whats-raised/abi.ts index 99394ef5..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: [ @@ -258,22 +238,26 @@ export const KEEP_WHATS_RAISED_ABI = [ { 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" }, + { indexed: false, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "message", type: "bytes32" }, ], - name: "Transfer", + name: "Unpaused", type: "event", }, { anonymous: false, inputs: [ - { indexed: false, internalType: "address", name: "account", type: "address" }, - { indexed: false, internalType: "bytes32", name: "message", type: "bytes32" }, + { indexed: true, internalType: "address", name: "account", type: "address" }, + { indexed: false, internalType: "bytes32", name: "reason", type: "bytes32" }, ], - name: "Unpaused", + 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" }, @@ -328,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", @@ -359,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", @@ -557,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", @@ -621,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 5b65a491..f7458c57 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 })); } @@ -94,14 +94,8 @@ export function createKeepWhatsRaisedEvents( async getUnpausedLogs(options) { return fetchEventLogs(publicClient, address, "Unpaused", 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); + async getCancelledLogs(options) { + return fetchEventLogs(publicClient, address, "Cancelled", options); }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); @@ -151,14 +145,8 @@ export function createKeepWhatsRaisedEvents( watchUnpaused(onLogs) { return createWatcher(publicClient, address, "Unpaused", 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); + watchCancelled(onLogs) { + return createWatcher(publicClient, address, "Cancelled", onLogs); }, }; } diff --git a/packages/contracts/src/contracts/keep-whats-raised/reads.ts b/packages/contracts/src/contracts/keep-whats-raised/reads.ts index 34e97bb5..41666800 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() { @@ -74,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: Hex) { - return publicClient.readContract({ - ...contract, - functionName: "supportsInterface", - args: [interfaceId], - }); - }, }; } diff --git a/packages/contracts/src/contracts/keep-whats-raised/simulate.ts b/packages/contracts/src/contracts/keep-whats-raised/simulate.ts index 47696f77..f79b7f6d 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,54 +325,7 @@ export function createKeepWhatsRaisedSimulate( args: [goalAmount], }), ); - }, - async approve(to: Address, tokenId: bigint, options?: CallSignerOptions) { - const signer = requireSigner(options?.signer ?? walletClient); const account = requireAccount(signer); - await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...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); - await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...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); - await simulateWithErrorDecode(() => - publicClient.simulateContract({ - ...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); - 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 cf395ac2..65bb5943 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. */ @@ -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: Hex): Promise; } /** Write methods for KeepWhatsRaised treasury. */ @@ -122,41 +106,33 @@ 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. */ 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 +143,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 +152,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 +161,21 @@ 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; } /** Event helpers for KeepWhatsRaised. */ @@ -242,12 +210,8 @@ export interface KeepWhatsRaisedEvents { getPausedLogs(options?: EventFilterOptions): Promise; /** Returns decoded Unpaused event logs. */ getUnpausedLogs(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; + /** Returns decoded Cancelled event logs. */ + getCancelledLogs(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. */ @@ -280,12 +244,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 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; + /** Watches for Cancelled events in real time. Returns an unwatch function. */ + watchCancelled(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], - }); - }, }; } diff --git a/packages/contracts/src/contracts/payment-treasury/abi.ts b/packages/contracts/src/contracts/payment-treasury/abi.ts index d127b5b8..3a089860 100644 --- a/packages/contracts/src/contracts/payment-treasury/abi.ts +++ b/packages/contracts/src/contracts/payment-treasury/abi.ts @@ -452,6 +452,45 @@ 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", + }, + { + 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", + 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..85913e28 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 })); } @@ -79,6 +79,15 @@ 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); + }, + async getCancelledLogs(options) { + return fetchEventLogs(publicClient, address, "Cancelled", options); + }, decodeLog(log) { return decode({ topics: [...log.topics] as Hex[], data: log.data }); }, @@ -112,5 +121,14 @@ 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); + }, + watchCancelled(onLogs) { + return createWatcher(publicClient, address, "Cancelled", onLogs); + }, }; } diff --git a/packages/contracts/src/contracts/payment-treasury/reads.ts b/packages/contracts/src/contracts/payment-treasury/reads.ts index 72b2e278..5379f2ba 100644 --- a/packages/contracts/src/contracts/payment-treasury/reads.ts +++ b/packages/contracts/src/contracts/payment-treasury/reads.ts @@ -43,10 +43,16 @@ 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() { return publicClient.readContract({ ...contract, functionName: "cancelled" }); }, + async paused() { + return publicClient.readContract({ ...contract, functionName: "paused" }); + }, }; } 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..d1d5ea16 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. */ @@ -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. */ @@ -90,7 +92,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 +103,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 +115,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 +126,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. */ @@ -173,6 +175,12 @@ 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; + /** 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. */ @@ -195,6 +203,12 @@ 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; + /** Watches for Cancelled events in real time. Returns an unwatch function. */ + watchCancelled(onLogs: EventWatchHandler): () => void; } /** 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/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. */ 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 440146a4..1f540d42 100644 --- a/packages/contracts/src/errors/index.ts +++ b/packages/contracts/src/errors/index.ts @@ -3,9 +3,11 @@ export { parseContractError, getRevertData, simulateWithErrorDecode, + toSimulationResult, } from "./parse-contract-error"; export { + GlobalParamsErrorNames, GlobalParamsCurrencyHasNoTokensError, GlobalParamsCurrencyTokenLengthMismatchError, GlobalParamsInvalidInputError, @@ -23,6 +25,7 @@ export { export type { GlobalParamsError } from "./contracts/global-params"; export { + CampaignInfoFactoryErrorNames, CampaignInfoFactoryCampaignInitializationFailedError, CampaignInfoFactoryCampaignWithSameIdentifierExistsError, CampaignInfoFactoryInvalidInputError, @@ -32,6 +35,7 @@ export { export type { CampaignInfoFactoryError } from "./contracts/campaign-info-factory"; export { + CampaignInfoErrorNames, CampaignInfoInvalidInputError, CampaignInfoInvalidPlatformUpdateError, CampaignInfoIsLockedError, @@ -42,6 +46,7 @@ export { export type { CampaignInfoError } from "./contracts/campaign-info"; export { + AllOrNothingErrorNames, AllOrNothingFeeAlreadyDisbursedError, AllOrNothingFeeNotDisbursedError, AllOrNothingInvalidInputError, @@ -56,6 +61,7 @@ export { export type { AllOrNothingError } from "./contracts/all-or-nothing"; export { + KeepWhatsRaisedErrorNames, KeepWhatsRaisedAlreadyClaimedError, KeepWhatsRaisedAlreadyEnabledError, KeepWhatsRaisedAlreadyWithdrawnError, @@ -74,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, @@ -101,6 +111,7 @@ export { export type { PaymentTreasuryError } from "./contracts/payment-treasury"; export { + TreasuryFactoryErrorNames, TreasuryFactoryImplementationNotSetError, TreasuryFactoryImplementationNotSetOrApprovedError, TreasuryFactoryInvalidAddressError, @@ -113,11 +124,19 @@ export { export type { TreasuryFactoryError } from "./contracts/treasury-factory"; 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-contract-error.ts b/packages/contracts/src/errors/parse-contract-error.ts index c0320da8..63702c55 100644 --- a/packages/contracts/src/errors/parse-contract-error.ts +++ b/packages/contracts/src/errors/parse-contract-error.ts @@ -1,6 +1,8 @@ -import type { Hex } from "../lib"; +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"; import { parseGlobalParamsError } from "./parse/global-params"; import { parseCampaignInfoFactoryError } from "./parse/campaign-info-factory"; import { parseCampaignInfoError } from "./parse/campaign-info"; @@ -74,15 +76,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 +95,43 @@ export async function simulateWithErrorDecode(operation: () => Promise) throw error; } } + +/** 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. + * + * 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: ViemSimulateRequest }): SimulationResult { + const { address, abi, functionName, args, value, gas } = response.request; + + const data = encodeFunctionData({ + abi, + functionName, + args: args as unknown[], + }); + + return { + result: response.result, + request: { + to: address, + data, + value, + gas, + }, + }; +} 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; + } } } 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/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..604354df --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/03-register-treasury-implementations.ts @@ -0,0 +1,65 @@ +/** + * 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, +); +await oak.waitForReceipt(txHash); +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); +// +// // 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/04-approve-implementations.ts b/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts new file mode 100644 index 00000000..630649ac --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/04-approve-implementations.ts @@ -0,0 +1,37 @@ +/** + * 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); +await oak.waitForReceipt(txHash); +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..06d3b318 --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/05-verify-setup.ts @@ -0,0 +1,116 @@ +/** + * 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. 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 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("Active implementations (NovaPay):", activeRegistrations.length); + +for (const reg of activeRegistrations) { + console.log( + ` Slot ${reg.args?.implementationId} → ${reg.args?.implementation}`, + ); +} + +// 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 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 = 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 activeRegistrations) { + const addr = (reg.args?.implementation as string)?.toLowerCase(); + if (latestApproval.get(addr) !== true) { + const status = latestApproval.has(addr) ? "disapproved" : "no approval event"; + console.error( + ` ✗ Slot ${reg.args?.implementationId} (${reg.args?.implementation}) — ${status}`, + ); + } + } +} + +// 3. Confirm enlistment event was emitted for NovaPay specifically +const enlistmentLogs = await globalParams.events.getPlatformEnlistedLogs(); +const novaPayLog = enlistmentLogs.find( + (log) => log.args?.platformBytes === platformHash, +); + +if (novaPayLog) { + console.log("\n=== Enlistment Confirmed ==="); + console.log("Event:", novaPayLog.eventName); + 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 new file mode 100644 index 00000000..750f882a --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/06-optional-configuration.ts @@ -0,0 +1,368 @@ +/** + * 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 (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. + */ + +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) + ); + await oak.waitForReceipt(tx1); + 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 + ); + await oak.waitForReceipt(tx2); + 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}`); + + // platformHash is used in the commented examples below (delist, update admin, etc.) + // const platformHash = keccak256(toHex("NOVAPAY")); + + // --- Currency management --- + // + // 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 usdcToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; + // const addTokenTx = await globalParams.addTokenToCurrency(usdCurrency, usdcToken); + // await oak.waitForReceipt(addTokenTx); + // 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, usdcToken); + // await oak.waitForReceipt(removeTokenTx); + // console.log("USDC 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..b8cf0c0a --- /dev/null +++ b/packages/contracts/src/examples/00-platform-enlistment/README.md @@ -0,0 +1,90 @@ +# 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 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. + +## 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`: + +| 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) | 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..fc89fadc --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/01-create-campaign.ts @@ -0,0 +1,97 @@ +/** + * 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 + * + * 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. + * + * 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 { + 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.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}`); + +// ── 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/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..c4a024e3 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/02-lookup-campaign.ts @@ -0,0 +1,30 @@ +/** + * 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 + * 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!, +}); + +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..1969ef86 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/04-deploy-treasury.ts @@ -0,0 +1,59 @@ +/** + * 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, TREASURY_FACTORY_EVENTS } 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}`); + +// 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 === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} + +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..c8b2bea3 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/06-backer-pledge.ts @@ -0,0 +1,86 @@ +/** + * 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. + * + * 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"; + +// --- 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 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 +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}`); + +// 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 --- +// +// 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 samCampaign = samOak.campaignInfo(campaignInfoAddress); + +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)"); + +// 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/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..a78691f5 --- /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 withdrawal is complete +const remaining = await treasury.getRaisedAmount(); +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 new file mode 100644 index 00000000..dd8d4230 --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/09b-failure-refund.ts @@ -0,0 +1,59 @@ +/** + * 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. + * + * Prerequisite: the backer must approve the treasury contract to + * 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: + * + * 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 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) { + 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. +// 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); +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..92c78976 --- /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 resumed, 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..f30938fc --- /dev/null +++ b/packages/contracts/src/examples/01-campaign-all-or-nothing/README.md @@ -0,0 +1,60 @@ +# 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. + +## 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 +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) | + 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..5ce0b068 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/01-create-campaign.ts @@ -0,0 +1,94 @@ +/** + * 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 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 + * whitelist only. This example uses one token for simplicity. + */ + +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.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", +}); + +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); + +// ── 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/02-deploy-treasury.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts new file mode 100644 index 00000000..43714ca6 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/02-deploy-treasury.ts @@ -0,0 +1,58 @@ +/** + * 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, TREASURY_FACTORY_EVENTS } 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); + +// 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 === TREASURY_FACTORY_EVENTS.TreasuryDeployed) { + treasuryAddress = decoded.args?.treasuryAddress as `0x${string}`; + break; + } + } catch { + // Log belongs to a different contract — skip + } +} + +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..747c185b --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/03-configure-treasury.ts @@ -0,0 +1,87 @@ +/** + * 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). 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 + * 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 + // 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, +}; + +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..aaa2858b --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/05-backer-pledge.ts @@ -0,0 +1,104 @@ +/** + * 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. + * + * 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"; + +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.USDC_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 --- +// 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 supporterTreasury.pledgeWithoutAReward( + supportPledgeId, + supporterAddress, + pledgeToken, + 50_000_000n, // $50 pledge amount + 0n, // no tip +); +await supporterOak.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-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/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/06c-final-withdrawal.ts b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06c-final-withdrawal.ts new file mode 100644 index 00000000..4be17deb --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/06c-final-withdrawal.ts @@ -0,0 +1,44 @@ +/** + * 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 (Steps 6a–6b), + * 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 8) 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.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 +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..a9e151c0 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/11-claim-refund.ts @@ -0,0 +1,55 @@ +/** + * 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. + * + * Prerequisite: the backer must approve the treasury contract to + * 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: + * + * - 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 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) { + 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. +// 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); +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..ce61ef1f --- /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 to 90 days from now +const now = getCurrentTimestamp(); +const newDeadline = addDays(now, 90); +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..cd96845a --- /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 resumed, 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..3299f8f0 --- /dev/null +++ b/packages/contracts/src/examples/02-campaign-keep-whats-raised/README.md @@ -0,0 +1,76 @@ +# 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) + +## 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 +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. **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 +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 + 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 | +| 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` | 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/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/03-create-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/03-create-payment.ts new file mode 100644 index 00000000..e25ba8d4 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/03-create-payment.ts @@ -0,0 +1,103 @@ +/** + * 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 + * 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 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 4 — 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. + * + * 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"; +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.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 + +const lineItems: LineItem[] = [ + { + typeId: keccak256(toHex("product")), // 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("product")), 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/04-process-crypto-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/04-process-crypto-payment.ts new file mode 100644 index 00000000..52ac1719 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/04-process-crypto-payment.ts @@ -0,0 +1,61 @@ +/** + * Step 4: Process a Crypto Payment (Buyer) — Independent On-Chain 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 + * `createPayment` call — they are two independent payment flows: + * + * - 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` 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"; +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.USDC_TOKEN_ADDRESS! as `0x${string}`; +const totalAmount = 135_000_000n; + +const lineItems: LineItem[] = [ + { typeId: keccak256(toHex("product")), 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/05-confirm-payment.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/05-confirm-payment.ts new file mode 100644 index 00000000..737369f8 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/05-confirm-payment.ts @@ -0,0 +1,44 @@ +/** + * 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, + * 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/06-read-payment-data.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/06-read-payment-data.ts new file mode 100644 index 00000000..8657e12a --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/06-read-payment-data.ts @@ -0,0 +1,66 @@ +/** + * 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. + * + * 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/07-handle-refunds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/07-handle-refunds.ts new file mode 100644 index 00000000..4c7a084a --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/07-handle-refunds.ts @@ -0,0 +1,122 @@ +/** + * 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: + * + * **A) Cancel an unconfirmed off-chain payment:** + * + * - `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. + * + * **B) Refund a confirmed off-chain payment (non-NFT):** + * + * - `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. + * + * **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. + */ + +import { createOakContractsClient, keccak256, toHex, CHAIN_IDS } from "@oaknetwork/contracts-sdk"; + +// ============================================================ +// A. Crypto payment refund (NFT Owner / Buyer) +// ============================================================ +// +// Steps 2–3 processed order-12345 as a crypto payment, which minted +// 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, + rpcUrl: process.env.RPC_URL!, + privateKey: process.env.SAM_PRIVATE_KEY! 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. Cancel an unconfirmed off-chain payment (Platform Admin) +// ============================================================ +// +// 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, +// 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 offchainPaymentId = keccak256(toHex("offchain-order-67890")); +// +// const cancelTxHash = await paymentTreasury.cancelPayment(offchainPaymentId); +// await oak.waitForReceipt(cancelTxHash); +// 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")); +// +// const refundTxHash = await paymentTreasury.claimRefund( +// confirmedPaymentId, +// process.env.SAM_ADDRESS! as `0x${string}`, +// ); +// await oak.waitForReceipt(refundTxHash); +// console.log("Refund sent to Sam's address"); diff --git a/packages/contracts/src/examples/03-campaign-payment-treasury/08-disburse-fees.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/08-disburse-fees.ts new file mode 100644 index 00000000..c8c59d29 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/08-disburse-fees.ts @@ -0,0 +1,36 @@ +/** + * 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 + * 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/09-withdraw-funds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/09-withdraw-funds.ts new file mode 100644 index 00000000..0d899eca --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/09-withdraw-funds.ts @@ -0,0 +1,41 @@ +/** + * Step 9: Withdraw Confirmed Funds (Platform Admin or Creator) + * + * 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. + * + * `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). + * + * 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/10-claim-expired-funds.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-expired-funds.ts new file mode 100644 index 00000000..f2e10f3a --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/10-claim-expired-funds.ts @@ -0,0 +1,40 @@ +/** + * 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 + * 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/11-claim-non-goal-line-items.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/11-claim-non-goal-line-items.ts new file mode 100644 index 00000000..9acde8c1 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/11-claim-non-goal-line-items.ts @@ -0,0 +1,32 @@ +/** + * 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 + * 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 usdcToken = process.env.USDC_TOKEN_ADDRESS! as `0x${string}`; + +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/12-pause-unpause-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/12-pause-unpause-treasury.ts new file mode 100644 index 00000000..c1e139f8 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/12-pause-unpause-treasury.ts @@ -0,0 +1,37 @@ +/** + * 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. + * + * `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/13-cancel-treasury.ts b/packages/contracts/src/examples/03-campaign-payment-treasury/13-cancel-treasury.ts new file mode 100644 index 00000000..5b530cf1 --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/13-cancel-treasury.ts @@ -0,0 +1,37 @@ +/** + * 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. + * + * 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..85370f1b --- /dev/null +++ b/packages/contracts/src/examples/03-campaign-payment-treasury/README.md @@ -0,0 +1,95 @@ +# 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 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 (off-chain path) or settles in one transaction (`processCryptoPayment`), and the funds become available for withdrawal after fees are disbursed. + +## 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 / 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: + +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. **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 + +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 `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 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. + +## 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-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 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 | +| 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) + +| Function | Who can call | Contract modifier | +| --- | --- | --- | +| `createPayment` / `createPaymentBatch` | Platform Admin | `onlyPlatformAdmin` | +| `processCryptoPayment` | Anyone (buyer) | (no role modifier) | +| `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` | +| `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) | 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..5d2cb374 --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/03-realtime-watchers.ts @@ -0,0 +1,61 @@ +/** + * 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 — 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 new file mode 100644 index 00000000..94b86304 --- /dev/null +++ b/packages/contracts/src/examples/04-event-monitoring/04-decode-raw-logs.ts @@ -0,0 +1,40 @@ +/** + * 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) { + try { + const decoded = factory.events.decodeLog({ + topics: log.topics, + data: log.data, + }); + + 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/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 | 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..e9a21871 --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/03-catch-typed-errors.ts @@ -0,0 +1,73 @@ +/** + * 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. + * + * Three patterns are shown: + * 1. Check for specific error types with instanceof + * 2. Match by error name using type-safe constants + * 3. Parse unknown revert data with parseContractError + */ + +import { + createOakContractsClient, + toHex, + CHAIN_IDS, + parseContractError, + getRevertData, + getRecoveryHint, +} from "@oaknetwork/contracts-sdk"; + +import { + CampaignInfoUnauthorizedError, + CampaignInfoErrorNames, + SharedErrorNames, +} 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 with instanceof + if (error instanceof CampaignInfoUnauthorizedError) { + console.error("You are not the campaign owner."); + console.error("Hint:", error.recoveryHint); + } else { + // Pattern 2: Parse revert data and match by error name constant + const revertData = getRevertData(error); + const parsed = revertData ? parseContractError(revertData) : null; + + if (parsed) { + 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 { + 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..aeaff2db --- /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, 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.startsWith("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..098247fb --- /dev/null +++ b/packages/contracts/src/examples/05-error-handling/05-safe-transaction-pattern.ts @@ -0,0 +1,61 @@ +/** + * 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, + 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 | 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..14979e4e --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/02-per-entity-signer.ts @@ -0,0 +1,35 @@ +/** + * 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 }); + +// 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..02115bd5 --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/03-per-call-signer.ts @@ -0,0 +1,39 @@ +/** + * 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 — 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 (safe now — fees are confirmed on-chain) +const withdrawTxHash = await treasury.withdraw({ signer: creatorSigner }); +await oak.waitForReceipt(withdrawTxHash); 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..2fff7017 --- /dev/null +++ b/packages/contracts/src/examples/06-advanced-patterns/04-item-registry.ts @@ -0,0 +1,76 @@ +/** + * 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 — 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, +); +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 | diff --git a/packages/contracts/src/examples/README.md b/packages/contracts/src/examples/README.md new file mode 100644 index 00000000..14500e60 --- /dev/null +++ b/packages/contracts/src/examples/README.md @@ -0,0 +1,172 @@ +# 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. + +--- + +## 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 + +``` +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-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 +│ ├── 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-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 +│ ├── 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 | 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}`; +} 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, diff --git a/packages/contracts/src/types/events.ts b/packages/contracts/src/types/events.ts index a42527bf..fcbaef37 100644 --- a/packages/contracts/src/types/events.ts +++ b/packages/contracts/src/types/events.ts @@ -1,10 +1,34 @@ -import type { Hex } from "../lib"; +import type { Address, 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; } @@ -26,3 +50,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; + }; +} diff --git a/packages/contracts/src/types/structs.ts b/packages/contracts/src/types/structs.ts index 939bf383..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. */ @@ -152,6 +162,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. */ diff --git a/packages/contracts/src/use-cases/README.md b/packages/contracts/src/use-cases/README.md new file mode 100644 index 00000000..a74e4261 --- /dev/null +++ b/packages/contracts/src/use-cases/README.md @@ -0,0 +1,142 @@ +# 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) | 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 | + +## 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: + +### CampaignInfoFactory + PaymentTreasury + +Best for: **escrow**, **marketplace**, **service payments** + +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 + +### CampaignInfoFactory + TimeConstrainedPaymentTreasury + +Best for: **prepayments**, **deposits**, **time-bound commitments** + +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 + +### 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 + +## 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 +// 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 + +Fees are always disbursed before withdrawal: + +```typescript +await treasury.disburseFees(); // protocol + platform fees distributed +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: + +```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..bcb12ea3 --- /dev/null +++ b/packages/contracts/src/use-cases/crowdfunding/creative-campaign.md @@ -0,0 +1,460 @@ +# 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 | `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.) | + +## Integration Flow + +### Step 1: Creator submits campaign — create on-chain + +> **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, +} 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 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 +// 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. + +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. Pledge NFTs live on the **CampaignInfo** contract, so `approve` is called on the CampaignInfo entity: + +```typescript +// 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); +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 — **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) +// 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); +``` + +## Architecture Diagram + +``` +Creator (Maya) ArtFund (Platform Admin) Blockchain + | | | + | createCampaign(...) | | + | [Any caller] | | + |---------------------------------------------------->| CampaignInfo deployed + | | | + | | deploy(platformHash, | + | | campaignInfo, 0) | + | |--------------------------->| AllOrNothing treasury deployed + | | | + | addRewards(...) | | + | [Campaign Owner] | | + |---------------------------------------------------->| Reward tiers registered + | | | + | getReward() / | | + | removeReward() | | + | [Anyone / Owner] | | + |---------------------------------------------------->| 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] | pauseTreasury() / | + | | unpauseTreasury() | + | |--------------------------->| Treasury paused/unpaused + | | | + | [Platform Admin or | cancelTreasury() | + | Campaign Owner] | | + | |--------------------------->| Treasury cancelled + | | | + | --- 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..bc4dfca2 --- /dev/null +++ b/packages/contracts/src/use-cases/escrow/healthcare-escrow.md @@ -0,0 +1,465 @@ +# 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 + +| 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 + +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`, `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.) | + +## Integration Flow + +### Step 1: Create a CampaignInfo contract + +> **Role: Any caller** — `createCampaign` is permissionless. + +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, 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.PLATFORM_PRIVATE_KEY as `0x${string}`, +}); + +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", +}); + +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 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). + +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. + +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"; + +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 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`) + +> **Role: Any caller** — `processCryptoPayment` is permissionless, but the buyer must first approve the treasury to transfer their ERC-20 tokens. + +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"; + +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); +``` + +### Step 4: 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 5: 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 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). + +The settled amount (minus fees) is sent to the campaign owner. + +```typescript +const txHash = await treasury.withdraw(); +await oak.waitForReceipt(txHash); +``` + +### Alternative: Cancellation and refund flows + +> Three distinct paths exist depending on payment state and type: + +**A) Cancel an unconfirmed off-chain payment (before `confirmPayment`):** + +> **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); +``` + +**B) Refund a confirmed off-chain payment (non-NFT):** + +> **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); +``` + +**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 +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); +await campaign.approve(TREASURY_ADDRESS, tokenId); + +await treasury.claimRefundSelf(paymentId); +``` + +### Step 7: 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 8: 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 | | + |------------------------------->| | + | | | + | --- FLOW A: Off-chain / fiat payment --- | + | | | + | | createPayment(...) | + | | [Platform Admin] | + | |------------------------------>| 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() | | + |--------------------------------------------------------------->| Treasury approved to spend + | | | + | | processCryptoPayment(...) | + | | [Any caller] | + | |------------------------------>| Pull tokens, already confirmed + NFT + | | | + | --- Both flows (after Flow A confirm or Flow B) --- | + | | | + | | 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 +- **`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 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 +- **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..85ec1579 --- /dev/null +++ b/packages/contracts/src/use-cases/flexible-funding/community-project.md @@ -0,0 +1,415 @@ +# 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 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? + +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 + +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 }); + +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); +``` + +**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( + pledgeId, + 2_500000n, // $2.50 USDC gateway fee (6 decimals) +); +``` + +**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 }); + +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); +``` + +### 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); +``` + +**Creator executes the partial amount** (only after **`withdrawalDelay`** seconds since approval, unless the delay is `0` in `configureTreasury` for a walkthrough): + +```typescript +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. The `claimTip` function transfers accumulated tips to the **platform tip recipient** (configured during platform enlistment). + +```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). + +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 (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); +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 + | | | + | createCampaign(...) | | + | [Any caller] | | + |------------------------------------------------------->| Campaign created + | | deploy(hash, info, 1) | + | |------------------------------>| KWR treasury deployed + | | configureTreasury(...) | + | |------------------------------>| Delays, fees configured + | addRewards(...) | | + | [Campaign Owner] | | + |------------------------------------------------------->| Reward tiers set + | | | +Backers pledge + tip | | + | pledgeForAReward() | | + |----------------------->|------------------------------>| NFT minted, funds + tip locked + | | setPaymentGatewayFee() | + | | [Platform Admin] | + | |------------------------------>| 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..34960ede --- /dev/null +++ b/packages/contracts/src/use-cases/marketplace/ecommerce-marketplace.md @@ -0,0 +1,424 @@ +# 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 +- **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 | +|----------|---------| +| **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 + +**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`, `claimRefundSelf(paymentId)` (NFT payments) | +| **Protocol Admin** | Oak protocol | Receives protocol fees (via `disburseFees`) | +| **Any caller** | Anyone | `disburseFees`, all read functions (`getPaymentData`, `getRaisedAmount`, `paused`, etc.) | + +## Integration Flow + +### Step 1: Create a CampaignInfo contract + +> **Role: Any caller** — `createCampaign` is permissionless. + +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, 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.PLATFORM_PRIVATE_KEY as `0x${string}`, +}); + +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 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. 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 }); +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) + { 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, itemId, USDC_TOKEN_ADDRESS, + totalAmount, expiration, lineItems, externalFees, +); + +const txHash = await treasury.createPayment( + orderId, buyerId, itemId, USDC_TOKEN_ADDRESS, + totalAmount, expiration, lineItems, externalFees, +); +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. + +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"; + +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, itemId, BUYER_ADDRESS, USDC_TOKEN_ADDRESS, + totalAmount, lineItems, externalFees, +); +await oak.waitForReceipt(txHash); +``` + +### Step 4: 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 5: 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 6: 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: Cancellation and refund flows + +> Three distinct paths exist depending on payment state and type: + +**A) Cancel an unconfirmed off-chain payment (before `confirmPayment`):** + +> **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); +``` + +**B) Refund a confirmed off-chain payment (non-NFT):** + +> **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); +``` + +**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 +const campaign = oak.campaignInfo(CAMPAIGN_INFO_ADDRESS); +await campaign.approve(TREASURY_ADDRESS, tokenId); + +await treasury.claimRefundSelf(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(); +``` + +## Architecture Diagram + +``` +Buyer (Alex) CeloMarket (Platform Admin) Blockchain + | | | + | Browse & order | | + |------------------------->| | + | | | + | --- FLOW A: Off-chain / fiat payment --- | + | | | + | | createPayment() | + | | [Platform Admin] | + | |------------------------------->| 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() | | + |------------------------------------------------------> | Treasury approved + | | | + | | processCryptoPayment() | + | | [Any caller] | + | |------------------------------->| Pull + confirmed + NFT + | | | + | --- Both flows (after Flow A confirm or Flow B) --- | + | | | + | | 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 +- **`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) +- **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 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 new file mode 100644 index 00000000..ba81ec7a --- /dev/null +++ b/packages/contracts/src/use-cases/prepayment/automotive-prepayment.md @@ -0,0 +1,425 @@ +# 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 + +| 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 + +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`, `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`, `claimRefundSelf`, `claimExpiredFunds`, `disburseFees`, `withdraw`, and `claimNonGoalLineItems` require the current time to be **after** `launchTime` (they use `_checkTimeIsGreater()`). + +## Integration Flow + +### Step 1: Create a CampaignInfo contract + +> **Role: Any caller** — `createCampaign` is permissionless. + +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, 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.KARMA_PLATFORM_KEY as `0x${string}`, +}); + +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. + +```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 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. + +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`). + +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 }); +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); +``` + +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. + +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 +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); +``` + +### 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); +// 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([ + () => treasury.getRaisedAmount(), + () => treasury.getAvailableRaisedAmount(), + () => treasury.getExpectedAmount(), +]); +``` + +### Step 5 (Success): Vehicle delivered — disburse and withdraw + +> **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 +const feeTx = await treasury.disburseFees(); +await oak.waitForReceipt(feeTx); + +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). 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); +``` + +**B) Refund a confirmed off-chain payment (non-NFT):** + +> **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 — NFT-backed crypto payment:** + +> **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); +``` + +### 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 | | + |------------------------>| | + | | | + | --- FLOW A: Off-chain / fiat payment --- | + | | | + | | createPayment(...) | + | | [Platform Admin, in window] | + | |------------------------------->| 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() | | + |-------------------------------------------------------->| Treasury approved + | | | + | | processCryptoPayment(...) | + | | [Any caller, in window] | + | |------------------------------->| Pull + confirmed + NFT + | | | + | --- Fees & withdraw (both flows) --- | + | | 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 | claimRefundSelf(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 +- **`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 +- **`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 +- **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` 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 c6c69b1b..9c03b256 100644 --- a/packages/contracts/src/utils/index.ts +++ b/packages/contracts/src/utils/index.ts @@ -3,8 +3,10 @@ * 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"; 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..3d5e0866 --- /dev/null +++ b/packages/contracts/src/utils/prepare.ts @@ -0,0 +1,117 @@ +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. + * + * @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 — must match a function in the ABI. */ + functionName: AbiWriteFunctionName; + /** 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. Undefined when the source did not include a gas estimate — callers should estimate separately before submitting. */ + 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 Parameters[0]); + + 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, + }; +}