Skip to main content

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

ContractPurpose
CampaignInfoFactoryCreates the CampaignInfo contract that holds NFT receipts and the accepted token list
TreasuryFactoryDeploys the TimeConstrainedPaymentTreasury clone linked to the CampaignInfo
TimeConstrainedPaymentTreasuryIdentical 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

RoleWhoOn-Chain Functions
Platform AdminKarma's ordering systemcreatePayment, createPaymentBatch, confirmPayment, confirmPaymentBatch, cancelPayment, claimRefund(paymentId, address) (non-NFT), claimExpiredFunds, claimNonGoalLineItems, pauseTreasury, unpauseTreasury, cancelTreasury
Platform Admin or Campaign OwnerKarma or dealerwithdraw, cancelTreasury
BuyerVehicle customerERC-20 approve, processCryptoPayment, claimRefundSelf(paymentId) (NFT payments)
Dealer (Campaign Owner)Karma dealershipReceives funds after withdraw
Protocol AdminOak protocolReceives protocol fees (via disburseFees)
Any callerAnyonedisburseFees, 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 launchTimedeadline + 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 callercreateCampaign is 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 callerdeploy 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.

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.

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 AdminconfirmPayment 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):

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 callerprocessCryptoPayment 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:

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 (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.

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.

// 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) (after launchTime). This refunds a confirmed payment where no NFT was minted. The contract verifies the payment is confirmed and has tokenId == 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 (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:

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 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 launchTimedeadline + 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 pathscancelPayment 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