lovable skill · lovable-ousd

The OUSD skill, in full.

How to build OUSD payments on Base, Ethereum, Solana and Tempo with Privy wallets, plus the fixes. Lovable uses it automatically on OUSD builds. Add it under Settings → Skills, then type / in chat to call it up.

Download .zip

Payments

Every pattern is: check balance → confirm → send → wait for confirmation → show explorer link.

PatternWhat happens
PayOne OUSD transfer from buyer to creator.
SplitOne payment, several sequential transfers by percentage. Show each with its own link; if one fails, show which shares are still owed.
TipSmall fixed amount (e.g. 0.10 OUSD) after an action.
ProvenanceThe confirmed tx hash is the proof. Save buyer, price, time and hash; render a certificate linking to the explorer.

Amounts

  • Convert with on-chain decimals: parseUnits("1.00", decimals).
  • Display 2 decimals. Default demo amounts ≤ 1 OUSD — this is real money.

Not enough OUSD

Show a "Get OUSD" button linking to joinopenstandard.com/integrate (Stripe / Bridge / Privy, Coinbase, Visa, Mastercard, or Coinbase / Kraken / Uniswap).

Wrong network

Refuse to send and show "Switch to <chain>". Never retry silently on another chain.

Reference code (generated)

These are the exact snippets every mega-prompt embeds.

Base

// src/lib/ousd.ts
import { base as CHAIN } from 'viem/chains';
export { CHAIN };
import { createPublicClient, http, erc20Abi, encodeFunctionData, parseUnits, formatUnits } from 'viem';
export const OUSD = '0xB2000000000000000000002fEb517dFeC7415344' as const;
export const pub = createPublicClient({ chain: CHAIN, transport: http('https://mainnet.base.org') });
let dec: number | undefined;
export async function ousdDecimals() { return dec ??= await pub.readContract({ address: OUSD, abi: erc20Abi, functionName: 'decimals' }); }
export async function ousdBalance(owner: `0x${string}`) {
  const raw = await pub.readContract({ address: OUSD, abi: erc20Abi, functionName: 'balanceOf', args: [owner] });
  return Number(formatUnits(raw, await ousdDecimals()));
}
export async function transferTx(to: `0x${string}`, amount: string) {
  const value = parseUnits(amount, await ousdDecimals());
  return { to: OUSD, value: 0n, chainId: CHAIN.id,
    data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [to, value] }) };
}
export const txUrl = (h: string) => 'https://basescan.org/tx/' + h;
export async function waitFor(hash: `0x${string}`) { return pub.waitForTransactionReceipt({ hash, timeout: 90_000 }); }

// src/components/privy-root.tsx  (default export; loaded with React.lazy inside <ClientOnly>)
import { PrivyProvider } from '@privy-io/react-auth';
import { CHAIN } from '@/lib/ousd';
export default function PrivyRoot({ appId, children }: { appId: string; children: React.ReactNode }) {
  return <PrivyProvider appId={appId} config={{ loginMethods: ['google'], defaultChain: CHAIN, supportedChains: [CHAIN],
    embeddedWallets: { ethereum: { createOnLogin: 'users-without-wallets' } } }}>{children}</PrivyProvider>;
}

// Sending (inside a component under PrivyRoot)
// const { sendTransaction } = useSendTransaction();       // from '@privy-io/react-auth'
// const { hash } = await sendTransaction(await transferTx(to, '1.00'), { sponsor: true });
// await waitFor(hash); show txUrl(hash).

Ethereum

// src/lib/ousd.ts
import { mainnet as CHAIN } from 'viem/chains';
export { CHAIN };
import { createPublicClient, http, erc20Abi, encodeFunctionData, parseUnits, formatUnits } from 'viem';
export const OUSD = '0x9f6F3991D525015a6F8CaF062C83b62fD3AC4436' as const;
export const pub = createPublicClient({ chain: CHAIN, transport: http('https://ethereum-rpc.publicnode.com') });
let dec: number | undefined;
export async function ousdDecimals() { return dec ??= await pub.readContract({ address: OUSD, abi: erc20Abi, functionName: 'decimals' }); }
export async function ousdBalance(owner: `0x${string}`) {
  const raw = await pub.readContract({ address: OUSD, abi: erc20Abi, functionName: 'balanceOf', args: [owner] });
  return Number(formatUnits(raw, await ousdDecimals()));
}
export async function transferTx(to: `0x${string}`, amount: string) {
  const value = parseUnits(amount, await ousdDecimals());
  return { to: OUSD, value: 0n, chainId: CHAIN.id,
    data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [to, value] }) };
}
export const txUrl = (h: string) => 'https://etherscan.io/tx/' + h;
export async function waitFor(hash: `0x${string}`) { return pub.waitForTransactionReceipt({ hash, timeout: 90_000 }); }

// src/components/privy-root.tsx  (default export; loaded with React.lazy inside <ClientOnly>)
import { PrivyProvider } from '@privy-io/react-auth';
import { CHAIN } from '@/lib/ousd';
export default function PrivyRoot({ appId, children }: { appId: string; children: React.ReactNode }) {
  return <PrivyProvider appId={appId} config={{ loginMethods: ['google'], defaultChain: CHAIN, supportedChains: [CHAIN],
    embeddedWallets: { ethereum: { createOnLogin: 'users-without-wallets' } } }}>{children}</PrivyProvider>;
}

// Sending (inside a component under PrivyRoot)
// const { sendTransaction } = useSendTransaction();       // from '@privy-io/react-auth'
// const { hash } = await sendTransaction(await transferTx(to, '1.00'), { sponsor: true });
// await waitFor(hash); show txUrl(hash).

