Documentation
Technical reference
qdit is a builder task hub for Stellar teams: projects, tasks, milestones, deployments and proofs in one hub, where each approved milestone’s SHA-256 proof hash is anchored on the Stellar network through a Soroban contract, signed by the builder’s own wallet. This page describes how the pieces fit — the contract, the database, the anchoring flow and the public proof pages — for developers, integrators and auditors. It is grounded in the code it documents; every identifier below is real.
Deployed milestone_proof contract
The same reproducible build runs on both networks — the WASM hash is byte-identical, so stellar contract build against this source yields the hash deployed to each. The contract carries no admin address and no upgrade entry point on either network.
- Mainnet
- CBJHS2ZGKYJJUQR6YNSMZYGDPVHEESKGONN7IEOUMBDWAMUYAVIOOZRD
- Public Global Stellar Network ; September 2015
- Testnet
- CBP3NKXCRUSOJLLUXDF5AIRNPAC6IL7TFJ2KCNL5A2GTKC2MB7M4OHVG
- Test SDF Network ; September 2015
10,777 bytes optimized · 6 exported functions · built with soroban-sdk 27.0.4, targeting wasm32v1-none. Explorer links live in the repository README.
Architecture
Four layers, each with a single job. The chain never holds user content — only a 32-byte hash of it — so the database stays authoritative for the work while the ledger stays authoritative for the proof.
| Layer | Where | Responsibility |
|---|---|---|
| Next.js app | web/ | App Router (v16), React 19, TypeScript, Tailwind v4. Server Components read the database through one query module; Server Actions write it. Browser-side wallet signing for anchoring and payments. |
| Soroban contract | contracts/ | The milestone_proof Rust crate (no_std, soroban-sdk 27). Owner-gated registry: registers projects and records milestone proof hashes with an approve / reject lifecycle. |
| Supabase Postgres | supabase/ | Plain-SQL migrations: tables, enums, triggers, indexes and Row Level Security. Holds the actual project, task and milestone content, plus local pointers at each on-chain transaction. |
| Stellar network | mainnet · testnet | Soroban RPC assembles and submits the signed contract calls; Horizon answers balance and transaction-verification reads. The ledger is the source of truth for what was anchored. |
Auth is Supabase Auth reached through Stellar wallet sign-in (SEP-10): the wallet signs a self-verifying challenge transaction and the server mints a session. The deployment’s network is fixed by NEXT_PUBLIC_STELLAR_NETWORK (mainnet or, by default, testnet) and the contract it anchors against by NEXT_PUBLIC_MILESTONE_PROOF_CONTRACT_ID.
On-chain contract
milestone_proofis an owner-gated registry. A project owner registers a project reference, a submitter attaches a proof hash for a milestone, and the project owner approves or rejects it. Only the 32-byte proof hash lives on chain; the artifact stays off chain. Identifiers are the app’s own UUIDs, passed as Soroban String and capped at 64 characters.
Exported functions
| Function | Auth | Notes |
|---|---|---|
create_project_ref(project_id, owner) | owner | Registers a project. Errors ProjectExists (1) if the id is taken, rather than upserting. |
transfer_project_owner(project_id, current_owner, new_owner) | both | current_owner must equal the address in storage. Milestone records are untouched. |
submit_milestone_proof(project_id, milestone_id, submitter, proof_hash) | submitter | Sets status Submitted, increments version, stamps the ledger timestamp. |
approve_milestone(project_id, milestone_id, approver) | approver = owner | Only valid from Submitted. Terminal. |
reject_milestone(project_id, milestone_id, approver) | approver = owner | Only valid from Submitted. May be re-submitted afterwards. |
get_milestone_status(project_id, milestone_id) | none | Read-only, unauthenticated. Errors MilestoneNotFound (3). Read through simulation — free, no signature. |
Milestone lifecycle
A single state machine, enforced on chain. An unseen milestone is implicitly Proposed; a submission moves it to Submitted; the owner rules on it. Approved is terminal, Rejected can be re-submitted (which overwrites proof_hash and increments the monotonic version). Approve and reject preserve version — they attest to a submission rather than making one.
Proposed ──submit──▶ Submitted ──approve──▶ Approved (terminal)
│
└────reject───▶ Rejected ──submit──▶ Submitted …Owner authorization
Every write calls require_auth(). On creation the owner argument is authorized (there is no stored state yet); on every subsequent write the owner is read from storage and authorized, never trusted from an argument — that is the difference between an owner-gated registry and a free-for-all. Approve and reject additionally check approver == stored owner and return NotAuthorized(4) otherwise, so the on-chain owner rules regardless of what the app’s membership table says.
transfer_project_owner requires both signatures: the outgoing owner proves the right to give the project away, the incoming owner proves the destination is an address someone actually controls. With no admin path and no upgrade, a one-sided transfer could strand a project on an unsignable address permanently — so both must sign. It recovers a wrong-but-controlled address and enables key rotation; it cannot recover a lost key, because a lost key cannot sign as current_owner.
Storage & TTL
Records live in persistent storage under a DataKey enum — Project(project_id) maps to the owner address, and Milestone(project_id, milestone_id) to the full record. Every write extends the TTL of every key it touches back out to ~90 days (90 × 17,280 ledgers), topped up whenever fewer than ~30 days remain.
Events
Every write emits exactly one event and a failed call emits none, so an off-chain indexer can resync from the ledger alone. The shape is uniform: topic 0 is the constant namespace qdit(one predicate filters every event of this contract), topic 1 is the verb, and topic 2 is the indexed project_id (watch one project without decoding each body).
| Topic 0 | Topic 1 | Topic 2 | Data |
|---|---|---|---|
qdit | register | project_id | owner |
qdit | transfer | project_id | previous_owner, new_owner |
qdit | submit | project_id | milestone_id, submitter, proof_hash, version |
qdit | approve | project_id | milestone_id, approver, version |
qdit | reject | project_id | milestone_id, approver, version |
Errors
A #[repr(u32)] error enum with explicit, append-only discriminants — returned inside a Result rather than panicked, so a client maps the code to a message: ProjectExists (1), ProjectNotFound (2), MilestoneNotFound (3), NotAuthorized (4), InvalidStatus (5), IdTooLong (6).
Data model
The off-chain layer is plain-SQL migrations. Access is derived entirely from project_members: every table has Row Level Security enabled and no permissive default, so with RLS on and no matching policy the answer is “no rows”.
Tables
| Table | Holds |
|---|---|
profiles | One row per auth user: display name, avatar, and the immutable linked wallet address. |
projects | A workspace. Carries the per-project on-chain registration columns and the public_proofs switch. |
project_members | Membership + role join table — the single source of truth for every RLS check. |
milestones | Milestone with an off-chain status mirroring the contract's state machine. |
tasks | Board card: Todo → In Progress → Done, priority, assignee, optional milestone link. |
stellar_proofs | Proof-of-work record: contract id, tx hash, network, wallet, links. |
deployments | Append-only deployment history; the latest row is the current release state. |
milestone_anchors | Append-only log of submit / approve / reject transactions — a local pointer at each ledger entry. |
milestone_reviews | Why a decision was made: from_status → to_status and a reason. Off chain by design. |
notifications | Per-recipient bell items, written by a database trigger on a milestone status change. |
Enums
milestone_status (proposed, submitted, approved, rejected) is deliberately identical to the contract’s MilestoneStatus so off-chain and on-chain state can be compared directly. The others: project_status, member_role, task_status, task_priority, stellar_network, deployment_status, anchor_action and notification_kind.
RLS policy model
Roles rank viewer < member < admin < owner. Policies route through a SECURITY DEFINER helper, is_project_member(project_id, min_role), which reads project_members as its owner to avoid the infinite-recursion trap of a policy that queries the table it protects. It always evaluates auth.uid(), never an argument, and returns only a boolean. Broadly: viewer+ reads, member+ writes tasks / milestones / proofs, admin+ manages the project, its deployments and its members. The two ledger-pointer tables (milestone_anchors, milestone_reviews) have no UPDATE or DELETE policy — editing a row would make it lie, and deleting one does not delete the ledger entry it names.
Read path
Every read goes through web/src/lib/queries.ts. RLS does the scoping, so the queries never filter by membership themselves — a missing filter is a narrower result, not a leak. Each list function returns a Page<T> — rows (after the limit), total (unfiltered) and matched(after filters) — so a view can tell “you have nothing yet” from “your filter matched nothing” without a second round trip, and no view can render an unbounded list by accident.
Write path
Mutations are Server Actions in web/src/app/**/actions.ts. They re-validate with the same zod schema the client used — the client copy is a convenience, this one is the boundary, because a POST can be made without ever loading the form — and return { ok, error, fieldErrors } rather than throwing, so a form renders the failure inline. Postgres error codes are mapped to sentences (a 42501 becomes a permission message); RLS remains the actual boundary, and a hidden button is courtesy, not security.
Anchoring flow
Signing happens in the browser, so a single Server Action cannot own the whole transaction. It splits in three, and anchoring is deliberately additive — it never writes milestones.status. Moving a milestone through the approval flow in the app and proving it on chain are separate acts, and the two are allowed to disagree; the UI shows both.
1 · Prepare (server)
prepare*authorizes the caller, computes the SHA-256 proof hash over a canonical, length-prefixed encoding of the milestone and its linked proofs (server-only, encoding pinned toutf8), then assembles and simulates the contract call and hands back base64 XDR plus the simulated fee. Simulation surfaces a contract error — an already-approved milestone, an unregistered project — before the user is asked to sign, costing nothing.2 · Sign (browser)
The wallet kit signs that XDR string and nothing else. The same
signTransactioncall every supported wallet already implements.3 · Submit (server)
submit*treats the signed XDR as untrusted input:assertInvocationre-derives what the transaction is allowed to be and refuses anything else — exactly one operation, aninvokeContracthost function, the configured contract id, and the expected function name. Only then does it submit through Soroban RPC and pollgetTransactionuntil success (a timeout orTRY_AGAIN_LATERis an error, not an optimistic success). It readsversionback from the contract by simulation, then inserts amilestone_anchorsrow whosesigner_addresscomes from the verified transaction, never from a profile field.
The client half of this lives in web/src/lib/chain/client.ts and web/src/lib/chain/actions.ts; the contract calls go through a TypeScript client generated by stellar contract bindings typescript from the deployed contract, never hand-written.
API routes
A handful of Route Handlers under web/src/app/api. The two Horizon readers sit behind the auth gate so neither can be used as an open Horizon proxy, and both use a plain fetch — stellar.ts stays dependency-free and does the arithmetic.
| Route | Does |
|---|---|
POST /api/auth/wallet/challenge | Step one of SEP-10 sign-in: returns a self-verifying challenge transaction (server-signed, sequence 0, short expiry) for any well-formed G-address. It answers for every address on purpose — a Stellar address is public, and answering here must never become an account-existence oracle. |
POST /api/auth/wallet/verify | Step two: verifies the signature and reads the address out of the signed transaction (never the request body). The caller states intent — sign-in mints a Supabase session (or returns registration-required plus a ticket cookie), link binds the wallet to the signed-in profile, which is a one-time, immutable write. |
GET /api/balance | Auth-gated. Reads Horizon /accounts and returns three numbers — balance, reserve (the minimum-balance rule plus liabilities) and spendable — because reporting only the total invites a send that fails on the reserve. |
GET /api/verify-tx | Auth-gated. Paste a 64-hex transaction hash; confirms it on the given network via Horizon /transactions and returns ledger, success, source, fee, operation count and memo. |
Public proof pages
A milestone’s proof, reachable by someone with no account, lives at /p/[slug]/[milestone] — outside the (app)route group, so no session is required. It is the product’s claim made checkable by the person the claim is for: a title, a status, the anchor history and the decisions with their reasons, each with a link to the ledger, so a reader can verify the hashes on stellar.expert without trusting the page.
The data comes only from the public_milestone_proof(p_slug, p_milestone_id) function. Every table keeps its RLS unchanged and anon holds no membership row, so a direct read returns nothing; the function is a SECURITY DEFINER window that returns a fixed, hand-listed shape — project name, milestone title, status, network, contract id, owner address, the anchor list and the review list, and nothing else. It returns null unless the project’s owner turned on public_proofs. Not-published, no-such-milestone and wrong-project all return null and are deliberately indistinguishable: telling them apart would confirm that private work exists.
A milestone linked to a doqtri heading also carries docSource: the doc id, the heading’s node id and label (the milestone title), the doc version the link was written against, and the project id once the project is registered on chain (where it is public already, and never shown) — never the doc’s title, which is doqtri’s private note title, and never the stored doc URL. The page shows it as a “Planned in doqtri” block, and treats it as a pointer, not as evidence: anyone who may write the link could have written it. It reads this deployment’s DoqtriRegistry (DOQTRI_REGISTRY_CONTRACT_ID on DOQTRI_REGISTRY_NETWORK) with a read-only Soroban simulation, cached for 30 seconds, and makes two checks. The node id, its status and version appear only when get_node has tool “qdit” and an artifact_refnaming this project and milestone; otherwise the block says only that the milestone is linked to the doc and not signed in doqtri yet. The doc check, labelled “doc anchored at version ≥ V”, holds when get_documentis at or past the linked version (with a “plan changed since” note when past it). The block says verified only when both hold, naming the on-chain version; anything else is not verified with its reason, or an explicit “could not check” when a read failed or no registry is configured. The heading’s own record counts as Verified only when get_milestone_statusalso shows the milestone Approved at the version it names. “Open in doqtri” goes to DOQTRI_ORIGIN/d/<doc id>, built by qdit, and is absent when DOQTRI_ORIGIN is unset. An unlinked milestone has no docSource key and its page is unchanged.
Above that block, a linked milestone shows its proof chain as an ordered list of four steps: plan version (both checks above holding), submitted and approved (the milestone’s current record from get_milestone_statuson the project’s own contract, read when the page loads and cached the same way), and verified in doqtri (the heading’s record, Verified for the version approved on chain). Each step says in words whether it is done, pending, does not match, or could not be checked, with the ledger time or the time qdit recorded it. Submitted and approved link the transaction from the anchor history at the record’s version; with none, they link the contract and say that Stellar RPC keeps events for about 7 days, so an older transaction cannot be looked up there. Plan version and verified link the DoqtriRegistrycontract, because qdit does not keep doqtri’s transactions.
Verify it yourself
The WASM hash is the point: anyone can rebuild this source and check the bytes deployed match the code. If it does not, the deployed bytes are not this code.
# Reproduce the deployed contract cd contracts stellar contract build # → target/wasm32v1-none/release/milestone_proof.wasm # Compare against the deployed hash on both networks: # d4bbe221cbe9837cf277448d0fe3aa99cf0dd9213a98db15b671a34dadf2a8b4 # Inspect the live interface directly stellar contract info interface --id milestone_proof --network testnet
Deployment is not upgradable, by choice — no admin address, no upgrade entry point. A bug means deploying a new contract id and migrating the per-project chain_contract_idthe app stores, since a project’s ownership stays behind in the old contract rather than being upgraded in place. Individual anchors are verifiable independently of qdit: every milestone_anchors row names a transaction hash you can open on the explorer, and the ledger — not the database — is authoritative.
Prefer the app? Open the proof registry or the dashboard.