A programmable execution router for the STRK20 shielded pool.
jalin (Indonesian) — to weave separate strands into one.
Jalin lets a single private transaction carry an arbitrary multi-step, multi-token
plan: swap, lend, stake, bridge, or anything else reachable by a Starknet contract
call — executed inside one privacy_invoke, with the resulting value credited
straight back into shielded notes.
| The deck | Nine panels: the constraint, the mechanism, what it reaches, what it does not do |
| The composer | Build a plan, see what it reveals, sign it with Ready |
| The demo | 2:49, recorded against production |
| Verify | The sprint's own rule applied to any repository's strk20.json, including this one |
strk20.json |
Three mainnet transactions, two declared contracts |
jalin-sdk |
The plan encoder, published |
Every figure on every page is a live contract call. Nothing on this site is typed
in, and /verify will tell you so about this repository as readily as any other.
STRK20 reaches the outside world through one primitive, the atomic sandwich:
pool --withdraw--> helper
pool --privacy_invoke()--> helper
helper --approve--> pool
helper --returns Span<OpenNoteDeposit>--> pool
Two protocol rules shape everything downstream:
- One
invokeper transaction. At most one external call per pool transaction. - Every token's balance must end at exactly zero. No value created or destroyed.
Together they mean a private DeFi action is only as expressive as the single helper contract it calls. Want a private swap? Deploy and audit a swap helper. Lend? Another helper. Swap-then-lend? A third. Every new interaction is a new Cairo contract, and that is why almost everything built on STRK20 today is a payment app — payments are the only thing you can ship without writing Cairo.
Jalin is one helper that takes a plan instead of fixed parameters.
fn privacy_invoke(
ref self: ContractState,
pool_address: ContractAddress,
steps: Array<Step>,
outputs: Array<Output>,
) -> Span<OpenNoteDeposit>A plan is a bounded list of steps. Each step names a target, a selector, calldata, and the approvals the step needs. Jalin executes them in order, credits each declared output, enforces the floor the caller set on it, approves the pool, and returns the deposits. Composition moves inside the single invoke, where the protocol allows it.
Each output is credited its whole balance rather than a measured delta. That is only sound because of I4 below: anything left on the router is unreachable, so there is nothing a delta would be protecting against and one less thing to get wrong.
Outputs may be empty. The pool accepts an empty Span, which is what makes a plan
that sends value away for good — a bridge leg, an escrow funding, a ballot —
expressible at all.
Nothing is whitelisted. Any Starknet contract is a valid target and calldata is free-form, which is what makes a bridge call, a DEX route, and a lending deposit the same object: a plan.
The obvious objection to an unrestricted router is that it can be told to do anything. The answer is that Jalin is non-custodial and holds nothing between transactions, so a hostile plan can only harm its own author — who is spending their own notes. Safety comes from invariants enforced in Cairo, not from a gatekeeper's list:
| # | Invariant | Attack it closes |
|---|---|---|
| I1 | Caller must be the pool | Anyone calling the router directly |
| I2 | No step may target the pool or the router | Reentrancy into the sandwich |
| I3 | Every approval granted by a step is reset to zero after that step | Stale allowance draining the next user |
| I4 | Zero residue: every token Jalin touches ends the transaction at zero balance | Sweeping another user's dust |
| I5 | Each declared output must clear its min_amount |
Slippage and hostile routes |
| I6 | Step count and calldata length are bounded | Griefing the proof budget |
I4 deliberately mirrors the pool's own rule. If a token cannot be brought back to zero, the transaction reverts rather than leaving a balance behind for someone else to find.
Private swaps are already live on AVNU, routed through its own anonymizer, and Ekubo is next. For a single swap, use those. They are purpose-built, audited by people who own the venue, and they will price better than a generic router calling the same pool.
Jalin and a venue's own anonymizer are not complementary — they are candidates for the same slot. The pool allows one invoke per transaction, so a transaction goes through AVNU's helper or through Jalin, never both. Picking Jalin costs you the venue-specific optimisations and buys you exactly one thing: more than one step.
That is also the argument for the router existing. AVNU had to write an anonymizer. Ekubo is writing another. Every venue that wants private execution writes its own, and none of them compose, because composition would need a second invoke that the protocol does not grant. Jalin moves the composition inside the one invoke that is allowed.
So: one action at one venue, use the venue. Two actions, or an action at a venue that has not written a helper yet, and there is nothing else to use.
Endur has no anonymizer. It has an ERC-4626 vault, and that is enough:
depositStep({
market: ENDUR_VAULT, // 0x28d709c8…0954b0a, verified on mainnet
selector: getSelectorFromName('deposit'),
asset: STRK,
amount,
receiver: ROUTER_ADDRESS,
})The router approves the vault, calls deposit(assets, receiver) with itself as
receiver, and the xSTRK shares land in a shielded note. asset() returns STRK and
0.25 STRK quotes at 0.2126 xSTRK — both checked against the live contract before
this was written.
Nothing in that step is Endur-specific on our side. No adapter was registered, no contract was deployed for it, and no permission was asked for. AVNU had to write an anonymizer to offer private swaps and Ekubo is writing another; every venue that wants private execution writes its own, and none of them compose. This is the alternative, and it runs today.
The same argument, made with a swap. AVNU's aggregator is asked for a quote
with the router as taker; it builds multi_route_swap on its exchange —
0x0427…3b0f, whose ABI was read from the chain before this was written —
with the router named as beneficiary and the route it found across pools, and
those 23 felts are the step's calldata:
callStep({
target: AVNU_EXCHANGE,
selector: getSelectorFromName('multi_route_swap'),
spend: { token: STRK, amount },
calldata: swap.calldata, // from /api/swap, verbatim
})The composer's "Swap half on AVNU, stake half on Endur" preset puts that step
beside the Endur deposit: two venues, two outputs, one invoke, with AVNU's own
buy_token_min_amount as the USDC floor. It is fetched when clicked rather than
written into the page, because a route is a price at a block.
It buys native USDC (0x33068f…), not the bridged USDC.e (0x53c9…), and the
reason is the pool's own history rather than a preference: over the pool's life
native USDC has been shielded 71 times and USDC.e once. A note in USDC.e would
sit in a cell of one — the "you, alone" the disclosure panel warns about — and
no router can fix that; choosing the token the crowd holds can.
This is not AVNU's private-swap route, and it is worth being precise about the difference. That route is AVNU's anonymizer — one venue, in the one invoke the pool allows. This is AVNU as a step among others, in the same invoke as a stake, which is the composition the anonymizer cannot express. The approve call AVNU also returns is dropped: the router grants and resets approvals itself (I3).
sdk/src/subaccounts.ts implements the sub-account portfolio layer — deriving a
stable label per strategy, rolling unlinkable positions back into one balance
sheet, and saying out loud when a portfolio has no internal anonymity left.
sdk/src/shadow.ts builds the Wallet API action for it.
An earlier version of this section said the Wallet API exposed no sub-account
method. It does now: starknet.js 10.6.0 (29 July 2026) added the handling, and
@starknet-io/types-js 0.10.4 carries shadow_account_invoke — calls made from
a shadow account the wallet derives per (dapp, nonce), with a collect_policy
that settles only what the interaction gained — and
wallet_strk20ShadowAccountCommitment. Whether a given wallet implements them
is a separate question, so the composer asks the connected wallet and shows its
answer verbatim: the partial commitment when it has one, the refusal when it
does not. Nothing on that panel is rendered from a constant.
The SDK route (transfers.build().shadowAccounts(name).invoke(...), rc.5) needs
a proving service URL, and one is hosted at
https://transaction-prover.alpha-mainnet.sw-dev.io — see "Through the SDK,
headlessly" below for how that was found and what this document claimed before
it was.
What the router does instead. Every external step is dispatched by the router
itself, so from the venue's side the caller is always the same address. Endur sees
0x8498d79… deposit into its vault; it cannot see which pool user asked. The
PlanExecuted event carries a plan id and two counts and deliberately no token,
amount or note id.
That is a different shape of privacy from a sub-account, and for flow-through actions it is the better one:
| Sub-account | Jalin router | |
|---|---|---|
| Identity seen by the venue | fresh, and yours alone | shared by every user of the router |
| Anonymity | unlinked from your wallet | unlinked, and in a crowd |
| Grows with usage | no | yes |
A fresh address is unlinked and alone, which is why a single transaction from one is often still identifiable by amount and timing. A shared router puts you in a set, and the set grows every time someone else runs a plan.
Where it genuinely falls short. This only holds for actions that flow through: swap, or stake-and-take-the-shares, where nothing is left owned afterwards. A protocol that records a persistent owner — a lending position, a vault subscription — cannot have the router hold it, because the router holds nothing between transactions by construction (invariant I4). For those, sub-accounts are the right answer and this is not a substitute for them.
And the honest caveat on the crowd: it is only as large as the number of people using the router, which today is small. The mechanism is right; the set has to be earned.
Everything the composer says to Ready goes through app/lib/wallet.ts, typed by
the Wallet API's own request map (@starknet-io/types-js 0.10.4) rather than
cast around it. That is not tidiness: two bugs — a call carrying a field too
many, and a call spelling the entrypoint the JSON-RPC way — hid behind as never
for days, and the types that refuse both were installed the whole time.
| Method | What the page does with the answer |
|---|---|
wallet_supportedWalletApi |
Names the API version beside the wallet, so a refusal can be read against it |
wallet_strk20Balances |
Shows what the pool holds for this account, and gates every run on amount + pool fee against it, naming the shortfall |
wallet_strk20PrepareInvoke (simulate) |
Dry run: the wallet assembles the transaction and reports what it would refuse, with nothing proved, sent or charged |
wallet_strk20InvokeTransaction |
The real thing |
wallet_strk20ShadowAccountCommitment |
The partial commitment for dapp jalin, computed inside the wallet, or the wallet's refusal — verbatim |
Support is detected by asking, per method, per session. A wallet that answers the balance call and not the shadow-account one gets a page that says exactly that.
jalin-five.vercel.app/jalin-demo.mp4 —
two minutes forty-nine, captioned, and named in strk20.json.
It is not a screen recording of a rehearsal. Playwright drives the deployed app through the same selectors the end-to-end suite uses, so the transaction it checks on screen is one of the three mainnet hashes listed above and the verdict beside it is computed from the chain while the recording runs. The narration is synthesised, the captions come from the speech engine's own sentence timings, and the whole thing rebuilds from this repository:
python scripts/demo-video/tts.py # narration and caption cues
node scripts/record-demo.mjs <dir> # footage, against the live site
python scripts/demo-video/compose.py # cut to the narration, burn captionsRecording against production rather than a local build is deliberate: a demo of something that only works on a laptop is a demo of nothing.
Automated stack detection reads package.json, so it records this project as not
using the privacy SDK. It does — built from source rather than depended on, which
is the one shape a dependency scan cannot see. The whole of it, including what is
missing:
| Part of the stack | Here |
|---|---|
| Shielded notes | Every plan output is credited straight back into one, above a floor the caller sets |
privacy_invoke |
The router is a helper the pool calls; the plan executes inside that single invoke |
| Anonymizer contract | contracts/ — the router, a governor and a private ballot, in Cairo. The router is exercised by every listed transaction; the ballot path is deployed but has never been cast, and its weight accounting has a disclosed defect (threat model) |
| Privacy SDK | Built from source into vendor/ by scripts/build-privacy-sdk.sh and used by scripts/mainnet.mjs |
| Proving and discovery | Self-hosted: the official discovery service and proof interceptor run from prover/ |
| Shadow accounts | Asked of the wallet at runtime and shown verbatim, including the refusal. The SDK route is open now that a hosted mainnet prover is known |
| Private transfers | Not the product. Jalin routes value through venues; a note-to-note transfer is what the pool already does without a helper |
The last two rows are the honest ones. Shadow accounts are reachable in the protocol and not from here yet, and private transfer is deliberately somebody else's problem.
| Path | What it holds |
|---|---|
contracts/ |
Cairo: the router, the governor, the private ballot |
sdk/ |
TypeScript: plan encoding, recipes, sub-account portfolio — packaged as jalin-sdk |
app/ |
The demo anyone can open, the composer, /verify, /governance and /slides |
prover/ |
Self-hosted discovery and screening services |
docs/ |
Threat model, what mainnet says, QA, deploying, cross-chain |
There is no bridge/. Cross-chain is not a feature of the router, it is a plan —
which is the whole argument, and it is made in docs/cross-chain.md.
The SDK is published, so it can be used without this repository:
npm install jalin-sdkVerified the way a stranger would: installed from the registry into an empty
directory, imported, and typechecked under both bundler and NodeNext
resolution with skipLibCheck off. Thirty-eight exports resolve, and a plan
encodes to felts. What ships is compiled
JavaScript with its own declarations, staged by
scripts/publish-sdk.mjs, which builds the tarball
and imports its entry point before packing — a package nobody imported is a
package nobody tested.
npm run publish:sdk # build, stage, verify, pack
npm run publish:sdk -- --publishThe workspace itself keeps resolving the TypeScript source. The entry a consumer
needs and the entry the app builds against are not the same file, and npm cannot
swap them at publish time — publishConfig only overrides npm's own config, and
the field replacement people remember is pnpm's. Staging the tarball separately
leaves the production build graph untouched, which is the property worth buying.
scarb build --manifest-path contracts/Scarb.toml
node scripts/verify-classes.mjsIt hashes the Sierra artifacts and compares each against the class deployed at
the addresses in strk20.json. Both match, which is the only way to know that
the source above is what runs.
Worth knowing before you try it: snforge test overwrites the release artifact
with a test build of the same contract. Hash that and the router does not match,
with nothing on screen to say why — it cost a real scare here. Run scarb build
after testing; the script warns when it sees test artifacts in target/.
node scripts/verify-transactions.mjs # this repository
node scripts/verify-transactions.mjs ../other/strk20.jsonThe sprint rules say a listed transaction must exist, have succeeded, have touched the pool, and — if you deployed contracts — have run through one of yours. All four are checkable, so there is no reason to discover on 31 August that a hash does not qualify.
It is read-only and takes any manifest, which is also how it was checked: against a transaction known to touch the pool, one known not to, and a hash that does not exist. Point it at your own entry if it is useful.
The verify page also reads the sprint hub's own projects.json and shows its
verdict beside this one — the count the panel will read, which requirements it
marks met, and whether the two verifiers agree. For this repository they do.
Cairo:
scarb build --manifest-path contracts/Scarb.toml
sh contracts/test.sh # snforge in a pinned containertest.sh runs in Docker because starknet-foundry publishes no Windows binary, and
because pinning the toolchain is worth more than saving a container. The scarb
cache lives in a named volume, so only the first run pays for the plugin build.
44 tests, two of them fuzzed at 256 runs each, covering every line of every
contract — sh contracts/coverage.sh && node scripts/coverage-gate.mjs fails if
any line of src/ never runs. Line coverage is a floor, not a proof: it says
every line ran, not that it ran under the conditions that would break it. Four of them fork Starknet mainnet at a
pinned block and run a plan through Endur's deployed xSTRK vault - and one through AVNU's exchange into Ekubo's STRK/USDC pool - funded by the
STRK20 pool's own STRK — which is where the STRK comes from in a real
transaction. A mock ERC-4626 returns what the mock was told to return; those
three prove the router works against a contract nobody here wrote. They need
network, and use a public node that takes no key.
TypeScript:
npm test # 129 SDK tests, no build step
npm run typecheck # tsc over the whole SDK, imported or not
npm run lint # eslint over the app
npm run check:links # every path this repository names
npm run test:e2e # 348 Playwright tests, six projects, three enginesThe browser suite has no fixtures in it. It reads the live chain, so it asserts invariants rather than values: that one STRK buys strictly fewer than one xSTRK share, that the crowd count is bounded by the deposit count, that a real mainnet transaction which touched the pool without going through our router does not qualify. A test that asserts a fixture only proves the fixture loaded.
The sprint asks for three and this project lists three. All three are plan
executions: each one carries a PlanExecuted event emitted by the router at
0x008498d79…, not merely a transaction that brushed the pool.
There are two ways to reach privacy_invoke, and they fail differently.
Through a wallet. wallet_strk20InvokeTransaction has the wallet build and
prove the transaction itself, which takes about thirty seconds. This works, and
it is how all three of these landed — from the composer, in a browser, against
Ready. Anyone with a wallet implementing the STRK20 methods and a shielded
balance can run a plan today.
Through the SDK, headlessly. scripts/mainnet.mjs needs a
PROVING_SERVICE_URL, and there is a hosted one:
https://transaction-prover.alpha-mainnet.sw-dev.io
It answers /health with {"status":"ok"} and it proves — the register phase
builds a real apply_actions against the pool with proof data attached, which
is the only test of a prover that means anything.
This section said for a week that no such endpoint existed, and six open
issues on the hub said the same:
#121,
#124,
#135,
#147,
#204,
#221. It was wrong,
and it was wrong in the direction that excused a shallower integration. The URL
is in another team's .env.example, which is where it was found; nothing about
it was secret and nobody had looked there.
Running the prover yourself is still possible and still documented, because
a hosted endpoint is somebody else's uptime. StarkWare's own demo ships
TODO_MAINNET_PROVER_URL, and the image is public — just not in the repository
everyone was searching:
docker run --rm -p 3000:3000 -e RPC_URL=<a mainnet RPC on JSON-RPC spec 0.10> -e CHAIN_ID=SN_MAIN ghcr.io/starkware-libs/starknet-privacy/transaction-prover:PRIVACY-0.14.3-RC.2Then provingProvider: { url: 'http://localhost:3000' }. The image is
anonymously pullable — an unauthenticated ghcr.io/token request returns a
token and the tag list answers. The source is
crates/starknet_transaction_prover/ in starkware-libs/sequencer, not in
starkware-libs/starknet-privacy, which is why looking there finds nothing.
It is not free, and it does not run everywhere. Upstream recommends 48 vCPU and
96 GB; one team in this sprint ran it on 32 GB at about fifty seconds a proof.
The published build also carries an undisclosed -C target-cpu, and on the
machine this was written on — a 12th-generation Core i3-12100F, where AVX-512 is
fused off — the container dies immediately:
docker run … transaction-prover:PRIVACY-0.14.3-RC.2
Exited (132) # SIGILL, no log output at all
The escape is a portable rebuild from
crates/starknet_transaction_prover/Dockerfile in starkware-libs/sequencer
with TARGET_CPU="", which is a nightly-Rust build of the sequencer workspace
and not a ten-minute job.
So the count here is three rather than thirty because that cost was not paid. A decision, and now a documented one — with the part that is genuinely blocked named separately below.
That gap is why scripted, repeatable runs are not part of this repository, and why the steps that need one — the AVNU multi-route swap beside the Endur stake, as a single plan — are exercised against a mainnet fork with the real contracts at their real addresses.
What is genuinely unreachable from here is narrower than the transaction count
suggests: the shadow-account route. wallet_strk20ShadowAccountCommitment
answers "Not implemented" on the wallet the composer asks, and the composer
prints that refusal verbatim rather than a constant.
docs/what-mainnet-says.md collects the findings
that came from reading the chain rather than the documentation — including the
one that removed a feature from this SDK.
They were written down for other people to use, and at least one measurement has been: another team took the block time from that document, checked it against the thirty seconds they had hardcoded, and found a seven-day campaign window that would really have closed in under three hours. The re-measurement that followed is in there too, across five sample sizes, because the first figure this project published was itself drawn from a sample short enough to lie.
Two other things here answer for anybody, not only for this project.
/api/manifest
reads any repository's strk20.json and says which of its hashes count and why
one does not — a transaction that succeeded but never reached the pool is
invisible in a block explorer unless you already know to look for it.
/api/hub returns
the sprint hub's own verdict on any entry without reading a file of 174 of them.
Built for the STRK20 Private Sprint, 14–31 August 2026. Deployed addresses,
mainnet transaction hashes and the demo link are in strk20.json.
MIT — see LICENSE.