Create a Pact for any Web3 action. One card. One condition. One action. One outcome.
Signed conditional cards, settled wallet-to-wallet on Unicity testnet2.
| Item | Answer |
|---|---|
| Short description | A universal builder for signed conditional cards. Giveaways, bounties, gated inboxes, paid alpha unlocks, and wallet attestations each become one card, signed by the creator's Sphere wallet, verified on every viewer's device, and settled wallet-to-wallet on Unicity testnet2. |
| Build path chosen | Sphere dApp — a browser dApp built on Sphere Connect (@unicitylabs/sphere-sdk), running against Unicity testnet2. No backend, no custody, no demo mode. |
| Network | Unicity testnet2 (PACT_NETWORK_ID = 4) |
| Live app | https://pact-neon-three.vercel.app |
| Agentic? | No. Pact is a human-driven dApp. It contains no autonomous agent and never signs on a user's behalf. See Agentic status for detail and for the agent-ready seam already built in. |
| Runs on AstridOS? | No. Pact has not been built for or tested on AstridOS. See AstridOS status. |
| License | MIT |
Pact has no backend and holds no keys and no funds. Each card is a small agreement: a condition, an optional stake or reward, and a settlement mechanic. The creator's Sphere wallet signs the card with sign_message; anyone who later sees that card re-verifies the secp256k1 signature locally before it renders. Every value flow — payments, stakes, rewards, messages, co-signatures — is a real Sphere Connect intent on testnet2.
Unicity's model is "verify, don't trust": a token is a file you hold, and the chain only answers has this been spent already? Pact applies the same idea to agreements. Your device checks every card and every signature itself.
Users pick a template (Giveaway, Quest, Bounty, Bug Bounty, Review Request, Access Request, Pitch Gate, Consultation, Alpha Unlock, Research Drop, Whitelist Spots, Attestation, Commitment, or a blank Pact). Each is a preset over one of four settlement mechanics:
- Collect & Reward (
drop) — participants send proof (text, link, or an attached screenshot) straight to the creator as a Sphere DM. The creator rewards winners directly, or runs a provably fair draw. - Stake to Reach (
gate) — reaching the creator requires staking real testnet tokens; the message travels with the stake. One-click refund keeps reply-or-refund promises honest. - Pay to Unlock (
vault) — buyers pay the price (with a memo identifying the card); the creator delivers the content or a unique code by Sphere DM. Content never leaves the creator's device until delivery. - Co-sign (
attest) — no tokens at all. Co-signers sign a canonical payload bound to the exact card (SHA-256 of its signed payload). The creator's app scans its inbox and verifies each signature by public-key recovery. Verifiable attestations with no contract and no indexer.
Adding a new kind of Pact is one entry in src/lib/templates.ts — no flow code changes. Signed payloads are versioned: v1 cards keep verifying byte-for-byte; v2 adds template and null-stake attest cards.
Pact has no server, so cards discover each other over Nostr — the same transport Unicity uses to move token files between wallets. A new card is published as a NIP-33 replaceable event (kind 30078, tagged pact-net-4) to Unicity's relays (wss://nostr-relay.testnet.unicity.network, wss://sphere-relay.unicity.network) plus public fallbacks. Everyone subscribes to that tag, so a brand-new user opens the app and immediately sees the network's open pacts.
Relays are dumb pipes and are never trusted. Every fetched card is re-verified against its creator's Sphere signature before it renders; wrong-network, oversized, malformed, or forged cards are dropped. The Nostr keypair (src/lib/nostr.ts) is transport-only: it signs the event envelope so relays accept it, and carries no funds and no authority.
Archiving republishes the card as closed. Deleting publishes a tombstone (status: deleted) that replaces the live event, so the card disappears for everyone — not just locally — and a persistent local deleted-id set means no relay echo can resurrect it. Feed updates for a card are only accepted from the key that first published it, so nobody can censor someone else's card by replaying its content with new tags.
Pay-to-unlock cards can sell N unique codes (whitelist spots, invite links, license keys). The creator pastes one code per line; the slot count is signed into the card while the codes stay creator-local. Each sale assigns the next free code by Sphere DM, and at zero the card closes as sold out. A live, advisory remaining tag on the feed shows buyers "1 of 2 left" (clamped to the signed total — never a security boundary, since the real gate is the creator holding the codes).
Delivery is reliable and hard to abuse. Paid unlocks are reconciled from the wallet's own authoritative transfer history (sphere_getHistory), so a payment that lands while the creator's app is closed is still delivered when they reopen it. Delivery only fires when the received amount actually covers the card's price in the right coin, which stops a spoofed-memo underpayment from unlocking a code. A crimson count badge on the creator's avatar shows how many paid buyers are waiting for a delivery signature; clicking it lists them with one-tap Sign & deliver, or Deliver all to walk the approvals back to back.
Your Profile shows your identity, stats, co-signatures, and Claimed — every code or content ever delivered to you. Claims are rebuilt from your own Sphere inbox (scanning for Pact delivery DMs) and stored locally, so your purchases survive the original card being deleted by its creator. Your data lives with you, not with the seller.
Drop winners are not hand-picked unless the creator chooses to:
- Entrants are collected from the creator's own Sphere inbox — every DM matching the Pact proof format inside the card's open window.
- The sorted entrant list is embedded in a canonical payload, which the creator signs with their Sphere wallet.
- ECDSA signing in the Sphere SDK is deterministic (RFC 6979): one payload, one possible signature. There is nothing to re-roll.
- Winners derive from SHA-256 of that signature. Anyone holding the draw record can re-verify it: same entrants, same signature, same winners, every time.
| Concern | How it works |
|---|---|
| Identity | Sphere Connect (identity:read); iframe / extension / popup transports |
| Card authenticity | sign_message intent; secp256k1 signature verified in-app (double-SHA256 with the Sphere Signed Message:\n prefix) |
| Payments | send intents with per-card memos (pact:<type>:<kind>:<id>) |
| Messaging | dm intents — proofs, gate messages, vault deliveries |
| Tokens | Real wallet assets via sphere_getAssets; amounts converted with BigInt math |
| Peers | sphere_resolve before every reward and manual delivery |
| Live payments | transfer:incoming subscription, reconciled against sphere_getHistory |
| Lock lifecycle | Connect 2.1: wallet:locked keeps the session alive, wallet:unlocked resumes it, wallet:disconnected tears it down. Older 2.0 wallets are feature-detected via client.walletProtocol and fall back to disconnect-on-lock |
| Fair draws | Entrants from sphere_getConversations / sphere_getMessages; winners seeded by a deterministic wallet signature |
| Attestations | Co-signatures collected from the inbox and verified locally by key recovery |
Every verified card can be shared three ways: a signed link (self-verifying — the full public card is base64url-encoded in the URL hash, size-capped, structurally validated, and signature-checked on import, so a tampered link is rejected outright and nothing in the hash ever reaches a server), a one-click X post, and a 1200×675 card image rendered locally on a canvas.
Prerequisites
- Node.js 22+ and npm (the Sphere SDK declares
engines.node >= 22) - A Sphere wallet on Unicity testnet2 with a little testnet UCT (needed only to create or pay a Pact; browsing and verifying cards needs no wallet)
Install and run
git clone https://github.com/0xmora/pact.git
cd pact
npm install
npm run dev # dev server on http://localhost:5173
npm run build # type-check (tsc -b) + production build to dist/
npm run lint # oxlint
npm run preview # serve the production build locallyConnecting your wallet (important)
Pact talks to Sphere over Sphere Connect. The wallet embeds the dApp in an iframe, and browsers block wallet popups from localhost, so:
- Deploy the app (or use the live URL).
- In Sphere, open Developer / Dev App URL and point it at your HTTPS URL.
- Open Pact through Sphere Preview. Click Connect Sphere and approve.
Everything is already pointed at testnet2 — there is nothing to configure. The network id is compiled in (PACT_NETWORK_ID = 4, see src/lib/pact.ts) and cards from any other network are rejected on sight. There are no environment variables and no API keys.
Try it end to end
- Open the app and browse Explore — cards stream in live from Unicity's relays, and each shows Signature verified only after your own device checks it.
- Click + New Pact, pick a template (e.g. Giveaway), set a reward and winners, and sign. Your card publishes to the feed.
- From another wallet, open the card and send a proof DM, or pay a Pay to Unlock card and watch the code arrive in your Sphere inbox.
- Open Profile → Sync from inbox to rebuild your claims from your own wallet.
Pact is not agentic. It is a human-driven dApp: it runs no autonomous agent, holds no keys, and can never sign, pay, or message on a user's behalf. Every value-moving action is a Sphere Connect intent that opens the wallet for explicit human approval — the wallet's onIntent handler always surfaces UI, and the permission scopes granted at connect time authorize Pact to request an action, never to perform one silently. That is a deliberate security boundary, not a limitation we intend to remove.
Where an agent would fit is already built and isolated. Vault delivery is expressed as a single guarded function (deliverToBuyer) driven by reconciled, authoritative payment history, so the creator's side of a sale is fully determined by verifiable facts and needs exactly one signature per sale. The day Unicity's agent layer (a wallet with its own signing key) is exposed to dApps, that same function becomes hands-free delivery with no redesign — the creator's identity would simply be operated by an always-on agent instead of a person tapping approve.
Pact does not run on AstridOS and has not been tested there. It is a standard browser dApp (React + TypeScript + Vite) with no OS-level integration, no native modules, and no AstridOS-specific code paths. It targets any modern browser and the Sphere wallet's embedded webview.
- Least-privilege permissions. A small set of Sphere Connect scopes; no
dm:manage, nomint. - Nothing from outside is trusted. Wallet responses, relay events, share links, and everything read from
localStorageare re-validated field by field, and every card's signature is re-verified before it renders — including cached ones. - Sends always target the signed creator pubkey, never a display nametag.
- Payment coverage is enforced. A vault code is only auto-delivered when the received amount covers the price in the correct coin; gate refunds never exceed what actually arrived.
- Feed integrity. Card updates (close / delete / spots-left) are accepted only from the key that first published the card, so third parties cannot censor or alter someone else's card.
- BigInt-only token math. No floating point anywhere near an amount.
- Wallet failures are classified by code, never by message text. One table in
src/lib/sphereErrors.tsdecides what every flow tells the user.INTENT_OUTCOME_UNKNOWN(4201) — the intent reached the wallet and the answer was lost — is never presented as "try again" and never auto-retried, because a resend is exactly how a payment goes out twice. - Strict headers on Vercel (
vercel.json): CSP,frame-ancestorslimited to Sphere, HSTS,nosniff, referrer policy. - Vault content is local-only and is never part of a card, a feed event, or a share link.
- Link safety. Only parser-validated
https:URLs in pact text become clickable, sojavascript:and lookalike schemes can never render as links; all outbound links usenoopener noreferrer. NoinnerHTML, noeval. - Screenshot proofs are opt-in, explicitly labelled as leaving the device, size/type-checked, and uploaded to nostr.build with NIP-98 request auth signed by the transport key.
src/
lib/ pure logic: pact (payload + verify), verify (secp256k1), amounts (BigInt),
templates, feed + nostr (discovery), claims, history (payment reconcile),
scan (throttled inbox walker), draw, attest, share, linkify, upload
hooks/ useSphereConnect (wallet), useFeed (relays)
components/ CardGrid, CreateModal, DetailModal, Profile, Landing, Avatar, LinkText
App.tsx flows, state, and the delivery pipeline
npx vercel --prodFramework preset Vite · build npm run build · output dist. Security headers ship via vercel.json. After deploying, set the Sphere Dev App URL to the deployed HTTPS URL.