Solana

// src/lib/ousd.ts
import { Connection, PublicKey, Transaction } from '@solana/web3.js';
import { getMint, getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction,
  createTransferCheckedInstruction, getAccount } from '@solana/spl-token';
export const MINT = new PublicKey('ousd2mJsPEckLHcSCDxyKD7NDGARZcfLbDZkKiatYHB');
export const conn = new Connection('https://api.mainnet-beta.solana.com', 'confirmed'); // swap for a proxied SOLANA_RPC_URL in production
let meta: { programId: PublicKey; decimals: number } | undefined;
export async function mintMeta() {
  if (meta) return meta;
  const info = await conn.getAccountInfo(MINT); const programId = info!.owner; // Token or Token-2022
  const m = await getMint(conn, MINT, 'confirmed', programId);
  return (meta = { programId, decimals: m.decimals });
}
export async function ousdBalance(owner: string) {
  const { programId, decimals } = await mintMeta();
  const ata = getAssociatedTokenAddressSync(MINT, new PublicKey(owner), false, programId);
  try { const a = await getAccount(conn, ata, 'confirmed', programId); return Number(a.amount) / 10 ** decimals; }
  catch { return 0; } // no token account yet
}
export async function transferTx(from: string, to: string, amount: number) {
  const { programId, decimals } = await mintMeta();
  const f = new PublicKey(from), t = new PublicKey(to);
  const src = getAssociatedTokenAddressSync(MINT, f, false, programId);
  const dst = getAssociatedTokenAddressSync(MINT, t, false, programId);
  const tx = new Transaction().add(
    createAssociatedTokenAccountIdempotentInstruction(f, dst, t, MINT, programId),
    createTransferCheckedInstruction(src, MINT, dst, f, BigInt(Math.round(amount * 10 ** decimals)), decimals, [], programId));
  tx.feePayer = f; tx.recentBlockhash = (await conn.getLatestBlockhash()).blockhash;
  return tx.serialize({ requireAllSignatures: false });
}
export const txUrl = (sig: string) => 'https://solscan.io/tx/' + sig;

// src/components/privy-root.tsx  (default export; loaded with React.lazy inside <ClientOnly>)
import { PrivyProvider } from '@privy-io/react-auth';
export default function PrivyRoot({ appId, children }: { appId: string; children: React.ReactNode }) {
  return <PrivyProvider appId={appId} config={{ loginMethods: ['google'],
    embeddedWallets: { solana: { createOnLogin: 'users-without-wallets' } } }}>{children}</PrivyProvider>;
}

// Sending: const { wallets } = useWallets() and const { signAndSendTransaction } = useSignAndSendTransaction()
// (both from '@privy-io/react-auth/solana'); const bytes = await transferTx(wallets[0].address, to, 1);
// const { signature } = await signAndSendTransaction({ transaction: bytes, wallet: wallets[0] });
// convert signature to base58 (bs58) if it is bytes, await conn.confirmTransaction, then show txUrl.

Tempo

// src/lib/ousd.ts
import { defineChain } from 'viem';
export const CHAIN = defineChain({ id: 4217, name: 'Tempo',
  nativeCurrency: { name: 'USD', symbol: 'USD', decimals: 6 },
  rpcUrls: { default: { http: ['https://rpc.tempo.xyz'] } } }); // confirm RPC at docs.tempo.xyz
import { createPublicClient, http, erc20Abi, encodeFunctionData, parseUnits, formatUnits } from 'viem';
export const OUSD = '0x20c0000000000000000000006a37DA5C996874BE' as const;
export const pub = createPublicClient({ chain: CHAIN, transport: http('https://rpc.tempo.xyz') });
let dec: number | undefined;
export async function ousdDecimals() { return dec ??= await pub.readContract({ address: OUSD, abi: erc20Abi, functionName: 'decimals' }); }
export async function ousdBalance(owner: `0x${string}`) {
  const raw = await pub.readContract({ address: OUSD, abi: erc20Abi, functionName: 'balanceOf', args: [owner] });
  return Number(formatUnits(raw, await ousdDecimals()));
}
export async function transferTx(to: `0x${string}`, amount: string) {
  const value = parseUnits(amount, await ousdDecimals());
  return { to: OUSD, value: 0n, chainId: CHAIN.id,
    data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [to, value] }) };
}
export const txUrl = (h: string) => 'https://explore.tempo.xyz/receipt/' + h;
export async function waitFor(hash: `0x${string}`) { return pub.waitForTransactionReceipt({ hash, timeout: 90_000 }); }

// src/components/privy-root.tsx  (default export; loaded with React.lazy inside <ClientOnly>)
import { PrivyProvider } from '@privy-io/react-auth';
import { CHAIN } from '@/lib/ousd';
export default function PrivyRoot({ appId, children }: { appId: string; children: React.ReactNode }) {
  return <PrivyProvider appId={appId} config={{ loginMethods: ['google'], defaultChain: CHAIN, supportedChains: [CHAIN],
    embeddedWallets: { ethereum: { createOnLogin: 'users-without-wallets' } } }}>{children}</PrivyProvider>;
}

// Sending (inside a component under PrivyRoot)
// const { sendTransaction } = useSendTransaction();       // from '@privy-io/react-auth'
// const { hash } = await sendTransaction(await transferTx(to, '1.00'), { sponsor: true });
// await waitFor(hash); show txUrl(hash).