Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 145 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,145 @@
# setlo
Conditional multi-party booking settlement in USDG on Arbitrum
# Setlo

**Book every event supplier together. Release deposits only when all of them say yes.**

Setlo is conditional, multi-party booking and settlement for events, settled in USDG on Arbitrum. An agency puts the venue, caterer, AV and every other supplier into one package. The client funds it once. Each supplier accepts or declines their own slot. Deposits go out only when every required supplier has accepted; if that doesn't happen in time, the client gets the money back, minus the hold fees they approved.

- Live app: https://setlo-mu.vercel.app (test networks, test USDG)
- Try a real booking in two minutes: https://setlo-mu.vercel.app/demo
- Documentation: https://setlo-mu.vercel.app/docs

![Setlo landing page](docs/screenshots/landing.jpg)

## The problem

An event is only booked when every supplier is booked, but today each supplier takes its own deposit, on its own timeline, over email. A client can pay the venue before learning the caterer is unavailable, and then has to chase that deposit back. Agencies sit in the middle chasing confirmations and fronting money, with no shared record of who agreed to what.

## How Setlo works

1. **The agency builds one package**: event date, shared terms, deadlines, an agency fee, and a slot per supplier with a deposit, an optional paid hold and a post-event balance.
2. **The client reviews and funds it** with one signature that approves the exact suppliers, payout accounts and amounts. Setlo relays it, so the client pays no network fee.
3. **Each supplier accepts or declines** their own slot, also gasless.
4. **When every required supplier has accepted**, the booking confirms. Deposits and the agency fee are credited and paid out automatically.
5. **After the event**, the client releases the balances, or they release automatically when the review window ends.

If a required supplier declines, the agency can replace them. If the package is still incomplete at the deadline, it expires: suppliers who accepted keep their paid hold, and everything else is refunded to the client.

Nobody needs a crypto wallet. People sign in with an email code; Setlo creates their payout account (a Privy embedded wallet) and pays the network fees for clients and suppliers.

| Docs | Demo |
| --- | --- |
| ![Setlo docs](docs/screenshots/docs.png) | ![Setlo demo](docs/screenshots/demo.png) |

## Architecture

```mermaid
flowchart LR
A[Agency] --> W[Next.js app on Vercel]
C[Client] --> W
S[Supplier] --> W
W -->|email login, EIP-712 signing| P[Privy embedded wallets]
W -->|signed actions| R[Relayer route]
R -->|QuickNode RPC| K[SetloPackages contract]
K -->|permit, transfers| U[Paxos USDG]
K -->|events| Q[QuickNode Webhooks]
Q --> R
W --> D[(Supabase)]
W --> E[Gmail SMTP]
```

The contract is the source of truth for money and state. Everything else is coordination and can fail without changing what is owed.

| Component | Technology | Responsibility |
| --- | --- | --- |
| Contract | Solidity, Foundry, OpenZeppelin | Package state machine, custody, acceptance rules, credits and pull-based claims |
| App | Next.js, React, TypeScript, Tailwind, viem | Role screens, signing, reading live state from the chain |
| Auth and wallets | Privy | Email, Google and passkey sign-in; embedded payout wallets |
| Relayer | Vercel route with a hot wallet | Submits a whitelist of signed or permissionless calls and pays gas; pushes payouts with `claimFor` |
| RPC and events | QuickNode RPC and Webhooks | Reads, writes and contract event delivery on both networks |
| Metadata | Supabase Postgres | Drafts, invites, profiles, terms text, tx log, processed events |
| Email | Gmail SMTP | Invites, payout-account setup links, payout notices |
| Token | Paxos USDG (6 decimals) | Funding and payouts |

### Package lifecycle

```
Open ──(last required accept + funding)──▶ Confirmed ──(release after event)──▶ Released
│ └──(cancel before event)──▶ Cancelled
└──(deadline missed / final expiry)──▶ Expired
```

| Transition | Credited |
| --- | --- |
| Confirm | Accepted deposits to suppliers, agency fee to the agency, unaccepted optional slots back to the client |
| Release | Each booked balance to its supplier |
| Cancel after confirm | Unreleased balances back to the client |
| Expire | Earned hold fees to suppliers who accepted, the remainder to the client |

### Trust boundaries

