Automotive Prepayment — Karma Automotive
See also: API Reference Examples — executable TypeScript walkthroughs.
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, andconfirmPaymentBatchmust be called while the current time is withinlaunchTime…deadline + bufferTime(perTimestampChecker).claimRefund,claimRefundSelf,claimExpiredFunds,disburseFees,withdraw, andclaimNonGoalLineItemsrequire the current time to be afterlaunchTime(they use_checkTimeIsGreater()).
Integration Flow
Step 1: Create a CampaignInfo contract
Role: Any caller —
createCampaignis permissionless.
Before deploying a TimeConstrainedPaymentTreasury, Karma needs a CampaignInfo contract. This holds NFT receipts for crypto payments and defines the accepted token list.
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 —
deployon 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.
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 (
launchTimetodeadline + 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.
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 —
confirmPaymentmust 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):
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 —
processCryptoPaymentis 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:
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:
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.
// 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(afterlaunchTime). Platform Admin or Campaign Owner forwithdraw(afterlaunchTime). For Flow A, you already calledconfirmPaymentunder Step 3 after delivery; for Flow B, the payment was confirmed whenprocessCryptoPaymentran—do not callconfirmPaymenthere.
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 aftercampaignDeadline + platformClaimDelay, and only afterlaunchTime(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.
// 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.
await treasury.cancelPayment(orderId);
B) Refund a confirmed off-chain payment (non-NFT):
Role: Platform Admin for
claimRefund(paymentId, refundAddress)(afterlaunchTime). This refunds a confirmed payment where no NFT was minted. The contract verifies the payment is confirmed and hastokenId == 0.
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 (afterlaunchTime). No priorcancelPaymentis needed — crypto payments cannot be cancelled viacancelPayment(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:
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.
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).
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.
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).
const txHash = await treasury.cancelTreasury(toHex("program-ended", { size: 32 }));
await oak.waitForReceipt(txHash);
Architecture Diagram
Karma Automotive Prepayment Flow
Key Takeaways
- ERC-20 approval is required — James must
approvethe treasury beforeprocessCryptoPaymentcan pull tokens createPaymentpath — fund the treasury beforeconfirmPayment(createPaymentdoes not pull from the buyer)processCryptoPaymentpath — do not callconfirmPaymentafterward; usedisburseFees/withdrawwhen appropriate- Multi-token — use any accepted
paymentTokenfor 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 afterlaunchTime - Same SDK interface as PaymentTreasury —
oak.paymentTreasury()works for both; behavior differs in the deployed contract bytecode claimExpiredFunds()is platform-admin-only and only afterdeadline + 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;
withdrawis platform admin or campaign owner;disburseFeesis permissionless - Three cancellation/refund paths —
cancelPaymentdeletes 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
claimNonGoalLineItemsbehave like PaymentTreasury but inherit the same time checks fromTimeConstrainedPaymentTreasury