TrueShot Ledger
Prove original photo ownership to combat unauthorized reuse and forgery.
NFT provenance mint· onchain authorship
Section · Onchain
full primer →The primitive.
Photographers mint each photo authenticity as an ERC-721 token on BOB Mainnet pointing at an IPFS CID, so authorship and timestamp are provable from a single BOB Explorer link.
Why this primitiveNFT provenance mint creates immutable proof of original photo creation linked to IPFS.
Kernel
an ERC-721 contract on BOB Sepolia that mints a creator-owned token pointing at an IPFS CID, verified on BOB Sepolia Explorer
Drives the UI as
a 'mint to claim authorship' button that returns the tokenId, owner address, and BOB Sepolia Explorer link
Required keys.
ROOTSTOCK_TESTNET_RPC_URL
Alchemy BOB Mainnet HTTPS endpoint. Create a free app → copy the HTTPS URL.
open ↗Add these in your Lovable project under Settings → Secrets before pasting the prompt below.
Appendix · Mega-prompt
The build prompt.
budget · 1 message
Paste into a fresh Lovable project. Make sure all five secrets above are set first. read the build strategy →
Build "TrueShot Ledger" in ONE Lovable message. Single-page demo. This prompt is self-contained: everything you need is below — do not ask follow-up questions.
CONCEPT
Prove original photo ownership to combat unauthorized reuse and forgery.
Discipline: Photography (photo authenticity).
Onchain primitive: NFT provenance mint. Why this primitive: NFT provenance mint creates immutable proof of original photo creation linked to IPFS.
5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page app. No router, no Lovable Cloud, no database, no auth flows beyond Privy drop-in.
- ONE Solidity contract, <=80 lines, deployed to BOB Sepolia, verified on BOB Sepolia Explorer (Blockscout).
- Privy is always the auth + sponsored-tx layer (Google login, embedded wallet).
- Pinata/IPFS only if the idea genuinely needs to store a file or metadata.
- At most ONE AI call per user action (use Lovable AI Gateway with LOVABLE_API_KEY if AI is part of the idea).
- Skip tests, skip CI, skip docs pages. Ship the demo, nothing else.
NETWORK — BOB Sepolia (testnet, everything below is required verbatim)
- chainId 808813 (hex 0xc57cd). BOB is an OP-Stack L2 secured by Bitcoin.
- Gas token is ETH. Never BTC, and never the BOB ERC-20 token — holding BOB
tokens on Ethereum L1 does not pay for gas on the BOB network.
- RPC: https://bob-sepolia.rpc.gobob.xyz
- Explorer: https://bob-sepolia.explorer.gobob.xyz (Blockscout, not Etherscan)
- Funding the deployer: claim Sepolia ETH at https://sepolia-faucet.pk910.de/
then bridge it to BOB Sepolia at https://bob-sepolia.gobob.xyz/
- Reference: https://docs.gobob.xyz/docs/user-hub/networks
- Create src/lib/chains.ts exactly like this:
```ts
import type { Chain } from "viem";
export const bobSepolia: Chain = {
id: 808813,
name: "BOB Sepolia",
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
rpcUrls: {
default: { http: ["https://bob-sepolia.rpc.gobob.xyz"] },
public: { http: ["https://bob-sepolia.rpc.gobob.xyz"] },
},
blockExplorers: {
default: { name: "BOB Sepolia Explorer", url: "https://bob-sepolia.explorer.gobob.xyz" },
},
testnet: true,
};
export const EXPLORER = "https://bob-sepolia.explorer.gobob.xyz";
```
FILE LAYOUT (create every one of these — nothing else)
```text
contracts/TrueShotLedger.sol the contract source below
hardhat.config.cjs Blockscout verify config below
scripts/deploy.cjs deploy script below
src/lib/chains.ts bobSepolia viem Chain (above)
src/lib/contract.ts ABI + address helper, viem public client
src/data/contract.json { "address": "0x…", "deployTx": "0x…" } written after deploy
src/components/privy-root.tsx ClientOnly + Suspense + lazy wrapper
src/components/privy-client-entry.tsx the ONLY file importing @privy-io/react-auth
src/pages/Index.tsx the entire demo UI (single page)
```
STACK
- React + Vite single page (the index route).
- SSR-safe Privy mount is mandatory. Never import @privy-io/react-auth at
module scope of a route file — it crashes SSR. Use
lazy(() => import('./privy-client-entry')) inside <ClientOnly> + <Suspense>,
and put <PrivyProvider> only inside privy-client-entry.tsx.
```tsx
// src/components/privy-root.tsx
import { lazy, Suspense, type ReactNode } from "react";
import { ClientOnly } from "@tanstack/react-router"; // or a { mounted } useEffect guard on plain Vite
const PrivyClientEntry = lazy(() => import("./privy-client-entry"));
export function PrivyRoot({ children }: { children: ReactNode }) {
return (
<ClientOnly fallback={<div className="p-6 text-sm opacity-60">Loading wallet…</div>}>
<Suspense fallback={<div className="p-6 text-sm opacity-60">Loading wallet…</div>}>
<PrivyClientEntry>{children}</PrivyClientEntry>
</Suspense>
</ClientOnly>
);
}
```
- PrivyProvider config — pass bobSepolia as `defaultChain` + `supportedChains`.
chainId is also passed per-call:
```tsx
// src/components/privy-client-entry.tsx
import { PrivyProvider } from "@privy-io/react-auth";
import { bobSepolia } from "@/lib/chains";
export default function PrivyClientEntry({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider
appId={import.meta.env.VITE_PRIVY_APP_ID}
config={{
loginMethods: ["google", "email"],
embeddedWallets: { ethereum: { createOnLogin: "users-without-wallets" } },
defaultChain: bobSepolia,
supportedChains: [bobSepolia],
appearance: { theme: "dark" },
}}
>
{children}
</PrivyProvider>
);
}
```
- Read the embedded wallet from useWallets, not user.wallet:
const embedded = wallets.find(w => w.walletClientType === 'privy');
- Every send goes through Privy `useSendTransaction` with BOTH `address`
and `sponsor`, wrapped in a 45s Promise.race timeout whose reject message
names the exact dashboard toggles:
await Promise.race([
sendTransaction(
{ to, data, chainId: 808813 },
{ address: embedded.address, sponsor: true }
),
new Promise((_, r) => setTimeout(() => r(new Error(
"Privy sendTransaction timed out after 45s. Enable Gas sponsorship -> App pays -> BOB Sepolia -> Allow transactions from the client."
)), 45_000)),
]);
- Do NOT pass uiOptions:{ showWalletUIs:false } — it aborts with
"signal is aborted without reason". The approval sheet still shows on
the embedded-EOA path; the fee reads US$0.00.
- Do NOT add ZeroDev / SmartWalletsProvider / a paymaster URL. Native
Privy sponsorship on BOB Sepolia works with the toggles above and nothing else.
- DASHBOARD PREREQUISITE (one-time): Privy dashboard -> Gas sponsorship
-> App pays -> add custom chain "BOB Sepolia" (chainId 808813, rpc
https://bob-sepolia.rpc.gobob.xyz) -> toggle "Allow transactions from
the client" ON. Without this, sendTransaction hangs silently.
- src/lib/pinata.ts uploads via `fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', { method:'POST', headers:{ Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` }, body: fd })`.
CONTRACT (contracts/TrueShotLedger.sol):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
/// @title TrueShotLedger
/// @notice ERC-721 provenance for: Prove original photo ownership to combat unauthorized reuse and forgery.
/// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
contract TrueShotLedger is ERC721 {
uint256 public nextId;
mapping(uint256 => string) public cidOf;
constructor() ERC721("TrueShotLedger", "TRUESH") {}
/// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
function mint(string calldata cid) external returns (uint256 id) {
id = ++nextId; cidOf[id] = cid; _safeMint(msg.sender, id);
}
function tokenURI(uint256 id) public view override returns (string memory) {
return string(abi.encodePacked("ipfs://", cidOf[id]));
}
}
```
DEPLOY + VERIFY
- Hardhat lives at the repo root with sources in /contracts (kept outside the
Vite bundle). Install `@nomicfoundation/hardhat-toolbox` AND
`@nomicfoundation/hardhat-verify@latest`.
- hardhat.config.cjs uses hardhat-verify's Blockscout customChain shape
(BOB Sepolia Explorer is Blockscout-based, not Etherscan):
```js
require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-verify");
module.exports = {
solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } },
networks: {
bobSepolia: {
url: process.env.BOB_SEPOLIA_RPC_URL || "https://bob-sepolia.rpc.gobob.xyz",
accounts: [process.env.METAMASK_PRIVATE_KEY.startsWith("0x")
? process.env.METAMASK_PRIVATE_KEY : "0x" + process.env.METAMASK_PRIVATE_KEY],
chainId: 808813,
},
},
etherscan: {
apiKey: { bobSepolia: "empty" }, // Blockscout ignores the key but the field is required
customChains: [{
network: "bobSepolia",
chainId: 808813,
urls: {
apiURL: "https://bob-sepolia.explorer.gobob.xyz/api",
browserURL: "https://bob-sepolia.explorer.gobob.xyz/",
},
}],
},
sourcify: { enabled: false },
};
```
```js
// scripts/deploy.cjs
const hre = require("hardhat");
const fs = require("fs");
async function main() {
const f = await hre.ethers.getContractFactory("TrueShotLedger");
const c = await f.deploy();
await c.waitForDeployment();
const address = await c.getAddress();
console.log("deployed:", address);
fs.writeFileSync("src/data/contract.json", JSON.stringify({
address,
deployTx: c.deploymentTransaction().hash,
chainId: 808813,
explorer: "https://bob-sepolia.explorer.gobob.xyz",
}, null, 2));
}
main().catch((e) => { console.error(e); process.exit(1); });
```
- Deploy: `npx hardhat run scripts/deploy.cjs --network bobSepolia`.
- Verify (run RIGHT AFTER deploy, no constructor args for these contracts):
`npx hardhat verify --network bobSepolia <address>`.
On success Blockscout returns "Successfully verified contract" and the
source becomes readable at
`https://bob-sepolia.explorer.gobob.xyz/address/<address>#code`.
Blockscout does NOT auto-verify — always run the command explicitly.
- Frontend reads: create a viem public client with the BOB Sepolia RPC —
`createPublicClient({ chain: bobSepolia, transport: http(import.meta.env.VITE_BOB_SEPOLIA_RPC_URL ?? "https://bob-sepolia.rpc.gobob.xyz") })`.
FRONTEND CODE (src/pages/Index.tsx — adapt copy, keep the choreography)
```tsx
import { useState } from "react";
import { usePrivy, useWallets, useSendTransaction } from "@privy-io/react-auth";
import { createPublicClient, encodeFunctionData, http } from "viem";
import { bobSepolia, EXPLORER } from "@/lib/chains";
import { ABI } from "@/lib/contract";
import contract from "@/data/contract.json";
const client = createPublicClient({ chain: bobSepolia, transport: http() });
export default function Index() {
const { ready, authenticated, login, logout } = usePrivy();
const { wallets } = useWallets();
const { sendTransaction } = useSendTransaction();
const embedded = wallets.find((w) => w.walletClientType === "privy");
const [input, setInput] = useState("");
const [status, setStatus] = useState<"idle" | "pending" | "done" | "error">("idle");
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function submit() {
if (!embedded) return;
setStatus("pending"); setError(null); setTxHash(null);
try {
const data = encodeFunctionData({ abi: ABI, functionName: "mint", args: [input] });
const res: any = await Promise.race([
sendTransaction(
{ to: contract.address as `0x${string}`, data, chainId: 808813 },
{ address: embedded.address, sponsor: true }
),
new Promise((_, r) => setTimeout(() => r(new Error(
"Privy sendTransaction timed out after 45s. Enable Gas sponsorship -> App pays -> BOB Sepolia -> Allow transactions from the client."
)), 45_000)),
]);
const hash = res?.hash ?? res;
setTxHash(hash);
await client.waitForTransactionReceipt({ hash });
setStatus("done");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatus("error");
}
}
if (!ready) return <main className="p-10">Loading…</main>;
return (
<main className="min-h-screen mx-auto max-w-2xl px-6 py-16">
<h1 className="text-4xl font-semibold tracking-tight">TrueShot Ledger</h1>
<p className="mt-3 opacity-70">Prove original photo ownership to combat unauthorized reuse and forgery.</p>
{!authenticated ? (
<button onClick={login} className="mt-8 px-6 py-3 rounded-md bg-primary text-primary-foreground">
Sign in with Google
</button>
) : (
<section className="mt-8 space-y-4">
<div className="text-xs font-mono opacity-60">{embedded?.address}</div>
<input value={input} onChange={(e) => setInput(e.target.value)}
placeholder="Describe the photo authenticity entry…"
className="w-full rounded-md border px-4 py-3 bg-transparent" />
<button onClick={submit} disabled={status === "pending" || !input}
className="px-6 py-3 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
{status === "pending" ? "Writing to BOB Sepolia…" : "Mint provenance token"}
</button>
{txHash && (
<a href={`${EXPLORER}/tx/${txHash}`} target="_blank" rel="noreferrer"
className="block text-sm underline break-all">
{status === "done" ? "Confirmed" : "Pending"} · {txHash}
</a>
)}
{error && <p className="text-sm text-red-500 break-all">{error}</p>}
<button onClick={logout} className="text-xs uppercase tracking-widest opacity-60">Sign out</button>
</section>
)}
<footer className="mt-20 text-xs opacity-60">Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14</footer>
</main>
);
}
```
Also export the ABI from src/lib/contract.ts so the page and any read call share it.
HOOK INTEGRATION — IPFS via Pinata
```ts
// src/lib/pinata.ts
export async function pinFile(file: File): Promise<string> {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: { Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` },
body: fd,
});
if (!res.ok) throw new Error(`Pinata ${res.status}: ${await res.text()}`);
const { IpfsHash } = await res.json();
return IpfsHash as string;
}
export async function pinJson(json: unknown): Promise<string> {
const res = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}`,
"Content-Type": "application/json",
},
body: JSON.stringify(json),
});
if (!res.ok) throw new Error(`Pinata ${res.status}: ${await res.text()}`);
return (await res.json()).IpfsHash as string;
}
```
Pin FIRST, then send the onchain tx with the returned CID. Preview the pinned
asset at `https://gateway.pinata.cloud/ipfs/<cid>` and show the CID as a chip
next to the explorer link. Never send the file itself onchain.
HOOK INTEGRATION — ERC-721 provenance mint
- Pin the artefact with `pinFile`, pin a metadata JSON
(`{ name, description, image: "ipfs://<cid>" }`) with `pinJson`, then call
`mint(metadataCid)`.
- Read the new tokenId back from the receipt logs, or call `nextId()` after
confirmation.
- Render an owned-token strip: loop ids 1..nextId, call `ownerOf(id)` and skip
the ones the signed-in address does not own, showing `cidOf(id)` previews.
```ts
const nextId = await client.readContract({ address, abi: ABI, functionName: "nextId" });
const cid = await client.readContract({ address, abi: ABI, functionName: "cidOf", args: [id] });
```
- Link each token to `https://bob-sepolia.explorer.gobob.xyz/token/<address>/instance/<id>`.
USER FLOW
1. Land on page -> 'Sign in with Google' (Privy) -> embedded wallet auto-provisioned.
2. After the user creates a photo authenticity artefact, pin the file to IPFS via Pinata, then call `mint(cid)` on the deployed contract through Privy's sponsored transaction. Show tokenId, IPFS preview (`https://gateway.pinata.cloud/ipfs/<cid>`), and BOB Sepolia Explorer mint-tx link.
3. While the tx is in flight the button reads 'Writing to BOB Sepolia…' and the hash appears as a pending link; on receipt it flips to 'Confirmed'.
4. The page re-reads chain state so a refresh shows the same result.
5. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"
DESIGN DIRECTION
Commit to one distinctive look drawn from photography — specifically
photo authenticity. No purple-on-white gradient hero, no Inter/Poppins default stack, no
generic three-card feature row. Pick a real typeface pairing, one accent colour
plus a near-monochrome base, and a layout with an obvious focal point. Define
colours as CSS variables/tokens; never hardcode text-white or bg-black. Dark by
default. The proof-of-onchain elements (address, tx hash, CID) should read as
deliberate typographic detail in a mono face, not as debug output.
TROUBLESHOOTING (these are the failures you will actually hit)
- Blank page / "document is not defined" on load -> @privy-io/react-auth was
imported at module scope of a route. Move it behind the lazy client entry.
- sendTransaction never resolves, no error -> the Privy dashboard gas-sponsorship
toggle for BOB Sepolia is off. Add the custom chain and enable "Allow
transactions from the client".
- "signal is aborted without reason" -> you passed uiOptions:{ showWalletUIs:false }.
Remove it.
- Tx reverts with EMPTY revert data -> you encoded a function the deployed
contract does not have. Check the verified ABI on Blockscout before
debugging the UI.
- Tx sits pending forever -> underpriced. Resend the same nonce with a higher
maxFeePerGas (~5 gwei) and 1 gwei priority fee.
- `hardhat verify` fails with "missing API key" -> keep the dummy
`apiKey: { bobSepolia: "empty" }` entry; Blockscout ignores the value but
hardhat-verify requires the field.
- Explorer shows "not verified" after deploy -> Blockscout never auto-verifies.
Run the verify command.
- Reads return nothing -> you are pointed at the wrong chain. Confirm chainId
808813 and the BOB Sepolia RPC, not Ethereum Sepolia (11155111).
REQUIRED SECRETS (Lovable -> Project Settings -> Secrets):
- METAMASK_PRIVATE_KEY BOB Sepolia deployer key. Fund flow: get Sepolia ETH from https://sepolia-faucet.pk910.de/ then bridge to BOB Sepolia at https://bob-sepolia.gobob.xyz/
- BOB_SEPOLIA_RPC_URL HTTPS RPC (default: https://bob-sepolia.rpc.gobob.xyz). Public RPC works for hackathon load; swap for a dedicated node provider if you need higher rate limits.
- PRIVY_APP_ID Google sign-in + sponsored tx. Docs: https://docs.privy.io/llms-full.txt
- PINATA_JWT IPFS uploads (only if app pins media). Docs: https://docs.pinata.cloud/llms-full.txt
CREDIT (must appear in UI footer AND as NatSpec on every deployed contract):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Market sizing.
TAM
$2.4B
global photo software market
SAM
$500M
photo editing and verification tools
SOM
$50M
forensic authenticity services for professional photographers
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
photo editing history
EditTrace Chain
Track complete edit histories on-chain for transparent creative workflows.
fine art printsProPrint Certify
Certify fine art photo prints with tamper-proof digital provenance.
event photographySnapEvent Auth
Validate event photo ownership instantly with blockchain timestamps.
photojournalismNewsFrame Provenance
Authenticate frontline news photos with immutable creator-owned tokens.