- The contract is non-upgradeable. The owner can only pause **new** package creation; it can't move, redirect or freeze funds.
- Client funding is an EIP-712 instruction bound to the package's configuration hash, so a USDG permit can't be pointed at another package.
- Supplier acceptances bind the slot's exact terms and the package revision; any change requires re-acceptance.
- Every signature is chain- and contract-bound, single-use (nonce) and time-limited.
- The relayer can only submit calls users signed or that anyone may call, and payouts always go to the recipient's own address.
- Paxos can pause USDG or freeze addresses, including the Setlo contract.

More detail: [architecture](https://setlo-mu.vercel.app/docs/architecture/system-overview), [trust model](https://setlo-mu.vercel.app/docs/security/trust-model), [contracts/README.md](contracts/README.md).

## Deployments (testnet)

| Network | Chain ID | SetloPackages | USDG |
| --- | --- | --- | --- |
| Arbitrum Sepolia | 421614 | [`0xccf304db9ab8607b379b0f7f87cbb4d269a1de73`](https://sepolia.arbiscan.io/address/0xccf304db9ab8607b379b0f7f87cbb4d269a1de73) | `0xFFC95faa3d63Cde504a05B567C600B78C0b41892` |
| Robinhood Chain Testnet | 46630 | [`0xccf304db9ab8607b379b0f7f87cbb4d269a1de73`](https://explorer.testnet.chain.robinhood.com/address/0xccf304db9ab8607b379b0f7f87cbb4d269a1de73) | `0x7E955252E15c84f5768B83c41a71F9eba181802F` |

Both are source-verified on Sourcify (exact match). These are test networks; test USDG has no value.

## Repository

```
contracts/ SetloPackages.sol, Foundry unit, fuzz and invariant tests, deploy and live E2E scripts
app/ Next.js app: UI, API routes (relay, deadlines, webhooks, invites, faucet), Supabase migrations
docs/ README screenshots
```

## Run it locally

Contracts:

```bash
cd contracts
forge build
forge test -vvv
```

App:

```bash
cd app
cp .env.example .env.local # fill in Privy, Supabase, relayer key, RPC URLs, QuickNode secret, Gmail
pnpm install
pnpm dev # http://localhost:3000
```

Checks run in CI:

```bash
pnpm lint && pnpm typecheck && pnpm test && pnpm build
```

## Evidence

- 43 Foundry unit and fuzz tests plus 2 invariant tests (the contract's USDG always covers what it owes).
- `contracts/script/E2E.s.sol` runs a funded, accepted, confirmed and released package and a declined package that expires with a refund, live on a deployed contract.
- `app/scripts/relay-smoke.mjs` runs a full booking through the relayer API with automatic payouts.
- Both live scripts have been run on Arbitrum Sepolia and Robinhood Chain Testnet with sub-dollar amounts, sweeping test payouts back afterwards.

## Limitations

- Test networks and test USDG only.
- No dispute resolution or proof of delivery; balances release when the client approves or the review window ends.
- No business identity verification; suppliers are email-authenticated and approved by the client.
- No in-app send or withdraw of USDG.
- Deadline actions (expire, automatic release) run when a booking page is open or someone presses the button; there is no background scheduler on Vercel Hobby.
- Amounts and payout addresses are public onchain.
28 changes: 28 additions & 0 deletions app/app/docs/[section]/[page]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { DocPage } from "@/components/docs/doc-page";
import { DOC_PAGES, findDoc } from "@/lib/docs";

type Params = { section: string; page: string };

export const dynamicParams = false;

export function generateStaticParams(): Params[] {
return DOC_PAGES.map((p) => {
const [section, page] = p.path.split("/");
return { section, page };
});
}

export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { section, page } = await params;
const doc = findDoc(`${section}/${page}`);
return doc ? { title: `${doc.page.title} · Docs`, description: doc.page.description } : {};
}

export default async function Doc({ params }: { params: Promise<Params> }) {
const { section, page } = await params;
const doc = findDoc(`${section}/${page}`);
if (!doc) notFound();
return <DocPage {...doc} />;
}
29 changes: 29 additions & 0 deletions app/app/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Link from "next/link";
import { Footer, Logo } from "@/components/app/shell";
import { DocsMobileNav, DocsSidebar } from "@/components/docs/docs-nav";

export default function DocsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<header className="sticky top-0 z-30 border-b border-border bg-bg/95 backdrop-blur">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<span className="flex items-center gap-3">
<Link href="/" aria-label="Setlo home"><Logo /></Link>
<Link href="/docs" className="rounded-full border border-border px-2.5 py-0.5 text-[13px] text-text-muted hover:text-text">Docs</Link>
</span>
<nav className="flex items-center gap-1 text-[15px] sm:gap-2" aria-label="Main">
<Link href="/demo" className="hidden min-h-11 items-center rounded-md px-3 hover:bg-surface-muted sm:inline-flex">Demo</Link>
<a href="https://github.com/CryptoZephyr/setlo" rel="noreferrer" target="_blank" className="hidden min-h-11 items-center rounded-md px-3 hover:bg-surface-muted sm:inline-flex">GitHub</a>
<Link href="/app" className="inline-flex min-h-11 items-center rounded-md px-3 hover:bg-surface-muted">Open app</Link>
</nav>
</div>
</header>
<DocsMobileNav />
<div className="mx-auto flex max-w-7xl gap-10 px-4 sm:px-6">
<DocsSidebar />
<main className="min-w-0 flex-1">{children}</main>
</div>
<Footer />
</>
);
}
6 changes: 6 additions & 0 deletions app/app/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
import { DOC_PAGES, docHref } from "@/lib/docs";

export default function Docs() {
redirect(docHref(DOC_PAGES[0]));
}
3 changes: 3 additions & 0 deletions app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export default function Landing() {
<a href="#how-it-works" className="hidden min-h-11 items-center rounded-md px-3 text-bg/90 hover:text-bg sm:inline-flex">
How it works
</a>
<Link href="/docs" className="inline-flex min-h-11 items-center rounded-md px-3 text-bg/90 hover:text-bg">
Docs
</Link>
<Link href="/signin" className="inline-flex min-h-11 items-center rounded-md px-3 text-bg/90 hover:text-bg">
Sign in
</Link>
Expand Down
3 changes: 3 additions & 0 deletions app/components/app/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export function AppHeader() {
<Link href="/app/new" className="inline-flex min-h-11 items-center rounded-md px-3 text-[15px] hover:bg-surface-muted">
New package
</Link>
<Link href="/docs" className="hidden min-h-11 items-center rounded-md px-3 text-[15px] hover:bg-surface-muted sm:inline-flex">
Docs
</Link>
<Link href="/app/account" className="inline-flex min-h-11 items-center rounded-md px-3 text-[15px] hover:bg-surface-muted" title={email}>
Account
</Link>
Expand Down
3 changes: 2 additions & 1 deletion app/components/app/legal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ export function LegalPage({ title, intro, sections }: { title: string; intro: st
return (
<>
<header className="border-b border-border bg-bg">
<div className="mx-auto flex h-16 max-w-6xl items-center px-4 sm:px-6">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<Link href="/" aria-label="Setlo home"><Logo /></Link>
<Link href="/docs" className="inline-flex min-h-11 items-center rounded-md px-3 text-[15px] hover:bg-surface-muted">Docs</Link>
</div>
</header>
<main className="mx-auto max-w-2xl px-6 py-14 sm:py-20">
Expand Down
1 change: 1 addition & 0 deletions app/components/app/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function Footer() {
<Link className="text-text-muted hover:text-text" href="/#money">Where the money goes</Link>
<Link className="text-text-muted hover:text-text" href="/#faq">FAQ</Link>
<Link className="text-text-muted hover:text-text" href="/demo">Try the demo</Link>
<Link className="text-text-muted hover:text-text" href="/docs">Documentation</Link>
</nav>
<nav aria-label="Account" className="flex flex-col gap-2 text-sm">
<p className="font-semibold">Account</p>
Expand Down
5 changes: 4 additions & 1 deletion app/components/demo/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ export function Demo() {
<header className="border-b border-border">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<Link href="/" aria-label="Setlo home"><Logo /></Link>
<Link href="/signin" className="inline-flex min-h-11 items-center rounded-md px-3 text-[15px] hover:bg-surface-muted">Sign in</Link>
<nav className="flex items-center gap-1" aria-label="Main">
<Link href="/docs" className="inline-flex min-h-11 items-center rounded-md px-3 text-[15px] hover:bg-surface-muted">Docs</Link>
<Link href="/signin" className="inline-flex min-h-11 items-center rounded-md px-3 text-[15px] hover:bg-surface-muted">Sign in</Link>
</nav>
</div>
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-8 sm:px-6 sm:py-10">
Expand Down
23 changes: 23 additions & 0 deletions app/components/docs/copy-code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { Check, Copy } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";

export function CopyCode({ text }: { text: string }) {
const [done, setDone] = useState(false);
return (
<Button
intent="ghost"
size="sm"
onPress={async () => {
await navigator.clipboard.writeText(text);
setDone(true);
setTimeout(() => setDone(false), 2000);
}}
>
{done ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
{done ? "Copied" : "Copy"}
</Button>
);
}
99 changes: 99 additions & 0 deletions app/components/docs/doc-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { ArrowLeft, ArrowRight } from "lucide-react";
import Link from "next/link";
import { CopyCode } from "./copy-code";
import { Notice } from "@/components/ui/status";
import { anchor, docHref, type DocPage as Page, type DocSection } from "@/lib/docs";

function Section({ s }: { s: DocSection }) {
return (
<section id={anchor(s.title)} className="mt-10 scroll-mt-24">
<h2 className="text-xl font-semibold">
<a href={`#${anchor(s.title)}`} className="hover:underline">{s.title}</a>
</h2>
{s.body?.map((p, i) => (
<p key={i} className="mt-3 text-[15px] leading-relaxed text-text-muted">{p}</p>
))}
{s.list && (
<ul className="mt-3 list-disc space-y-1.5 pl-5 text-[15px] leading-relaxed text-text-muted">
{s.list.map((x) => <li key={x}>{x}</li>)}
</ul>
)}
{s.flow && (
<ol className="mt-4 grid gap-px overflow-hidden rounded-lg border border-border bg-border">
{s.flow.map((x, i) => (
<li key={x} className="flex gap-4 bg-surface p-4">
<span className="font-display text-2xl leading-none text-brand">{i + 1}</span>
<p className="text-[15px] leading-relaxed">{x}</p>
</li>
))}
</ol>
)}
{s.table && (
<div className="mt-4 overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-[14px]">
<thead className="bg-surface-muted">
<tr>{s.table.head.map((h) => <th key={h} className="px-3 py-2 font-semibold">{h}</th>)}</tr>
</thead>
<tbody>
{s.table.rows.map((r) => (
<tr key={r.join("|")} className="border-t border-border align-top">
{r.map((c, i) => (
<td key={i} className={c.startsWith("0x") ? "px-3 py-2 font-mono text-[13px] break-all" : "px-3 py-2 text-text-muted"}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{s.code && (
<div className="mt-4 overflow-hidden rounded-lg border border-border bg-surface">
<div className="flex justify-end border-b border-border px-2 py-1.5">
<CopyCode text={s.code} />
</div>
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed"><code>{s.code}</code></pre>
</div>
)}
{s.callout && (
<Notice tone={s.callout.warning ? "waiting" : "info"} className="mt-4" title={s.callout.title}>{s.callout.body}</Notice>
)}
</section>
);
}

export function DocPage({ page, prev, next }: { page: Page; prev: Page | null; next: Page | null }) {
return (
<div className="flex gap-10">
<article className="min-w-0 flex-1 py-10 sm:py-14">
<p className="text-sm font-medium tracking-wide text-brand uppercase">{page.group}</p>
<h1 className="mt-2 font-display text-4xl sm:text-5xl">{page.title}</h1>
<p className="mt-4 text-lg text-text-muted">{page.description}</p>
{page.sections.map((s) => <Section key={s.title} s={s} />)}
<nav aria-label="Previous and next" className="mt-14 grid gap-3 border-t border-border pt-6 sm:grid-cols-2">
{prev ? (
<Link href={docHref(prev)} className="rounded-lg border border-border p-4 hover:bg-surface-muted">
<span className="flex items-center gap-1 text-[13px] text-text-muted"><ArrowLeft className="size-3.5" aria-hidden /> Previous</span>
<span className="mt-1 block font-medium">{prev.title}</span>
</Link>
) : <span />}
{next && (
<Link href={docHref(next)} className="rounded-lg border border-border p-4 text-right hover:bg-surface-muted">
<span className="flex items-center justify-end gap-1 text-[13px] text-text-muted">Next <ArrowRight className="size-3.5" aria-hidden /></span>
<span className="mt-1 block font-medium">{next.title}</span>
</Link>
)}
</nav>
</article>
{page.sections.length > 1 && (
<aside className="sticky top-16 hidden h-fit w-52 shrink-0 py-14 xl:block">
<p className="mb-2 text-[13px] font-medium tracking-wide text-text-muted uppercase">On this page</p>
<ul className="flex flex-col gap-1.5 text-sm">
{page.sections.map((s) => (
<li key={s.title}><a href={`#${anchor(s.title)}`} className="text-text-muted hover:text-text">{s.title}</a></li>
))}
</ul>
</aside>
)}
</div>
);
}
Loading
Loading