This is a submission for the MLH x DEV Writing Challenge
What I Built
Market traders in Lagos sell all day, mostly for cash or bank transfer. None of it adds up to a record a lender can trust, so when stock runs out mid-week there's no credit to fall back on.
I built Òjà for the MLH Solana Hackathon. It's a Solana Pay checkout where:
- every sale writes a receipt on Solana in the trader's name,
- that record unlocks a small working-capital advance, and
- the advance repays itself from 20% of every later sale, split by the program in the same transaction.
In Yoruba, òjà is the cloth a mother uses to carry her child on her back while she keeps working. That's the idea: credit that carries you while you sell.
It was my first Solana program, so the rest of this section is mostly about the parts that surprised me.
The whole idea is one instruction
I expected the lending side to be the hard part: tracking who owes what, sending reminders, collecting. On Solana it collapsed into the payment itself. The pay_invoice instruction in the Anchor program does all of it at once:
- checks the payment is real (right token, not the shop paying itself),
- creates a receipt account for the invoice,
- if the shop has an active advance, sends the lender's share first,
- sends the rest to the shop,
- updates the shop's sales count, volume and unique customers.
The repayment part is short:
if advance.status == AdvanceStatus::Active {
let lender_token = accounts
.lender_token
.as_ref()
.ok_or(OjaError::AdvanceAccountsMissing)?;
require_keys_eq!(lender_token.owner, advance.lender, OjaError::WrongLenderAccount);
let remaining = advance.total_due.checked_sub(advance.repaid).ok_or(OjaError::Overflow)?;
let share = (amount as u128)
.checked_mul(advance.repay_bps as u128)
.ok_or(OjaError::Overflow)?
/ BPS as u128;
lender_share = (share as u64).min(remaining);
// transfer lender_share to the lender, then the rest to the shop
}
Because the split happens inside the payment, there's no "remember to repay" step to skip. If the client leaves out the advance accounts, the program fails with AdvanceAccountsMissing. If it points the lender's share at another wallet, it fails with WrongLenderAccount. In my demo, a 14.20 USDC sale sent 2.84 USDC to the lender before the shop saw a kobo.
Receipts are just accounts, and that's the point
I briefly considered minting each receipt as an NFT. I didn't, because a credit record you can sell isn't a credit record. Instead each receipt is a plain program account at an address derived from the shop and the invoice id:
#[account(
init,
payer = fee_payer,
space = 8 + Receipt::INIT_SPACE,
seeds = [RECEIPT_SEED, merchant.key().as_ref(), invoice_id.as_ref()],
bump
)]
pub receipt: Account<'info, Receipt>,
That gives two things for free. Anyone can find a receipt without an index. And since an account can only be created once at a given address, paying the same invoice twice simply fails. That's the on-chain version of the double-fulfilment problem pandawm writes about for crypto checkouts.
Making the history hard to fake
Credit only works if the record is real, so most of my time went into ways to cheat. The program rejects:
| Attempt | Rejected with |
|---|---|
| Paying your own shop | SelfPayment |
| Paying with another token | WrongMint |
| Paying the same invoice twice | receipt already exists |
| Paying without repaying the open advance | AdvanceAccountsMissing |
| Sending the lender's share elsewhere | WrongLenderAccount |
| Borrowing beyond the record |
AdvanceTooLarge / AboveCreditLadder
|
Advances are capped at 30% of sales volume and climb a ladder (10, 25, 50, then 100 USDC) only as earlier advances are repaid. Off-chain, the app also checks whether the shop owner has sent money to any of its "customers" and shows that warning to lenders. I tripped it myself on day one by sending SOL between my test wallets, and the shop is still flagged.
The honest limit: someone with several unlinked wallets can still fake a small history. The 10 USDC first rung is there so that costs more than it pays. (The demo shop in my video was seeded this way, and every seeded sale says so in its on-chain memo.)
AI writes, code checks
Gemini does two jobs, and neither is trusted on its own:
- Invoices. The trader types "3 plates of jollof 1,500 each, 2 Coke 500". Gemini (Flash-Lite, through the Vercel AI SDK with structured output) returns items, quantities and prices. Code then re-adds every line and the total and shows the checks under the invoice.
- Lender notes. Gemini summarises the shop's on-chain record for lenders. Code pulls every number out of the note and matches it against the record, and anything it can't match is flagged. My first version happily wrote "two customers" in words and the checker reported "all 0 numbers match", so it now rejects number words and retries with the next model.
The naira price: no oracle had it
My first version converted naira at a fixed rate. When I went to replace it with an oracle, I found that Pyth lists 40 currency pairs and naira isn't one of them, and Switchboard has no ready-made feed either. Pyth's Hermes API also now needs an API key.
What does work without a key is reading Pyth's price accounts on Solana directly. So each invoice now uses:
- USDC/USD from Pyth's on-chain price account, accepted only if Pyth's receiver program owns it, it carries the USDC feed id, and it's under 10 minutes old,
- USD/NGN as the median of two independent FX sources, flagged if they disagree by more than 3%, and refused outright if neither answers.
The fixed rate turned out to be about 15% off the market. As this post on showing market data's age argues, a price without its source and age is a trap, so each invoice shows where its rate came from. These sources still give the official rate, not Nigeria's street rate; letting the trader override it is next.
Demo
- Live on devnet: https://oja-sash.vercel.app
- Code: https://github.com/emarc99/oja
On any shop page, open the Try to cheat panel. It builds each attack from the table above and runs it through simulateTransaction against devnet, so you can watch the program refuse it.
Partner Technologies
Solana is the technology this hackathon was built around, and Òjà leans on three parts of it.
-
An Anchor program on devnet. It holds the shops, receipts and advances, and
pay_invoicedoes the payment, the receipt and the lender split atomically. Program-derived addresses do the work an index and a duplicate check would do elsewhere. - Solana Pay transaction requests. Solana Pay is a URL standard, not a payment processor, and its transaction request flavour lets my server build the exact transaction the customer signs. That's how the receipt and the split get into every payment without the customer's wallet knowing anything about Òjà.
- Fee sponsorship. A sponsor wallet is set as fee payer and signs first, so the customer's wallet only approves the USDC transfer. For a market customer, "you need SOL to pay in USDC" would have killed it.
Working with it, the program was the easy part. Getting each "Try to cheat" simulation to fail for the intended reason took longer than writing the checks: my first attempts kept failing on setup errors like uninitialised token accounts instead of reaching the rule I wanted to show.
Other tools: Gemini through the Vercel AI SDK, Pyth's on-chain price accounts, Helius RPC, Next.js on Vercel, and Cursor.
Hackathon Experience
I built Òjà solo at the MLH Solana Hackathon (Nov 6–8, 2026). Before that weekend, my only Solana code was the week-one exercises from Turbin3's Q4 2025 cohort, so this was my first real program, and I entered it for Top 3 Overall, DeFi, AI and Best Beginner Hack.
What I'll remember most is the moment the first repayment landed: one customer payment, and the lender's 20% arrived in the same transaction as the shop's share, with nothing for anyone to chase. The other is finding out that no oracle publishes a naira price, which turned a one-line change into the most interesting part of the build.
What I'd do differently:
- Start with a private RPC. The public devnet endpoint rate-limited me into 20-second page loads until I moved to Helius.
- Plan for wallet account switches. Phantom kept a stale connection after I switched accounts, so payments failed with "not connected" until the page reconnected.
-
Watch what Anchor's client pulls into the browser. It tried to bundle Node's
fs, which I had to stub.
It runs on devnet only. Built with help from Cursor, with the program, app and this write-up reviewed by me.
If you've built anything with revenue-based repayment on-chain, or know of a naira price feed I missed, I'd love to hear about it in the comments.
Top comments (0)