Everything documented below is completed, self-funded prior work. The v1
milestone_proof contract, the web app, and the testnet and mainnet tester rounds were
built and paid for by the team, and are not part of any pending funding request.
Next phase — planned, not started: a new milestone_proof v2 contract on Stellar
testnet with on-chain team roles and M-of-N milestone approval, Merkle batch anchoring
with inclusion proofs, and @qdit/verify v0.1, a minimal open-source verifier (library +
CLI). v1 stays deployed and unchanged; v2 is a separate deployment.
Problem
Builder teams plan and track in Linear, Notion, Jira and a spreadsheet — then claim
delivery to a grant program, a client or a DAO.
That creates a trust gap:
“Done” is a database column. Whoever owns the tracker can set it, unset it and
backdate it, and nobody outside the workspace can tell.
Approval leaves no receipt. A reviewer clicks approve; the evidence is a screenshot
in a thread that outlives nothing.
Reporting is re-typed by hand. The tracker, the grant report and the ledger are
three separate stories about the same work.
Proof does not survive the team. Vendor churn, a lapsed seat, a migrated workspace —
the history goes with the tool.
In short: project management has no independently verifiable record of what shipped.
Solution
qdit is a builder task hub — with Stellar as the proof layer.
Layer
What it does
Hub + board
Projects, tasks, milestones, deployments and proofs, project-scoped by default
Supabase Auth via wallet sign-in (SEP-10) + 30 RLS policies over 8 tables
Anchoring and payments built browser-side: prepare → wallet signs → server verifies and submits
Submission reliability — the anchor path retries transient failures on a linear backoff under one wall-clock budget, bids inclusion-fee headroom so a wallet-signed envelope can't be rejected for underbidding mid-retry, and keeps the transaction hash on a timeout so a pending submit is reconciled later instead of lost
Take-away proof — anchor history as a per-project CSV, and a self-contained certificate per milestone that verifies standalone against the ledger with this app offline (/api/export/anchors, /api/export/certificate)
Public proof page — a milestone anyone can open with no account; names the active network (testnet is labelled, not inferred) and states plainly what the ledger proves and what it does not
/wallet — connect, disconnect, XLM balance from Horizon, send a Testnet payment
Typed URL filter state, so a filtered view is shareable and survives a refresh
Responsive down to phone width. Below lg (1024px) the sidebar becomes a sheet behind
a menu button and every panel stacks to one column. List rows shed optional columns using
container queries rather than viewport breakpoints, because the same row renders
full-width on /tasks and inside a half-width dashboard panel:
queries.ts is the only read path; every function returns Page<T> so no view renders an unbounded list by accident
actions.ts re-validates with the same zod schema the client used and returns { ok, error, fieldErrors } instead of throwing
Deployed contracts
The app now runs against both mainnet and testnet. The contract carries no admin
address and no upgrade entry point on either network — that decision stayed closed even
for the mainnet deploy, so a bug means shipping a new contract id and migrating the
chain_contract_id the app stores per project, not an in-place upgrade.
Stellar Mainnet — production
The mainnet deployment both production rounds ran against, on real fees — the
first with 20 testers, the
second with 50.
Field
Value
Network
Stellar Mainnet (Public Global Stellar Network ; September 2015)
The WASM hash is byte-identical to the testnet deployment
below — the same reproducible build is live on both networks, so stellar contract build
against this source yields the hash deployed to mainnet and testnet alike.
Stellar Testnet — sandbox
Field
Value
Network
Stellar Testnet (Test SDF Network ; September 2015)
The WASM hash is the point of that table: anyone can rebuild this source with
stellar contract build and check the hash matches what is deployed. If it does not,
the deployed bytes are not this code.
Deployment is not upgradable — no admin address, no upgrade entry point, by choice.
A bug means deploying a new contract id and migrating the chain_contract_id the app
stores per project. That migration is near-free while projects.chain_contract_id is null
everywhere and milestone_anchors is empty, and grows with every registered project.
Verified live on this deployment
Beyond the 26 contract unit tests, checked against the deployed contract itself:
create_project_ref emits its event and sets the owner.
transfer_project_ownercannot be assembled without the incoming owner's signature —
the CLI refuses with Missing signing key for account G…. That is the two-signature rule
holding in production, not just in mock_auths.
After a transfer, the previous owner gets Error(Contract, #4)NotAuthorized on the
same project, so rights genuinely move rather than being shared.
A caution learned while checking this: stellar contract invoke silently signs every
auth entry it holds a local key for. A transfer between two identities that are both in
your keystore therefore looks like it needed one signature. It did not — use an address
you hold no key for to see the requirement.
Mainnet product test — 20 wallets · 67 transactions
Twenty testers ran the live app against the mainnet contract
CBJHS2ZGKYJJUQR6YNSMZYGDPVHEESKGONN7IEOUMBDWAMUYAVIOOZRD
on 21 August 2026, paying real fees. Every wallet, transaction and proof below is real
mainnet activity — each link opens on Stellar Expert.
Contract call
Transactions
create_project_ref
23
submit_milestone_proof
22
approve_milestone
22
reject_milestone
0
Total
67
That total is corroborated independently of this repo: the contract has emitted 67 events,
one per write, matching the 67 transactions above.
Milestone outcomes: 22 submitted, all 22 approved, none rejected, across 23 registered
projects. No transaction failed on the ledger. The retry pain the
feedback round describes was entirely
client-side — submissions that timed out before the transaction confirmed, then landed on a
retry — so every write that reached the chain succeeded. The one exception is the round's
1★: Nadia Concepcion's project registered, but her submit and approve never reached the
ledger — one transaction, no milestone anchored, exactly the "nothing went through" she
reported.
Testers
Projects counts create_project_ref calls; Approved is approved / submitted
milestones; Anchor txs counts the submit / approve / reject transactions on top of
the register txs.
Milestone outcomes: 50 submitted, 50 approved, none rejected, across 50 registered
projects — every run finished on chain. The closing verification pass re-read every project
straight from the contract and returned checked=50 approved=50 other=0, which corroborates
the table below independently of the run's own records.
No transaction failed on the ledger. Forty-five runs landed cleanly on 30 August; five
stalled on the old submit path — two submits and two approvals exhausting four attempts on
transaction submission timeout, and one approval dying on
transaction submission failed: TxInsufficientFee, the single failure no number of identical
retries can fix. The reliability fix (8f3570f)
— retry with linear backoff, inclusion-fee headroom bid before signing, and a pending state
that keeps the hash rather than a failed-attempt counter — shipped the next morning, and
those five re-ran to completion on 31 August (their approvals are dated the 31st on chain).
That is what closes the round at 50 of 50.
Across the whole round, 33 of 50 runs hit at least one auto-retry and 30 needed two or
more; on the hardened path every one recovered. This is the round-1 submission-reliability
finding, now closed rather than merely re-measured — see
the round-2 feedback for how the ratings moved.
Testers
Approved is approved / submitted milestones; Anchor txs counts the submit /
approve transactions on top of the register tx. Every tester registered exactly one
project with one milestone.
Two cohorts ran the live app against
CBP3NKXCRUSOJLLUXDF5AIRNPAC6IL7TFJ2KCNL5A2GTKC2MB7M4OHVG
on 15–16 August 2026. Every tester registered their own project on chain, then drove real
milestones through submit → approve | reject, including re-submissions where a reviewer
sent work back. Every wallet and transaction link below opens on Stellar Expert.
Contract call
Transactions
create_project_ref
50
submit_milestone
157
approve_milestone
103
reject_milestone
44
Total
354
Milestone outcomes: 103 approved · 10 still submitted · 6 rejected and not yet
resubmitted, across 119 milestones. 31 milestones needed at least one revise round, so
version on those is 2 or 3 rather than 1.
Then every milestone was read back from the contract and compared against what the app
believed: 119 / 119 matched on status, version and proof hash. No row disagreed.
Testers
Approved counts approved milestones over milestones run; anchor txs counts the
submit / approve / reject transactions on that project, on top of its register tx.
Fifty testers ran the live app on Stellar Testnet and filled the feedback form (wallet
address, email, name, product rating, free-text answer to “what would you change or
improve?”). It ran in three rounds: a first-contact round, a re-test by the same
twenty testers after the round-1 fixes shipped, and a cold round with thirty testers who
had never seen the app.
Fifty distinct testers, one current response each — the round-1 answers are superseded by
the same person's round-2 answer.
Metric
Value
Responses
50 / 50
Average rating
4.36 / 5
Promoters (4–5)
42 (84%)
Passives (3)
6 (12%)
Detractors (1–2)
2 (4%)
Rating
Count
Share
★★★★★
28
56%
★★★★☆
14
28%
★★★☆☆
6
12%
★★☆☆☆
2
4%
★☆☆☆☆
0
0%
What round 1 asked for and round 2 confirmed shipped. Every item below was a round-1
complaint; the quote is a round-2 tester on the same feature.
Round-1 complaint
Round-2 verdict
Rejection carried no reason
“rejected twice before it went through and both times i actually knew why, reason was right there under the title”
Status changes were silent
“liked getting the notif when status flipped, didnt have to keep refreshing”
Nothing shareable with an outside reviewer
“sent our grant person the public proof link … they just opened it, no login, no screenshots”
Cold start undocumented
“theres a checklist now that ticks itself off as you go” · “the fund button gave me testnet xlm without me googling anything”
Fee invisible before signing
“fee shows before the wallet popup now, exactly what i asked for”
Anchor state missing from the board
“board shows an anchor badge now, dont gotta click into every card anymore”
No version history
“v1 rejected on this date with this reason, v2 rejected, v3 approved, each one linking to its transaction”
Resubmit hidden in a dropdown
“resubmit is a real visible button now, not hidden in some dropdown”
Wrong network read as “account does not exist”
“it actually told me i was on the wrong network before anything broke”
Grey hash text unreadable in light mode
“light mode is readable now, the hashes used to be grey on white”
Board cramped on a phone
“rows are bigger so i stopped fat fingering the wrong milestone”
What still hurts the score: not the chain, again. Both 2★ responses and every 3★ are
about getting other people in and taking proof out — member invites that deliver
nothing, a wallet extension non-crypto teammates will not install, an owner handover that
only exists in the CLI, and no export a reviewer can keep. Not one tester reported a wrong
chain result, in either cohort.
Theme
Mentions
Worst rating raising it
Member invite delivers nothing — “no account found” is a dead end
4
2★
Wallet extension is the front door; non-crypto teammates stop there
4
2★
Mainnet cost per project unknown; only a per-transaction fee is shown
3
3★
Owner handover still needs the CLI
2
2★
No export — CSV of anchors, or a signed archive that verifies later
2
4★
Board does not scale — no search, no status filter, no cross-project queue
2
4★
Slow loads on a weak connection
2
3★
Small gaps: local-time dates, due reminders, prompt for an approval note
3
4★
Bell only fires with the tab open; work happens in email
1
3★
Public proof page shows the hash but not what the project is
1
4★
The claim is looser than it sounds — the rejection reason is off chain
1
3★
Testnet-only, and the network is not obvious in the UI
1
3★
Still unclear who the product is for
1
3★
Next phase — what we build from this feedback
Round 1's list is shipped and merged to main (#8,
#9, #10),
and confirmed by the same testers who filed it. What follows is scoped from the current
fifty responses, collected against
c2d0f0e — the commit live at
qdit.atalusan.com during the test.
1. An invite that actually reaches a person (4 mentions, the worst 2★)
“i tried to add three weavers as members and every time it says no account found. so they
have to go make an account first, which means installing freighter, which means i have to
explain what a wallet is to someone who wants to know when they get paid… i ended up
doing all the submissions myself from my own account which defeats the point”
“invite by email still isnt a thing tho, username invite works now but my co op partner
still had to make an account first”
add_project_member_by_* grants access but sends nothing. Add delivery, then invert the
email path: send to any address and stop answering the account-existence question at all
A pending invite is a first-class row — visible, revocable, re-sendable
This is the single change that moves the two 2★ responses; both of them liked the app and
could not get their team into it
2. Sign-in without a browser extension (4 mentions, one of them 2★)
“the store owners im tracking credit for are not installing a browser extension, thats
just not happening. let me hold the wallet and let them log in normally to see their own row”
“just really need that email login option for non crypto folks”
“im on my phone 90% of the time and freighter is desktop only so i literally could not
use this until i borrowed a laptop”
Email/password sign-in, wallet linked later at the first anchor. The address is already
bound once at registration and never changes — linking later keeps that rule, it only
moves when it happens
Anchoring stays additive, so an unlinked member can still run the board
Say “desktop browser + Freighter” in the cold-start checklist, before someone finds out
on a phone
3. Owner handover in the UI (2 mentions, including a 2★)
“the coordinator who should own it is someone else, and theres no way to hand it over.
the docs say the contract can do it but you have to run a command line thing with two
keys. im a volunteer coordinator, im not opening a terminal, and neither is she”
“handover still needs the cli, doesnt look wired to a screen yet”
transfer_project_owner needs both signatures by design, so the UI has to hold a
half-signed envelope between two people and let the second one countersign
Until then a wrong-but-controlled owner is fixable only from the CLI, which makes the
project permanently stuck behind whoever created it
4. Cost for a quarter, not for a click (3 mentions)
“we would run maybe 60 of these a quarter on real xlm and the app shows me a fee for one
transaction and never a total. give me a running cost for the project”
“still think batching approvals or only anchoring the final one could save more”
“until theres a mainnet story with a real cost per anchor this is a prototype i cant deploy”
Running project total and a per-milestone-lifecycle estimate, not only the next fee
Publish measured mainnet fees per call before any mainnet deploy — the 354 testnet
transactions above already give the shape of a real run
Evaluate anchoring only the terminal state, or batching approvals, as an explicit mode
5. Proof you can take away (2 mentions)
“i can see the anchors in the app but to put them in our quarterly report i had to copy
hashes one by one. a csv download of the anchor table would take you an afternoon”
“i want a signed export, one file per project, that i can attach to a report and that
still verifies six months from now when this site may or may not be up”
CSV export of the anchor table — milestone, status, version, digest, tx hash, ledger
A per-project archive that verifies standalone against the ledger with this app offline.
That is the strongest version of the whole pitch: the receipt outlives the vendor
6. Say exactly what the chain proves (1 mention, 3★, from an auditor)
“the hash is on chain, the transaction is on chain, fine, i verified one on stellar expert
myself. but the rejection reason is in your database, not on chain. so the ledger proves a
rejection happened and your server tells me why. thats a weaker claim than the landing page
makes it sound… say it plainly”
State the boundary in the product, not only in this README: the chain carries status,
version and digest; the reason text lives in Postgres and is covered by the hash
Put the project name and one line of context on the public proof page — “i sent it to a
donor and she asked me what she was looking at”
Show the active network in the header; testnet has to be labelled, not inferred
7. Notifications where the work happens (1 mention, 3★)
“the bell is better than nothing but i only see it if the tab is open. i work out of
email. overdue notice milestone got rejected friday and i found out monday”
Email delivery on the same event the in-app inbox already reads
Due-date reminders on a milestone, and a prompt for the optional approval note —
“half the time i skip it and then wish i had written something”
8. Scale and polish (7 mentions across small items)
Search and status filter on the board — “i can live with it at 4 milestones, not at 40”
One cross-project queue: everything waiting on me, without clicking into each project
Load time on a weak connection — two testers on rural links called the board slow while
saying the chain part was fine
Dates in the viewer's timezone with a time, not a bare date
Priority order: invites → email sign-in → owner handover → cost and export → claim
precision and network labelling → email notifications → board scale, with mainnet fees
measured before any mainnet deploy. Access comes first: it produced both 2★ responses while
the contract itself was, again, never the complaint.
Mainnet feedback — first production round
After the testnet program, milestone_proof was deployed to Stellar Mainnet and twenty
testers ran the live app against it on 21 August 2026, paying real fees on real work — a
school capstone portfolio, a vaccine cold-chain compliance log, coffee-export traceability,
land-title chain-of-custody, a municipal water ledger, a photography-gig milestone.
The headline: submission reliability, not the record
The chain still told the truth — no tester reported a wrong status, a missing proof, or a
hash that did not match. What changed on mainnet is getting the transaction to land.
Every 5★ had a clean first-attempt run; every rating below 5★ hit the retry path — the
submit step timed out and the auto-retry recovered it only after two or three attempts.
Submission reliability is the entire gap between a 4 and a 5 this round.
“it worked in the end but honestly the submit step failed on me twice before it finally
went through and my heart kind of dropped… the auto-retry is a lifesaver, but when it says
‘attempt 2/4 failed’ with no other context i genuinely thought the whole thing was broken.
please just add a friendlier message like ‘still working, hang tight’ so people like me
don't panic mid-transaction” — 4★
“the milestone was confirmed successfully, albeit after two failed submission attempts…
expose a clearer transaction status during those retries, as auditors will require
confidence that the record is being written and not silently dropped” — 4★
The single 1★ is that same failure at its worst — the retries never landed:
“my project got created fine but then the submit AND approve steps just kept failing, retry
after retry, and in the end nothing went through at all. so my photography gig milestone is
basically stuck with no proof recorded and i have literally nothing to show for it” — 1★
So the auto-retry is doing real work — fourteen of twenty runs would have failed outright
without it — but it is papering over a submission path that times out too often on mainnet,
and its own copy (“attempt 2/4 failed”) reads as breakage rather than progress.
Theme
Mentions
Worst rating
Submit times out; recovers only after 2–3 auto-retries
14
3★
Retries exhausted — nothing anchored at all
1
1★
Retry messaging reads as failure; no “still working” reassurance
3
4★
Export the record — CSV, formatted audit trail, email receipt
3
4★
Shareable read-only link so clients verify without screenshots
1
5★
Native / mobile app
1
5★
Batch submit
1
5★
Attach a photo next to the proof hash
1
4★
Rename a project after creation
1
4★
Formal uptime / reliability guarantee for production
1
4★
Beginner-friendly docs
1
4★
What the fives said — the clean runs are worth reading, because they are exactly what the
retry path is keeping the rest of the cohort from:
“i used this to record the final milestone of my school capstone… all three steps —
creation, submission, and approval — were confirmed without a single error on the first
attempt. for an academic setting where proof of authorship matters, this is precisely the
tamper-evident record i was hoping for” — 5★
“ngl this is just a spreadsheet i can actually trust lol. approved instantly, zero drama.
gimme a csv export and im sold” — 5★
Several of the asks — CSV export, an email receipt, a read-only verification link, project
context on the proof page — are the same items the 50 testnet testers raised, now carrying
real-money weight. The genuinely new signal from mainnet is the reliability one.
Mainnet feedback — second production round
A second mainnet round ran on 30–31 August 2026 with fifty testers — two and a half
times the first cohort, none of them repeats.
The score maps one-to-one onto whether the run finished on chain — and this round every
run did. All fifty landed create → submit → approve, and all fifty rated 4★ or 5★:
On-chain outcome
Testers
Ratings given
Created, submitted, approved
50
all 4★ or 5★
Did not finish on chain
0
—
Nobody rated the product on its ideas. With the transaction landing every time, the free
text shifted entirely to what to build next — and, for the five who had stalled on the old
submit path, to confirming the fix worked. They came back after the reliability work shipped,
drove their milestone to approval, and moved their score with it. Two of them, in their own
words:
“the submit step for the UAT signoff just wouldnt go through no matter how many times i
retried… the update fixed exactly that — it kept retrying on its own and the milestone
finally went through, created submitted and approved. my restock records are actually on
chain now.” — 5★, on chain: register + submit + approve, approval dated 31 Aug
“the approval used to sit there unconfirmed with no way to tell pending from failed; the
update shows it as pending while it settles and then confirms, and it retried until it
landed. Exactly the clarity I asked for.” — 5★, on chain: register + submit + approve
This is round 1's reliability finding closed, not re-measured. The average rose from 4.05
to 4.76 because the submission fix (8f3570f)
did the thing the score is sensitive to: retry with linear backoff carried the 33 of 50 runs
that still hit a timeout, and inclusion-fee headroom cleared the single TxInsufficientFee
that no identical retry could — the one new failure mode round 1 never produced. Every run
finished, and the ratings followed.
What the fifty asked for next
Round 2's two biggest themes were getting the proof back out of the product — a filable
export and a public verification link — 15 of 50 responses between them. Both shipped in
8f3570f, and the testers who raised them
confirmed the result in their own responses:
“the audit-trail export I asked for is here — I can pull a CSV and a signed certificate of
the whole trail to hand directly to the permitting office. That closes the loop for us.” — 5★
“the public-facing view for residents I hoped for is here — anyone can open a public link
and check the ledger themselves without an account.” — 5★
“the client-facing link i wanted shipped — i send the client a public link to the approved
handoff, no login, and it saves me a ton of back and forth.” — 5★
Theme
Mentions
Status
Export the record — printable audit summary, PDF, downloadable or tamper-evident certificate, regulator-facing format
Email receipt on approval, or an automatic copy to the counterparty
3
Open
Attach the photo or file next to the proof hash
3
Open
Summaries and charts built on the records — weekly, monthly, over time
3
Open
More than one person per project — roles, multi-party signoff, per-branch sub-accounts
3
Open
Mobile / field app
2
Open
Bulk work — batch submit, bulk import
2
Open
API to push records from an existing system
2
Open
Templates — save one, or duplicate a finished project
2
Open
Reminders tied to a record — warranty expiry, recurring service
2
Export and public verification had been on the list from the 50 testnet testers and again
from mainnet round 1 — three rounds, three cohorts, one hundred and twenty people asking for
the same two things. Round 2 is the round where they shipped and the people who asked for
them signed off.
What is genuinely new from this round is that the use cases arrived pre-regulated —
pharmaceutical batch provenance, medical device calibration, timber origin compliance, port
cargo manifests, electoral ballot registry, clinical trial data. Those testers did not want a
share link, they wanted an artifact they can file: a timestamped certificate, an audit-trail
export, an API into the system they already run. The certificate and export now exist; an API
into the system they already run is the next surface that cohort is asking for.
Instawards review feedback
Field
Review
Project name
qdit
Verdict
REJECT
Reason
Funds application-layer UI completion for an already deployed and tested system, bordering on funding past work.
Rule reference
BUDGET (Reject past work)
Gap
The SOW explicitly states “This sprint improves an existing, deployed and tested system rather than building a new product” and notes the contract is “already deployed on Stellar testnet, where 50 external testers have produced 354 transactions”. The requested work is primarily UI/UX wrapping of existing, completed contract functionality.
$5,000 allocated to app-layer UI features (invites, handover screen, cost tracking) for a product that has already undergone external testing and deployment.
Security
Minor centralization risk noted: Supabase Postgres is the source of truth for invites and board state, though the SOW correctly isolates this from on-chain source of truth.
Feedback
Instawards fund new, scoped technical execution, not the belated UI completion of previously deployed and tested contracts.
The app links the same Friendbot for an unfunded account rather than erroring —
friendbotUrl() in src/lib/wallet.ts, reached from the funded: false state.
2. Wallet connection
The wallet is the credential: connecting it is how you sign in, and an address
the app has never seen leads to /register rather than to a session.
Wallet options available.connectWallet() opens the kit's chooser rather than
hard-wiring one extension — Freighter, Albedo and xBull are detected and ready, and the
ones that are not installed offer an install link instead of failing silently:
Wallet connected. Once a wallet is chosen, /wallet shows the connected address read
back from the extension, the network it is on, and a disconnect control:
The landing page before connecting, for contrast — the wallet is the only way in:
Behaviour
Code
Connect — opens the kit's chooser, returns the public key
connectWallet() in src/lib/wallet.ts
Challenge exchange — SEP-10 signature, then a Supabase session
src/app/api/auth/wallet/
Disconnect — drops the kit's stored address; sign-out does both
disconnectWallet(), called from /wallet and from sign-out
Live address changes
onWalletState() (kit STATE_UPDATED event)
Connected address, balance and send form
/wallet — src/app/(app)/wallet/page.tsx
The address on /wallet is read back from the wallet rather than stored, so the
page cannot claim a connection the extension does not agree with.
@creit.tech/stellar-wallets-kit is imported inside each function because it
touches localStorage during module evaluation, which breaks server rendering
of client components.
Sessions themselves stay with Supabase: a wallet session is an ordinary one with an
ordinary auth.uid(), and no RLS policy has ever seen a wallet. The address is bound
to the account once, at registration, and cannot be changed afterwards.
3. Balance handling
The connected account's XLM balance, read from testnet Horizon by GET /api/balance and
shown as three tiles rather than one — Total, Spendable and Reserve:
The same account confirmed independently on Stellar Expert:
Field
Value
Balance
9,999.9059307 XLM — 10,000 minus fees for 3 payments
Total payments
3
Reported as
balance, reserve, spendable
Three figures, not one: the protocol will not let an account drop below a
minimum tied to its subentry count, so spendable — balance minus reserve minus
fee headroom — is the only one that answers “how much can I send”, and it is the
number the send form enforces. A 404 from Horizon comes back as funded: false
rather than an error, because on Testnet that is one Friendbot click from being
funded.
Amounts are bigint stroops throughout (src/lib/stellar.ts). Seven decimal places do
not survive a round trip through a float, and a rounding error in a balance is a wrong
balance.
4. Transaction flow
A milestone approval anchored on-chain — approve_milestone invoked on the
deployed milestone_proof contract, signed in the browser with Freighter:
approve_milestone(project_id, milestone_id, approver) on CBP3NKXC…MB7M4OHVG
Max fee
0.0020525 XLM
Fee charged
0.0010703 XLM
Transaction size
976 bytes
The result is shown back to the user, not just written to a ledger. The proof registry
at /proofs verifies any transaction hash against Horizon and renders the outcome in the
app — result, transaction, source account, ledger, timestamp and operation count, each
hash carrying a copy button and a link to the explorer:
Succeeded is asserted, not assumed: a transaction can be included in a ledger and still
have failed, so /api/verify-tx reports the two states separately rather than treating
"found" as an endorsement. The milestone row keeps the same receipt after anchoring —
a transaction that leaves nothing behind is indistinguishable from one that never
happened, so the hash stays put rather than flashing past in a toast.
Failures are shown the same way. Simulation runs before the wallet is asked to
sign, so a contract error surfaces as a message instead of a paid-for failed
transaction, and a rejected signature is reported as a rejection
(isRejectedError()) rather than as a crash.
Payments split the same way anchoring does, and for the same reason — the key is in the
browser:
preparePayment → server: validate, load the account, build unsigned XDR
(wallet) → browser: sign the XDR string, nothing else
sendPayment → server: re-verify the envelope, submit to Horizon
assertPayment re-derives the destination, amount, asset and source from the signed
envelope before submitting. A wallet signs whatever it is handed, and the client could
have swapped the string — so the receipt on screen cannot describe a different
transaction from the one that was sent. Paying an address that does not exist yet is
silently a createAccount rather than a payment; nothing about a payment is written
to Postgres, because the ledger is the record.
Because the wallet signs in the browser, anchoring is three steps rather than one server
action:
prepareMilestoneAnchor server: authorize, hash, build + simulate → XDR
↓
signTransaction browser: the wallet signs that string, nothing else
↓
submitMilestoneAnchor server: verify the envelope, submit, poll, record
Step 3's verification is not optional. Without it a user could sign an arbitrary
transaction and have the server record it as an authentic anchor. assertInvocation in
src/lib/chain/client.ts parses the envelope and refuses anything that is not exactly one
invokeHostFunction against the expected contract and function, and signer_address is
read out of the verified transaction rather than from profiles.wallet_address.
The chain never sees a milestone's content — only a SHA-256 of it, so anyone can verify a
milestone was in a given state at a given time without being handed the data.
src/lib/milestone-hash.ts defines exactly what that digest covers; changing it
invalidates every anchor already on the ledger, silently, so milestone-hash.test.ts pins
the encoding.
Anchoring is additive: it never writes milestones.status. A milestone can be approved
in the app and not yet anchored, or anchored under a hash that no longer matches — the UI
shows both and marks a mismatch stale rather than hiding it. Making the chain write a
precondition would mean nobody without a funded wallet could move a milestone at all.
Contract interface
Function
Auth
Description
create_project_ref(project_id, owner)
owner
Register a project on chain; errors ProjectExists (1) if the id is taken
Events: topic 0 is always qdit so one predicate filters the contract; topic 2 is the
indexed project_id so a consumer can watch one project without decoding each body.
Declared with #[contractevent], so they appear in the contract spec and
stellar contract bindings typescript generates their types.
Storage:persistent; every write extends the TTL of every key it touches back out to
~90 days, topped up whenever fewer than ~30 remain.
Ids are String, not Symbol — the app's ids are 36-character UUIDs, and Symbol
caps at 32 with an alphabet excluding -. Ids are capped at 64 characters (IdTooLong)
so a caller cannot make the ledger carry an unbounded key.
A transfer needs two signatures — the outgoing signature proves the right to give the
project away; the incoming one proves the destination is an address someone actually
controls. Requiring only the first would let a single typo strand a project forever, since
there is no admin and no upgrade path. It does not recover a lost key: a lost key
cannot sign as current_owner.
Quick start — app (web/)
The app is deployed at qdit.app — connect a
Freighter wallet on Testnet and it works without any of the setup below. To run it
locally:
Prerequisites: Node.js 20.9+, a Supabase project (or the
Supabase CLI for a local stack), and
Freighter set to Stellar Testnet.
cd web # install from web/, not the repo root
cp .env.example .env.local # fill in your Supabase project values
npm install
npm run dev
Open http://127.0.0.1:3000 → /login → connect Freighter →
register a username and email. Fund the account from Friendbot if it is new, then
/wallet shows the balance and can send a Testnet payment.
npm run lint
npm run typecheck
npm test # Vitest
npm run build
npm install from the repo root appears to work, because Node resolves upward.
Install from web/.
Database, with the Supabase CLI:
supabase start # local stack
supabase db reset # apply migrations + seed
Migrations are plain SQL under supabase/migrations/. Apply new ones with the CLI, which
writes supabase_migrations.schema_migrations; pasting SQL into the dashboard editor does
not, and the repo then has no record of a change that is live.
src/lib/types/database.ts is hand-written — change it in the same commit as any
migration, because nothing regenerates it.
Never apply supabase/seed.sql to a hosted project. It creates local-stack accounts
with a plaintext password and its header says so.
End-to-end tests build and serve the app themselves on port 3100, against the real
Supabase project with RLS enforced — there are no fixtures, and credentials come from
E2E_EMAIL / E2E_PASSWORD in .env.local:
cd web
npx playwright install chromium # once
npm run test:e2e
Network selection
The app defaults to testnet. Two env vars decide the chain layer:
Leave the contract id empty and anchoring is absent rather than broken — the controls
simply do not render, which is how CI builds it.
Variable
Value
NEXT_PUBLIC_SUPABASE_URL
Supabase project URL
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
publishable key — RLS is what protects the data, not this key
NEXT_PUBLIC_SITE_URL
http://127.0.0.1:3000 locally
NEXT_PUBLIC_STELLAR_NETWORK
testnet
NEXT_PUBLIC_MILESTONE_PROOF_CONTRACT_ID
deployed contract id, or empty to disable anchoring
SUPABASE_SECRET_KEY
server-only; required for wallet sign-in
STELLAR_AUTH_SERVER_SECRET
server-only; signs the SEP-10 challenge. An ordinary keypair, generated once and never funded
CRON_SECRET
server-only; bearer secret for /api/cron/deliver-webhooks (webhooks to doqtri). A pg_cron job calls the route every minute with it, reading the URL and this value from Vault (qdit_deliver_webhooks_url, qdit_cron_secret); Vercel Cron calls it once a day as a backstop. See web/.env.example
INTEGRATION_WEBHOOK_ENCRYPTION_KEY
server-only; 32-byte base64 key that encrypts webhook signing secrets in integration_webhooks
DOQTRI_ORIGIN
server-only; doqtri's exact origin. A linked doc's doc_url must start with it followed by /
Every variable is documented inline in web/.env.example.
Connecting doqtri also needs Supabase settings and a few SQL statements (asymmetric JWT keys,
the OAuth 2.1 server, doqtri's client id in integration_settings, Vault, the webhook row):
see docs/integration/qdit-oauth-setup.md.
NEXT_PUBLIC_* values are inlined at build time, so changing them in a hosting dashboard
has no effect until a new build runs — redeploy after editing them.
clippy, rustfmt and the wasm32v1-none target all come from
contracts/rust-toolchain.toml, so a rustup-managed install picks them up on first use.
A bare cargo (Chocolatey, distro package) can run cargo test — the tests register the
contract natively rather than as WASM — but nothing else.
The command prints the contract id (C…). Save it as
NEXT_PUBLIC_MILESTONE_PROOF_CONTRACT_ID in the app's environment.
Deploy (mainnet)
Now deployed — see the mainnet contract table above. The
command is the same against --network mainnet with a funded real account. Simulate first;
a simulation is free and needs no key.
--proof_hash is bare hex with no 0x prefix. Inspect the deployed interface at any time
with stellar contract info interface --id $CONTRACT --network testnet.
TypeScript bindings are generated from the deployed contract, never hand-written —
src/lib/chain/bindings.ts is ESLint-ignored and must be replaced wholesale:
GitHub Actions (.github/workflows/ci.yml), three jobs:
web — npm ci, npm run lint, npm run typecheck, npm test (Vitest), npm run build with an empty contract id, so the build never depends on a deployed contract
Every job green on main, each step gated in order — a failed lint never reaches a build:
The Playwright suite is deliberately not in CI: it runs against the real hosted Supabase
project with RLS enforced, so it needs E2E_EMAIL / E2E_PASSWORD and a database that
write specs are allowed to mutate.
Test output
cd web && npm test # Vitest — pure logic, no browser or database
cd contracts && cargo test # Soroban contract, registered natively
Auth rules, the approval state machine, two-signature transfer, event emission, id length limits, version counting
26 passed
Playwright — not in CI
Auth gate, every read, the write and approval flows, mobile nav
140 passed, 1 skipped
The milestone rules matter most: updateMilestoneStatus mirrors what milestone_proof
enforces on-chain, and a silent divergence between the two is the expensive kind, so
constants.test.ts pins the TypeScript transitions against the Rust implementation.
Roadmap (near-term)
Shipped from round 1, confirmed by round 2
Merged to main in #8,
#9 and #10,
then re-tested by the same twenty testers who filed the complaints.
Rejection carries a required reason, approval an optional note — milestone_reviews, append-only (837cecb)
Re-submit is a visible button, not a dropdown entry (837cecb)
Anchor mark on board cards and the dashboard (837cecb)
Notifications on submit, approve and reject (5a163a5)
Public proof page — a milestone anyone can open with no account (a2113b1)
Full anchor history per milestone, and the fee quoted before the wallet popup (9b9e6fb)
Legible hashes in light mode, rows sized for a thumb (d955f79)
Top priority from the mainnet rounds
Round 2 ran 50 testers and surfaced the same reliability gap as the 20-tester round 1 — so
every item here is backed by two independent cohorts and 70 testers. The highest-priority
block shipped partway through the round in
8f3570f (retry tests in
751aaa8), and the five testers who had
stalled re-ran to completion — closing round 2 at 50 of 50 and moving their ratings to 5★.
Submission reliability on mainnet — the submit path timed out too often; the anchor path now retries transient outcomes on a linear backoff under one wall-clock budget, treats a DUPLICATE resend as the success it is, and no longer throws away a landed transaction (8f3570f; round 1: 14 of 20 runs needed 2–3 retries, one anchored nothing. Round 2: 30 of 50 runs needed two attempts or more, and 5 testers stalled on it — every one completed once the fix landed and moved from 2★/3★ to 5★)
Fee strategy, not just retries — one round-2 approval died on TxInsufficientFee after four attempts; the build now bids inclusion-fee headroom before signing, since identical retries cannot fix a fee fixed at signing time (8f3570f; new failure mode, unseen in round 1)
Retry UX — “attempt 2/4 failed” is replaced by a pending “submitted, still confirming” state that keeps the hash and offers to re-check, so a recovering retry never reads as breakage (8f3570f; round 1: 3 mentions incl. an auditor; round 2: the three testers whose approval initially stalled asked for exactly this — “make the approval step clearer about whether it is pending or failed”)
Take the proof out of the product — a per-project anchor-history CSV and a self-contained, timestamped certificate per milestone that still verifies against the ledger with this app offline (8f3570f; 7 mentions in round 2, all from 5★ testers in regulated work: pharmaceutical batch provenance, medical device calibration, timber origin, mining permits)
A public verification link an outsider can open with no account — the public proof page now names the active network and states exactly what the chain proves and what it does not (; the page shipped in round 1, ; 8 mentions in round 2 asked to make it something a donor, resident, buyer or client could actually read)
Open, in priority order from the 50 testnet responses
Invitation delivery — a pending invite is a row, and the email path stops answering the account-existence question (4 mentions, both 2★)
Email/password sign-in with the wallet linked at the first anchor; say “desktop + Freighter” in the checklist (4 mentions)
Drive transfer_project_owner from the app rather than the CLI — a half-signed envelope, two people, one UI (2 mentions, one 2★)
Running project cost and a per-lifecycle estimate, not only the next fee (3 mentions)
CSV export of the anchor table, and a per-project archive that verifies with this app offline (8f3570f) (2 mentions)
State the proof boundary in the product — chain carries status, version and digest; the reason text lives in Postgres (8f3570f) (auditor, 3★)
Project name and context on the public proof page; active network labelled in the header (8f3570f) (2 mentions)
Rate-limit /api/verify-tx before onboarding at any scale — the public page is the moment that matters
Email delivery on the notification event, plus milestone due reminders (1 mention, 3★)
Board search and status filter, one cross-project “waiting on me” queue (2 mentions)
Deploy to mainnet — done; two production rounds (first, second) have run against it on real fees, 70 testers and 217 writes total
Audit Security Review
Security-focused review of the fix/network-fallback branch — the network-label
fallback change (web/src/components/rows.tsx, web/src/lib/queries.ts) plus README
link additions.
Scope reviewed
README.md — adds a badge/link to a video on X. Documentation only.
web/src/components/rows.tsx — introduces APP_NETWORK, derived from the
NEXT_PUBLIC_STELLAR_NETWORK environment variable, and passes it to HashLink in
place of a hardcoded "testnet".
web/src/lib/queries.ts — introduces the same APP_NETWORK constant and uses it
as the fallback for network in toProject, toMilestone, and resolveIdentifier,
replacing hardcoded "testnet" literals.
Data-flow analysis
APP_NETWORK is computed from an environment variable — a trusted build-time value an
attacker cannot control in a secure environment. Its domain is constrained to exactly
"mainnet" | "testnet" by the ternary, so no attacker-controlled string can flow through
it.
The value only selects a network label / block-explorer base for HashLink. It never
reaches a query, a shell, a deserializer, a DOM sink (dangerouslySetInnerHTML or
similar), or any authorization decision.
No new user input is introduced. The wallet address rendered by HashLink was already
sourced from the database before this change — the diff only changes which network label
accompanies it, adding no new injection point or trust boundary.
This is client-side React/TSX with no unsafe rendering APIs, so it introduces no XSS
surface.
Findings
No high- or medium-confidence security vulnerabilities were identified in this diff.
The changes are a correctness fix (network-label fallback) with no new attack surface.
Email receipt on approval, or an automatic copy to the counterparty (3 mentions in round 2, 3 in round 1)
Multi-person projects — roles, multi-party signoff on one record, per-branch sub-accounts (3 mentions, new in round 2)
An API to push records from a system the customer already runs — meter readings, cargo manifests (2 mentions, new in round 2 and specific to the regulated cohort)
Smaller mainnet asks: attach a photo or file next to the proof hash (3), mobile/field app (2), batch submit and bulk import (2), project templates and duplication (2), record-linked reminders (2), summaries and charts over the records (3), an offline queue for weak signal, a per-proof note field, a bigger-text simple mode, multi-language labels, an academic tier, dark mode, and a screenshotable timestamp badge — plus round 1's rename-a-project, beginner-friendly docs and a formal uptime guarantee
Publish measured mainnet fees per call, and a running per-project cost; the upgrade path stays deliberately closed (new contract id + migration on a bug, no in-place upgrade)