Leveraged Mining 2025 Updated
One system for mining clients, coaches and referral partners
Four services behind one business: a Go API over a mining pool with no product API, a client portal, an admin console, and a partner platform that turns CRM deals into commission owed.
- Role
- Full-stack engineer, led v2 work
- Timeline
- Sep 2025 – Oct 2026
- Team
- Solo build, client as product owner
- Stack
- Go, Fiber, Postgres, Next.js, Supabase
Overview
One system for mining clients, coaches and referral partners
Leveraged Mining sells hosted bitcoin mining alongside a coaching program. That means three groups of people who need completely different things from the same business. Clients want to see their machines earning. The team running the program wants to see who is engaged and who has gone quiet. Referral partners want to know what they are owed and when.
I built the system that serves all three. I joined on the frontend, moved to full-stack when the backend developer left, and shipped v1 in three months; the client portal served about 200 clients. The engagement ran from September 2025 to October 2026. A Go API owns the data and the pool integration. Three Next.js applications sit on top of it: a client portal, an internal admin console, and a separate partner platform with its own database and its own login. They share nothing except the business they describe, which was deliberate.
- 2
- Databases, and one upstream nobody controls
- 70
- Test files on the money path
- 29
- Schema migrations shipped
Problem
Numbers that existed, but nobody could safely reach
Every number the business needed already existed somewhere. Mining performance sat with the pool. Deals sat in the CRM. Coaching hours sat in whatever the client happened to write down. None of it was reachable by the person who actually needed to look at it, and every workaround was some version of a human copying figures between two screens.
The pool was the sharpest version of it. The product surface exposes the account view and nothing narrower, so anything per client had to be derived rather than requested. So the numbers were technically available and practically unusable, which is a worse position than not having them at all.
- No contract to build againstThe upstream surface carries no version and no compatibility promise. Anything built on it has to assume the shape can move.
- Access was all or nothingThere was no way to show one client their own machines without exposing everyone else's alongside them.
- Payouts were reconciled by handEvery cycle, someone exported the CRM, worked through it in a spreadsheet, and hoped the two agreed. Nobody could answer why a figure was what it was six months later.
- Nobody could demo the productShowing the portal to a prospect meant showing either a real client's earnings or an empty account.
Discovery
What the upstream turned out to be
The first week went into understanding the integration rather than writing it. What came back changed the plan twice.
The pool's data is real but the surface around it is not a contract, so nothing coming out of it can be trusted to keep its shape. That pushed every field through defensive parsing and pushed the whole integration behind one boundary, so the rest of the system would never learn what the upstream looked like.
The second finding was better news. Underneath that surface, access can be scoped to one client's workers, which meant permissions did not need a bespoke model at all. Attach the scope to the client record and the question collapses into ordinary row ownership, which is a problem every part of the stack already knows how to answer.
The CRM had its own surprise. The record the business calls a deal was not the record the earliest rows had been keyed by. A naive first sync would have duplicated every historical deal, which is the kind of thing you find in week one or explain in month six.
System design
Two systems that stay out of each other's way
Four applications, two databases, one upstream nobody controls. A request starts at a client, passes an edge that decides who is asking, reaches exactly one service that owns those rules, and only then touches data or something external.
Clients
Edge
Services
Data and upstream
Clients
- Operator channelHighest privilege actions
- Operator guard
- Client portalMachines, payouts, logs
- Session + role guard
- Admin consoleClients, packages, content
- Session + role guard
- Partner portalDeals and commission
- Partner session
Edge
- Operator guardOut of band, not a browser session
- Go API · admin lifecycle
- Session + role guardClient and admin
- Go API
- Partner sessionSeparate credentials entirely
- Sync + commission
Services
- Go APIAccounts, activity, packages, content
- Mining database
- Pool adapter
- Pool adapterThe only code that knows the upstream
- Mining pool · scoped per client
- Sync + commissionPricing engine, run lock
- Partner database
- CRM · scheduled pull + webhook
Data and upstream
- Mining databaseSystem of record
- Mining poolNo product API
- Partner databaseSeparate system of record
- CRMSource of deals
Solid lines are request paths. Dashed lines are out of band: a scheduled pull, a webhook, a notification that fires after the response has already gone out. The partner platform sits on its own database on purpose, so nothing in the mining system can read a partner row and nothing in the partner system can read a client's mining data.
Two rules hold the shape. Nothing skips a layer, and nothing reaches sideways into a sibling's data. A request handler knows about the web and nothing else. A service knows the rules and owns the money. Storage answers questions and holds no opinions. It sounds like bookkeeping until the sixth month, when the reason a package is still readable is that it never grew a shortcut.
The pool adapter is the sharpest example. It is the only code in the system that knows the upstream exists. Every other package asks for a number and gets a number, or gets an error already phrased for a person. Nothing outside that boundary has ever had to care how the numbers are obtained, and when the upstream shifts, exactly one package changes.
That boundary owns the traffic policy too, because it is the only place that knows the upstream is slow and occasionally hostile. Every call carries a deadline. Failures retry a bounded number of times with backoff and jitter rather than hammering something already struggling, and sustained failure opens a breaker so the system stops trying for a while instead of queueing up behind a dead dependency.
The client dashboard is why that matters in practice. A home screen asks for several different figures at once, and the naive shape is one upstream call per card. Instead the adapter reads through a short lived cache scoped to the client, and collapses concurrent misses into a single in flight request, so ten cards rendering together produce one call rather than ten. When the breaker is open the portal serves the last good reading with its age on it, because a number from four minutes ago is worth more to a client than a spinner that resolves into an apology.
A client opens their dashboard
The session is verified against current account state rather than trusted from the token alone, so access revoked a minute ago is revoked on this request and not at the next expiry.
On failureAn unverified or revoked session is refused before any business code runs.
Identity comes from the session, never the request
Whose data is being asked for is decided by the verified session and nothing else. There is no path where the answer comes from something the caller supplied.
The service resolves the account
A demonstration account never reaches the network at all. Its numbers come from a generated dataset instead, so the portal can be shown to a prospect without exposing anyone real.
The cache answers first
A recent reading for this client is served straight back. Several cards asking at the same moment share one lookup rather than racing each other upstream, and a miss is what actually reaches the network.
Live reads go through the adapter
The client's own scope is what the upstream is asked about, which is the mechanism that makes one client's view structurally incapable of containing another client's machines. The call has a deadline, a bounded retry, and a breaker behind it.
On failureTimeouts, throttling and malformed answers resolve to one short sentence the client can act on, or to the last good reading with its age shown. The upstream's raw response is kept for diagnosis and never rendered.
Parse defensively, degrade honestly
Anything that does not match the expected shape is skipped rather than crashing the request, and an account with nothing in it yet reads as empty rather than as a failure. A new client with no machines is a normal state, not an error.
Return one type
Demonstration and live data return the same shape, so the interface has no branch for which one it is rendering. That is what stops the demo path from quietly rotting while nobody looks at it.

