Field Recording Mint
Ownership minting for authentic field recordings archived with verifiable IPFS CIDs.
Aptos Digital Asset provenance mint· onchain authorship
Section · Onchain
full primer →The primitive.
Musicians mint each ambient sound archives as an Aptos Digital Asset pointing at an IPFS CID, so authorship and timestamp are provable from a single Aptos Explorer link.
Why this primitiveNFT provenance tokens preserve authenticity of raw audio captures.
Kernel
an Aptos Digital Asset (Token v2) collection whose tokens point at an IPFS CID, minted by the creator's own embedded wallet
Drives the UI as
a 'mint to claim authorship' button that returns the token object address, owner and Aptos Explorer link
Required keys.
APTOS_DEPLOYER_PRIVATE_KEY
Exported from a Petra "classic" account with the network set to Testnet. Fund it at the Aptos faucet.
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 "Field Recording Mint" in ONE Lovable message. Single-page demo.
CONCEPT
Ownership minting for authentic field recordings archived with verifiable IPFS CIDs.
Discipline: Music & Sound Design (ambient sound archives).
Onchain primitive: Aptos Digital Asset provenance mint. Why this primitive: NFT provenance tokens preserve authenticity of raw audio captures.
5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page app. No router, no Lovable Cloud, no database, no auth flows beyond Privy drop-in.
- ONE Move module, <=80 lines, published to Aptos Testnet, source visible on Aptos Explorer.
- Privy is always the auth layer (Google login, Aptos embedded wallet, server-side raw signing).
- 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.
STACK
- React + Vite + TanStack Start 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.
- PrivyProvider config (Aptos is a Tier-2 chain in Privy — there is NO chain
object to pass, and NO useSendTransaction hook that speaks Aptos):
<PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID}
config={{ loginMethods:['google','email'],
appearance:{ theme:'dark' } }}>
- CRITICAL: the Privy React SDK CANNOT sign Aptos transactions. Signing happens
SERVER-SIDE with @privy-io/node raw signing. The browser only sends the Privy
access token (getAccessToken()) to a TanStack server function.
- Server session bridge (src/lib/privy.server.ts) — READ SECTION C BELOW FIRST.
Verify the Privy access token against the app's JWKS, then resolve an
APP-CONTROLLED Aptos wallet by external_id. Never create it with `owner`.
- Sign + submit bridge (src/lib/aptos-tx.server.ts) — exact call shapes in SECTION E below.
- Aptos client (src/lib/aptos.server.ts):
new Aptos(new AptosConfig({ network: Network.TESTNET, fullnode: process.env.APTOS_RPC_URL }))
Use the Alchemy Aptos Testnet URL (https://aptos-testnet.g.alchemy.com/v2/<KEY>/v1).
The public fullnode rate-limits under hackathon load.
- The user pays their own gas in APT. There is no sponsored-tx toggle to flip —
give every new wallet a faucet nudge (https://aptos.dev/network/faucet) and
show the APT balance before enabling the action button.
- Move package in /move/field_recording_mint (kept outside the Vite bundle). Install the Aptos
CLI (https://aptos.dev/build/cli) — there is no Hardhat, no Foundry, no ABI json.
- Move.toml pins the framework to the commit your CLI was built against; a
floating `rev = "mainnet"` breaks the build with spec errors:
[addresses]
app = "_"
[dependencies.AptosFramework]
git = "https://github.com/aptos-labs/aptos-core.git"
rev = "fde503186ef74658abd2c66532a8602eca33a20d"
subdir = "aptos-move/framework/aptos-framework"
- Publish:
aptos init --network testnet --private-key $APTOS_DEPLOYER_PRIVATE_KEY
aptos move publish --package-dir move/field_recording_mint --named-addresses app=<deployer-address>
Move source is published WITH the module — Aptos Explorer shows it under the
account's Modules tab immediately. There is no separate verify step and no
API key to buy.
- Write the publisher address to `src/data/contract.json` so the UI links to
`https://explorer.aptoslabs.com/account/<address>/modules?network=testnet`
and every tx to `https://explorer.aptoslabs.com/txn/<hash>?network=testnet`.
MOVE MODULE (move/field_recording_mint/sources/field_recording_mint.move) — Aptos Digital Asset (Token v2), not ERC-721:
```move
/// Field Recording Mint — provenance mints.
/// Ownership minting for authentic field recordings archived with verifiable IPFS CIDs.
/// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
module app::field_recording_mint {
use std::option;
use std::signer;
use std::string::{Self, String};
use aptos_token_objects::collection;
use aptos_token_objects::token;
const COLLECTION: vector<u8> = b"Field Recording Mint";
fun init_module(publisher: &signer) {
collection::create_unlimited_collection(
publisher,
string::utf8(b"Ownership minting for authentic field recordings archived with verifiable IPFS CIDs."),
string::utf8(COLLECTION),
option::none(),
string::utf8(b"ipfs://"),
);
}
/// Mint a creator-owned token pointing at an IPFS CID.
public entry fun mint(creator: &signer, name: String, cid: String) {
let uri = string::utf8(b"ipfs://");
string::append(&mut uri, cid);
token::create_named_token(
creator,
string::utf8(COLLECTION),
string::utf8(b"Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"),
name,
option::none(),
uri,
);
let _ = signer::address_of(creator);
}
}
```
DIGITAL ASSET GOTCHAS
- Aptos NFTs are objects, not token ids. The UI shows the token OBJECT ADDRESS
and links to `https://explorer.aptoslabs.com/object/<addr>?network=testnet`.
- The collection is created by `init_module` at publish time and is owned by the
publisher; users mint named tokens into it. Names must be unique per collection.
- Query a user's holdings with the Aptos indexer GraphQL
(`current_token_ownerships_v2`), not by scanning events.
APTOS SKILL PACK — non-obvious rules that decide whether this ships or 401s.
Everything below was learned by shipping this exact stack. Follow it literally; do not
"improve" the call shapes, they are the ones that actually work.
A. CHAIN CONSTANTS (copy verbatim — never look these up again)
network testnet -> Network.TESTNET, chain id 2, CAIP-2 "aptos:testnet"
SDK @aptos-labs/ts-sdk (never viem / ethers / wagmi / web3.js)
RPC process.env.APTOS_RPC_URL — Alchemy https://aptos-testnet.g.alchemy.com/v2/<KEY>/v1
Append "/v1" if the pasted URL is missing it. The public fullnode
rate-limits `view` calls under load.
USDC fungible-asset METADATA object
0x69091fbab5f7d635ee7ac5098cf0c1efbe31d68fec0f2cd565e8d168daf52832
6 decimals -> "10000" atomic = 0.01 USDC. (Mainnet USDC is a DIFFERENT
address. Testnet USDC is a fungible asset, not an ERC-20, not a coin type.)
APT 8 decimals (octas)
Explorer https://explorer.aptoslabs.com + "?network=testnet" on EVERY url:
/txn/<hash>?network=testnet
/account/<addr>/modules?network=testnet
/fungible_asset/<metadata>/info?network=testnet
Omit the suffix and the link silently shows mainnet / "not found".
APT faucet https://aptos.dev/network/faucet
USDC faucet https://faucet.circle.com/ (choose Aptos Testnet)
Put all of the above in src/data/aptos.json and import it everywhere. Never retype
an address in a component.
B. PRIVY IS A TIER-2 CHAIN ON APTOS — THE BROWSER SDK CANNOT SIGN
There is no Aptos chain object to pass to <PrivyProvider>, no useSignTransaction,
no useSendTransaction that speaks Aptos, no wallet hook that returns an Aptos signer.
The ONLY job of the browser is: log the user in and call `getAccessToken()`.
Every signature is produced server-side by @privy-io/node `wallets().rawSign()`
inside a TanStack server function, after that function verifies the access token.
Client: const token = await getAccessToken(); -> pass to the server fn.
Server: verify token -> resolve wallet -> build tx -> rawSign -> submit.
C. USE APP-CONTROLLED WALLETS KEYED BY external_id (this is the 401 fix)
A USER-OWNED wallet (`owner: { user_id }`) requires a `privy-authorization-signature`
header on every sign call. The browser SDK cannot produce one for a Tier-2 chain, so
you get: 401 Missing 'privy-authorization-signature' header or no signatures provided.
Create the wallet with NO owner and an external_id derived from the Privy user id,
and gate access by verifying the access token yourself:
```ts
// src/lib/privy.server.ts
import { PrivyClient, verifyAccessToken } from "@privy-io/node";
import { createRemoteJWKSet } from "jose";
import privyCfg from "@/data/privy.json"; // { "appId": "<public app id>" }
// The app id is PUBLIC — ship it in src/data/privy.json and fall back to it.
// The server env usually only carries PRIVY_APP_SECRET, and reading
// process.env.PRIVY_APP_ID alone yields a spurious "PRIVY_APP_ID is not configured".
const appId = () =>
process.env["PRIVY_APP_ID"] ?? process.env["VITE_PRIVY_APP_ID"] ?? privyCfg.appId;
export const privyClient = () =>
new PrivyClient({ appId: appId(), appSecret: process.env["PRIVY_APP_SECRET"]! });
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
export async function requirePrivyUser(accessToken: string): Promise<string> {
if (!accessToken) throw new Error("Missing Privy access token");
jwks ??= createRemoteJWKSet(
new URL(`https://auth.privy.io/api/v1/apps/${appId()}/jwks.json`));
const claims = await verifyAccessToken({
access_token: accessToken, app_id: appId(), verification_key: jwks });
return claims.user_id;
}
// external ids allow [a-zA-Z0-9_-] only, max 64 chars; `did:privy:...` must be sanitised
const externalIdFor = (userId: string) =>
`aptos-${userId}`.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64);
export async function getOrCreateAptosWallet(userId: string) {
const client = privyClient();
const external_id = externalIdFor(userId);
const existing = await client.wallets()
.list({ external_id, chain_type: "aptos", limit: 1 });
const found = existing.data?.[0];
if (found?.public_key) return {
id: found.id, address: found.address,
publicKey: normalizeEd25519PublicKey(found.public_key) };
const created = await client.wallets().create({
chain_type: "aptos", external_id, idempotency_key: external_id }); // NO owner
return { id: created.id, address: created.address,
publicKey: normalizeEd25519PublicKey(created.public_key!) };
}
export async function rawSignHash(walletId: string, hashHex: string) {
const res = await privyClient().wallets()
.rawSign(walletId, { params: { hash: hashHex as `0x${string}` } });
return res.signature; // note the { params: { hash } } nesting — not { hash }
}
```
D. NORMALISE THE PUBLIC KEY BEFORE Ed25519PublicKey
Privy may hand back hex, base64/base64url, or a DER/SPKI-wrapped blob. Aptos demands
raw 32 bytes and otherwise throws `PublicKey length should be 32`:
```ts
export function normalizeEd25519PublicKey(raw: string): string {
const trimmed = raw.trim();
const hexBody = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
let bytes: Uint8Array;
if (/^[0-9a-fA-F]+$/.test(hexBody) && hexBody.length % 2 === 0 && hexBody.length >= 64) {
bytes = new Uint8Array(hexBody.match(/../g)!.map((b) => parseInt(b, 16)));
} else {
const b64 = trimmed.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
}
if (bytes.length > 32) bytes = bytes.slice(bytes.length - 32); // strip DER/SPKI header
if (bytes.length !== 32) throw new Error(`bad Aptos public key: ${bytes.length} bytes`);
return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
}
```
E. SIGN + SUBMIT BRIDGE (the whole trick — copy the shape exactly)
```ts
// src/lib/aptos-tx.server.ts
import { AccountAddress, AccountAuthenticatorEd25519, Ed25519PublicKey,
Ed25519Signature, Hex, generateSigningMessageForTransaction,
type InputGenerateTransactionPayloadData } from "@aptos-labs/ts-sdk";
export async function signAndSubmit(wallet, data: InputGenerateTransactionPayloadData) {
const aptos = aptosClient();
const transaction = await aptos.transaction.build.simple({ sender: wallet.address, data });
const signingMessage = generateSigningMessageForTransaction(transaction);
// `hash` takes the WHOLE signing message as 0x-hex, not a 32-byte digest of it.
const signatureHex = await rawSignHash(wallet.id, Hex.fromHexInput(signingMessage).toString());
const publicKey = new Ed25519PublicKey(wallet.publicKey); // already normalised (D)
// Assert before submitting: a length-correct WRONG key fails on-chain with an
// opaque error instead of telling you the key is wrong.
const derived = publicKey.authKey().derivedAddress().toStringLong();
const reported = AccountAddress.from(wallet.address).toStringLong();
if (derived !== reported) throw new Error(`key mismatch: ${derived} vs ${reported}`);
const senderAuthenticator = new AccountAuthenticatorEd25519(
publicKey, new Ed25519Signature(signatureHex.startsWith("0x") ? signatureHex : `0x${signatureHex}`));
const pending = await aptos.transaction.submit.simple({ transaction, senderAuthenticator });
const committed = await aptos.waitForTransaction({
transactionHash: pending.hash, options: { checkSuccess: false } });
return { hash: pending.hash, success: committed.success, vmStatus: committed.vm_status };
}
```
F. BALANCES COME FROM VIEW FUNCTIONS, NOT BALANCE FIELDS
```ts
// src/lib/aptos.server.ts
export function aptosClient() {
const raw = process.env["APTOS_RPC_URL"];
if (!raw) return new Aptos(new AptosConfig({ network: Network.TESTNET }));
const base = raw.replace(/\/+$/, "");
return new Aptos(new AptosConfig({ network: Network.TESTNET,
fullnode: base.endsWith("/v1") ? base : `${base}/v1` }));
}
export const usdcBalance = async (aptos, owner) => BigInt((await aptos.view({ payload: {
function: "0x1::primary_fungible_store::balance",
typeArguments: ["0x1::fungible_asset::Metadata"],
functionArguments: [owner, USDC_METADATA] } }))[0] ?? 0);
export const aptBalance = async (aptos, owner) => {
try { return BigInt((await aptos.view({ payload: {
function: "0x1::coin::balance",
typeArguments: ["0x1::aptos_coin::AptosCoin"],
functionArguments: [owner] } }))[0] ?? 0); }
catch { return 0n; } // an unfunded account THROWS here — never let it crash the page
};
```
G. FUNDING UX IS PART OF THE DEMO (user pays their own gas)
There is no sponsored-transaction toggle. A fresh embedded wallet has 0 APT and every
submit fails with an unhelpful error. The wallet panel MUST show:
the address (copyable), live APT and USDC balances, a Refresh button, the APT faucet
link, the USDC faucet link, and an explicit warning + disabled action button while
APT is zero.
H. SERVER-ONLY MODULE BOUNDARY
@privy-io/node, PRIVY_APP_SECRET and any deployer key must never reach the client
bundle. Keep them in *.server.ts, imported only from *.functions.ts handlers or from
src/routes/api/public/* handlers (in route files use `await import()` INSIDE the
handler). Read process.env inside .handler(), never at module scope. Browser config
uses import.meta.env.VITE_* only.
I. FAILURE MODES — match the symptom, apply the fix, do not guess
PublicKey length should be 32 -> key is base64/DER; normalise (D)
401 Missing 'privy-authorization-signature' -> wallet is user-owned; recreate app-controlled (C)
PRIVY_APP_ID is not configured (server) -> fall back to the public id in src/data/privy.json (C)
Privy import crashes SSR -> @privy-io/react-auth reached the server graph;
mount behind <ClientOnly> + lazy()
key mismatch: 0x.. vs 0x.. -> wrong wallet/key pair; re-resolve by external_id
Move compile errors inside aptos_framework -> Move.toml on a floating rev; pin it (below)
view calls flake / 429 -> set APTOS_RPC_URL to the Alchemy endpoint
Explorer link shows mainnet / not found -> missing ?network=testnet
Transaction submits then instantly reverts -> wallet has no APT; Aptos faucet
J. MOVE PUBLISH — CLI ONLY, NO HARDHAT / FOUNDRY / ABI JSON
- Install the Aptos CLI (https://aptos.dev/build/cli). In a sandboxed Linux build
environment the binary is missing libudev, libssl and libstdc++; map them in
(e.g. via Nix) before running it, otherwise the CLI dies on start.
- Pin AptosFramework to the commit matching your CLI version. The default
`rev = "mainnet"` fails to compile with spec errors:
[addresses]
app = "_"
[dependencies.AptosFramework]
git = "https://github.com/aptos-labs/aptos-core.git"
rev = "fde503186ef74658abd2c66532a8602eca33a20d"
subdir = "aptos-move/framework/aptos-framework"
- Publish:
aptos init --network testnet --private-key $APTOS_DEPLOYER_PRIVATE_KEY
aptos move publish --package-dir move/<pkg> --named-addresses app=<deployer-address>
- Move source ships WITH the bytecode: Aptos Explorer shows it under the account's
Modules tab immediately. There is NO verify step and no API key to buy.
- VERIFY BY CALLING A #[view] FUNCTION (e.g. `count`) after publishing. A publish
receipt is not proof that init_module ran.
- Write the publisher address into src/data/contract.json and build every UI link
from it with the ?network=testnet suffix.
USER FLOW
1. Land on page -> 'Sign in with Google' (Privy) -> Aptos embedded wallet auto-provisioned server-side.
2. After the user creates a ambient sound archives artefact, pin the file to IPFS via Pinata, then call `field_recording_mint::mint(name, cid)` through the server-side Privy raw-signing bridge. Show the token OBJECT ADDRESS, the IPFS preview (`https://gateway.pinata.cloud/ipfs/<cid>`) and the Aptos Explorer tx link.
3. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"
REQUIRED SECRETS (Lovable -> Project Settings -> Secrets):
- APTOS_DEPLOYER_PRIVATE_KEY Aptos Testnet publisher key. Export it from a Petra "classic" account
(a Google/social Petra account gives you NO private key) with the
network switched to Testnet, then fund it: https://aptos.dev/network/faucet
- APTOS_RPC_URL Alchemy Aptos Testnet fullnode (https://aptos-testnet.g.alchemy.com/v2/<key>/v1).
Free app at https://dashboard.alchemy.com/. Public fullnodes throttle under load.
- PRIVY_APP_ID (+ VITE_PRIVY_APP_ID) Google sign-in + Aptos embedded wallet. Docs: https://docs.privy.io/llms-full.txt
- PRIVY_APP_SECRET Server-side @privy-io/node raw signing. Aptos cannot be signed in the browser.
- PINATA_JWT IPFS uploads (only if app pins media). Docs: https://docs.pinata.cloud/llms-full.txt
APTOS DOCS: https://aptos.dev/llms-full.txt
CREDIT (must appear in UI footer AND as a doc comment on the published Move module):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14Market sizing.
TAM
$600M
field recording tools
SAM
$180M
ambient sound markets
SOM
$30M
indie recordists
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
sample library curation
Sample Provenance Vault
Securely mint and verify original samples for trusted reuse and licensing.
mix version archivingMix Snapshot Ledger
Mint unique mix versions so producers prove original mixes over time.
synthesizer preset sharingSynth Patch Provenance
Create certified original synth presets with owned provenance for resale or sharing.
remix licensingSample Remix Rights
Mint remixable sample NFTs granting verified usage and royalty rights to remixers.