Pattern Pathway
Pin editable pattern files for seamless transfer and secure versioning.
IPFS via Pinata· decentralized storage
Section · Onchain
full primer →The primitive.
Every seamless pattern sharing artefact is pinned to IPFS through Pinata; fashion designers get a permanent CID and a public gateway preview instead of a fragile cloud URL.
Why this primitiveIPFS guarantees immutable and accessible sharing of pattern files.
Kernel
a Pinata JWT upload that pins images / JSON / manifests to IPFS and returns a permanent CID
Drives the UI as
a 'pinned to IPFS' chip with the CID and an ipfs.io gateway preview
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 "Pattern Pathway" in ONE Lovable message. Single-page demo.
CONCEPT
Pin editable pattern files for seamless transfer and secure versioning.
Discipline: Fashion & Textile Design (seamless pattern sharing).
Onchain primitive: IPFS via Pinata. Why this primitive: IPFS guarantees immutable and accessible sharing of pattern files.
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/pattern_pathway (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/pattern_pathway --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`.
- 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 })`.
MOVE MODULE (move/pattern_pathway/sources/pattern_pathway.move):
```move
/// Pattern Pathway — append-only public record.
/// Pin editable pattern files for seamless transfer and secure versioning.
/// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
module app::pattern_pathway {
use std::signer;
use std::string::String;
use std::vector;
use aptos_framework::timestamp;
use aptos_framework::event;
#[event]
struct Logged has drop, store { author: address, payload: String, at: u64 }
struct Entry has store, copy, drop { author: address, payload: String, at: u64 }
struct Ledger has key { entries: vector<Entry> }
fun init_module(publisher: &signer) {
move_to(publisher, Ledger { entries: vector::empty<Entry>() });
}
/// Append a payload (CID, hash or title) to the public ledger.
public entry fun log(author: &signer, payload: String) acquires Ledger {
let ledger = borrow_global_mut<Ledger>(@app);
let entry = Entry { author: signer::address_of(author), payload, at: timestamp::now_seconds() };
vector::push_back(&mut ledger.entries, entry);
event::emit(Logged { author: entry.author, payload: entry.payload, at: entry.at });
}
#[view]
public fun entries(): vector<Entry> acquires Ledger { borrow_global<Ledger>(@app).entries }
#[view]
public fun count(): u64 acquires Ledger { vector::length(&borrow_global<Ledger>(@app).entries) }
}
```
MOVE GOTCHAS (these bite every time)
- `init_module` runs once at publish; a missing `Ledger` resource means you
published before adding it — republish, don't patch at runtime.
- Every function touching a global needs `acquires Ledger`.
- Entry-function args can only be primitives / String / vector — no structs.
- Read state with `#[view]` functions via `aptos.view({ payload: { function:
`${moduleAddress}::pattern_pathway::entries` } })`, never by parsing 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. On submit, pin the seamless pattern sharing artefact to Pinata, then call `pattern_pathway::log(cid)` through the server-side Privy raw-signing bridge. Render the CID, the IPFS gateway preview 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
$1.4B
pattern design software
SAM
$320M
pattern sharing platforms
SOM
$23M
pattern version control apps
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
digital textile archives
Fabric Storyline
Securely archive and share original fabric patterns with permanent proof of creativity.
fashion show documentationRunway Replay
Pin high-resolution show images and metadata to create an unalterable runway history.
color palette preservationColor Code Vault
Store confirmed color palettes and their usage securely for design authenticity.
garment construction logsStitch Trace
Record detailed stitch patterns and techniques on-chain to prove craftsmanship.