“I worked with Zana on our leveraged mining project, and he proved himself when it mattered most. When our backend developer left, he moved from frontend to full-stack almost overnight, cleared every blocker, and shipped a successful v1 on time.”
Correctness
Numbers that have to agree with themselves
One question shaped most of the schema: store the total, or compute it. The obvious move for a number the admin console shows constantly is to keep a running counter beside it. I did not, because a counter is a second source of truth, and it goes wrong quietly the first time a record is edited or deleted. Nobody notices until someone asks why a figure moved. Anything describing the present is computed from the underlying records, every time.
Anything describing a moment goes the other way. A figure that was published, acted on, or paid against is not a fact about now. It is a statement about what was true on a date, under rules that were in force on that date. Recomputing it later against today's rules would silently rewrite history the first time anything changed, so those figures are written down when they are settled and never derived again.
That is the rule the whole model runs on. Describes the present, compute it and keep one source of truth. Describes a moment, record it and treat it as immutable. Getting that backwards is how reporting systems end up disagreeing with themselves, and why the answer to why is this number what it is turns into a shrug.
Ingestion is the other half of correctness. Inbound events from the CRM are authenticated before anything is read out of them, and they survive a key rotation on the sender's side without dropping anything on ours. Each event carries an identity of its own, so arriving twice does not mean applying twice. At-least-once delivery is a promise every webhook makes and every integration eventually collects on.
The scheduled reconciliation exists because webhooks alone are not a guarantee. A pull on a schedule closes the gap left by any event that never arrived, and because both paths write through the same idempotent step, a record that came in twice by two different routes still lands once. Pull and push together are what make the data eventually correct rather than usually correct.
Decisions
The calls that were not obvious
Four decisions did most of the work. Each one had an obvious option that would have been faster to ship and worse to live with.
| Challenge | Obvious option | What I did | What it cost |
|---|---|---|---|
| Upstream with no contract | Call it from wherever the data is needed | One adapter that owns every fact about the upstream, including its traffic policy | An extra layer to write. It paid for itself the first time the upstream changed shape and exactly one package needed touching. |
| A dashboard of many figures | One upstream call per card | Read through a per client cache, with concurrent misses collapsed into one in flight call | Readings can be a few minutes old. On a metric that moves slowly, that is a trade worth making for a page that paints immediately. |
| Outsiders in the same system | One database, a role flag, shared sessions | A separate application with its own store and its own accounts | Two deploys instead of one. In exchange, no authorisation mistake on one side can reach the other. |
| Re-keying live records | A one shot migration over everything | A resolver that prefers the stable key and falls back to the legacy one, adopting as it goes | The fallback path has to live until it stops being used. No downtime and no duplicated history. |
The demo problem was the one I did not expect to be interesting. The team needed to show the portal without a real account behind it, and both obvious options were bad: a real client's account is a privacy problem, and an empty one sells nothing. So a demonstration account is served generated data instead, derived deterministically rather than randomly. The same account shows the same believable figures all day and moves overnight, which means a call can be run twice without the story changing underneath it, and a screenshot taken in the morning still matches the product in the afternoon. It returns the same shape as the live path, so the interface cannot tell the difference and the demo cannot quietly rot.
Scheduled work is the other place where the obvious version is wrong. The reconciliation runs on a schedule and can also be started by hand, which means it can overlap with itself. Both entry points claim a lock first, so a second run refuses to start rather than racing the first, and a run that dies without releasing is reclaimed on the next attempt instead of blocking the schedule forever. Two jobs writing the same record at the same time is the kind of bug that never reproduces and shows up as a number nobody can explain.
Every long running job writes a record of itself: when it started, what it touched, whether it finished. That is not logging for its own sake. It is the difference between answering did last night's run work with a yes and answering it with a maybe.
Lessons
What building against someone else's product taught me
The integration work was never really about the pool. It was about deciding where the uncertainty was allowed to live. Once every upstream response passed through one boundary that either returned a clean type or a sentence a human could read, the rest of the system stopped caring how solid the ground underneath it was. That generalises: an unreliable dependency is fine as long as exactly one place is responsible for making it look reliable, and that place is small enough to hold in your head.
The other lesson was about separation being cheaper than it looks. Running the partner platform as its own application with its own database felt like extra work in week seven. It stopped feeling that way the first time a change to partner authentication shipped on its own, on its own schedule, with no chance of touching a mining client's session.

