Warning
The following is a proof-of-concept. This open source code is provided for research, experimentation, and developer education only. This code has not been audited, is actively experimental, and may contain bugs, vulnerabilities, or incomplete features. Use at your own risk and obtain legal advice as appropriate - DYOR.
Parity doesn’t deploy the code but may update it based on community feedback.
If you experience problems with any product or service that was built on or deployed from this code, you should contact the third party who deployed the code in its amended form, not Parity.
A prototype funding surface for Polkadot App mobile hosts. It receives an inbound asset, crypto or
fiat-sourced, on an ephemeral account on Asset Hub, converts it into CASH on the People chain,
and hands the result to the host. The same surface runs the other way at #/withdraw: it takes
CASH out of the host's purse and delivers PAS to an Asset Hub address. The prototype consists of
a static Nuxt 4 single-page app plus a background worker, both published to bulletin/DotNS.
The surface (app/, lib/) is what the user sees. It quotes, shows a deposit address or
opens the provider's widget, and tracks the request. The worker (worker/) runs in the
background inside the host. Once a deposit has landed, the surface hands the job to the
worker, which converts it to CASH and sends it to the People chain in one transaction on Asset
Hub (by teleport, or by reserve transfer where the chains do not trust the teleport), then, once
that CASH is final on People, claims it through the host's top-up call. The top-up is registered
under the ephemeral account's public key and driven by the host from there.
The surface shows the top-up done as soon as the host reports the claim in a block; the worker
follows the host's status over a subscription it holds open until the claim is final, and
registers a further top-up for whatever a short claim left on the account. The worker keeps going
after the surface is closed.
The conversion has two tiers, and the PSM is the default. Asset Hub's Peg Stability Module swaps
an approved token for CASH at a given rate less its own fee, so what a purchase buys
is known before it is quoted and cannot move before it settles: the provider delivers that
token and the worker signs one Utility.batch_all that mints the CASH and sends it to People.
The AssetConversion pool is the fallback, priced at whatever it quotes at the time. It
takes over whenever the PSM cannot serve a request (i.e.: no instance open for the pair, minting
paused, the amount over the instance's debt ceiling or under its minimum) and then the provider
delivers the native token and one XCM swaps and sends it instead. Which tier a request takes
is decided at quote time, before the provider is told what to send, and recorded on the request
with the fee rate it was quoted; the worker runs the tier it is handed and never chooses one.
On the Polkadot route the token the buyer picks decides: DOT takes the pool, USDT the PSM (the
pool through PAS when the PSM cannot serve), USDC a stable pool leg, one XCM that exchanges USDC
for PAS and PAS for CASH inside the holding and sends the CASH on, every fee paid in the stable,
and dotUSD, the underlying itself, sent as it is with no conversion, its fees paid in dotUSD.
@getsome/funding holds the four programs, the routing rule and the tick.
The two talk over host storage. lib/worker-rpc.ts (surface side) and worker/src/rpc.js
(worker side) implement a polled request/response channel: the surface writes a request under
a sequence key, the worker claims it, runs the handler, and writes the response. Job records
live in the same storage, so either side can resume after a restart.
Every request runs through a fresh ephemeral keypair. Nothing is stored for it. The
session asks the EntropyPort for a seed under a label made of the source id and the trade
number, and @getsome/ephemeral derives the sr25519 keypair from that seed.
Inside a host the entropy is deterministic, so the surface, the worker, and a later resume all
re-derive the same account from the same label. In a plain browser a random seed is persisted
instead, which is enough for UI work but cannot be recovered across devices.
The rail delivers funds to the ephemeral's address. The worker re-derives the same key to spend them. When the claim completes, the next request moves to a new trade number and a new account. Rails with a refund leg get a second per-request key on the source chain, derived the same way, so a refund never needs a typed address.
The off-ramp reuses the pieces above in the other direction. The page at #/withdraw derives a
fresh ephemeral key under a wd: label and asks the host to pay the CASH into it, under a payment
id derived from the key. The worker watches that key on People and runs two transactions signed
by it: a swap that buys the PAS the fees need, then one XCM that withdraws everything the key
holds and lands it on the destination account on Asset Hub. The key is left empty and reaped.
On Asset Hub the destination takes DOT, dotUSD, USDT or USDC, and the sale inside the XCM follows
the on-ramp's tiers the other way: the PSM redeems the CASH for USDT one to one less its fee,
with the pool as the fallback when the PSM cannot serve; the pool sells the CASH for DOT, and for
a stable sells that DOT again on the stable's pool; dotUSD is the CASH sent as it is. The
PSM redeem runs inside the same XCM: the key's origin travels with it, the redeem runs as the
key's account on Asset Hub, and the fee refund and the PAS that travelled are sold for USDT too.
Whatever the tier, the fee PAS that travels with the CASH ends up in the landing token, so the
destination is credited one asset.
The sale is decided from the token picked at quote time with the on-ramp's own rule and frozen on
the hand-off, so the worker never re-decides it. Arrival is a
balance read at the head, like every other read in the engine: the destination's balance in that
token is read just before the XCM leaves, and the run is done once it has grown by what the Asset
Hub dry run said would land. Hosts serve the current head and nothing older, so nothing follows
block history. @getsome/withdraw holds the program, the sizing and the tick;
worker/src/withdraw-engine.js drives it.
Withdrawals are a second record kind in the same request store as the top-ups, moved by the same observations and the same reconcile.
A withdrawal to a bank or a card is a sale through Meld. The page quotes it for an exact PAS figure, sized from the pool with room for the seller's KYC, and opens the provider's SELL session for that figure; the seller does KYC on the provider's page. Nothing is asked of the purse until the provider names its deposit address, which the page reads off the adapter and checks against the figure agreed, and until the order and the price are checked once more. Then the purse pays the key, the worker's message leg lands the sale's PAS on the key's own Asset Hub account, and the rail leg pays the provider exactly that figure, once, at a nonce it pins, after the adapter confirms the address, the asset and the amount, and within an hour of the purse being asked. What the sale landed above the figure goes home as CASH when it is worth the way back (0.1 PAS or more; less stays on the key), and the quote says about how much: the worker hands the key to the funding engine, which converts and claims it into the purse as it does an on-ramp, and starts that job again if it fails, up to three times. A sale that ends before the provider is paid, because the price moved past the figure, the provider closed or changed the order, or the hour ran out, sends everything on the key home the same way. A payment whose answer was lost holds the job until the chain shows it landed or outlived its mortality; only a key whose chain state contradicts the payment stops the job for a person.
@getsome/core owns the session state machine and defines the ports it needs. The rail port
(ChainflipRail in packages/core/src/ports.ts) is the contract a funding source has to
meet: reverse-quote a target amount, open a deposit channel to the ephemeral, report status,
probe liquidity, and list its sources. Two packages implement it, and one source needs none:
@getsome/chainflipfor crypto deposits, over the Chainflip SDK. Sources are BTC, ETH, USDC and the other Chainflip assets.@getsome/meldfor card and bank sources, over a Meld adapter service. Meld delivers the route's deposit asset to the ephemeral; from there the flow is identical to a crypto deposit.- Polkadot directly: the buyer sends DOT, dotUSD, USDT or USDC from any wallet to the ephemeral
on Asset Hub.
createManualRailin@getsome/fundingstands in for the rail port, one source id per token, and the deposit screen shows the account as a QR at once.
The core session does not know which rail it is running. The surface picks one per route.
On the UI side the same idea repeats. app/funding/ is a shell that resolves a funding
route (crypto, card, bank) to a route package: a lazily loaded screen, a top-up adapter
that projects that rail's records into the shared history, and an optional status line for
the journey view. A route with no registered package is shown as unavailable. Adding a
funding source means implementing the rail port and registering a route package; the
session, the worker, and the history need no change.
Everything the engine touches outside its own code comes in through a small set of ports:
ChainPort (balances, submit, sweep), StorageAdapter, EntropyPort, and the rail. Three
packages wire them:
@getsome/hostfor running inside a Polkadot App host: storage, entropy, and chain access come from the host API.@getsome/browserfor a plain browser: local storage and a persisted random seed.@getsome/testingfakes for the unit tests. The same fakes drive the mock world the app runs when no host is present, so the whole state machine can be exercised atlocalhost:3000without a chain.
app/ Nuxt 4 SPA: screens, composables, pinia stores, the funding shell
lib/ host integration: chain connections, session wiring, faucet, worker RPC
worker/ the background executable (plain ESM, bundled by esbuild)
packages/ the engine, one workspace package each (see below)
tests/ app-level tests; each package keeps its own unit tests next to its source
public/ static assets copied into the build; public/worker/ receives the worker bundle
brand/ the product icon used in the bulletin manifest
.papi/ polkadot-api chain descriptors, regenerated by `pnpm install`
.github/ build, deploy and PR-preview workflows
| Package | Role |
|---|---|
@getsome/core |
session state machine, flow store, re-entry logic, port types |
@getsome/ephemeral |
seed to keypair derivation, handoff secret encoding, refund keys |
@getsome/funding |
the funding program the worker runs on Asset Hub |
@getsome/withdraw |
the withdrawal legs the worker runs on People, swap then XCM |
@getsome/chainflip |
crypto rail over the Chainflip SDK |
@getsome/meld |
card and bank rail over the Meld adapter |
@getsome/people |
People chain port: CASH balances and the handoff submit |
@getsome/revive |
Asset Hub chain port over polkadot-api, batch and sweep building |
@getsome/host |
port wiring for the Polkadot App host |
@getsome/browser |
port wiring for a plain browser |
@getsome/testing |
fakes for tests and the mock world |
Packages export TypeScript source directly (exports points at src/index.ts); there is no
build step for them. They are private to this workspace and are not published to npm.
pnpm build:worker bundles worker/src/index.js into a single ESM file and copies it to
public/worker/index.js. pnpm build then runs nuxt generate, which copies public/ into
.output/public, so the worker bundle ends up inside the site. Run the worker build first;
public/worker/ is not tracked.
bulletin-deploy.config.ts describes the product for bulletin-deploy: a manifest record on
the base name and one executable record each for the app (.output/public) and the worker
(.output/public/worker).
Node 22 and pnpm 9.12 (the packageManager field pins the exact pnpm version).
pnpm install # also regenerates .papi/ descriptors
pnpm dev # localhost:3000, mock world in a plain browser
pnpm test # unit tests for the packages and the app
pnpm typecheck # app layer (vue-tsc)
pnpm typecheck:packages # the engine (tsc)
pnpm format # prettierA build targets the network packages/core/src/network.json describes: whether it is a testnet,
the native symbol and, for Asset Hub and People, the para id, genesis hash and endpoint, plus the
CASH asset id on Asset Hub and People's pool account. .papi/ holds the metadata the calls are
typed from and must name the same genesis hashes; the build refuses a mismatch. Both describe Paseo
Next. Demo builds, and with them the faucet and Skip, need "testnet": true; the build refuses
VITE_FAUCET_SEED otherwise.
Copy .env.example to .env and fill in what you need. Nuxt reads .env, not .env.local.
| Variable | Purpose |
|---|---|
VITE_FAUCET_SEED |
demo faucet account, holding PAS, dotUSD, USDt and USDC on Asset Hub; inlined into the client bundle, use a testnet account |
VITE_DEPLOYER_SEED |
fallback for MNEMONIC in deploy.sh |
VITE_MELD_BASE_URL |
origin of the Meld adapter; unset, the offline fake Meld client runs instead |
VITE_MELD_PRODUCT_ID |
product id the adapter expects in the x-dev-product-id header |
Two tests submit real transactions to the Paseo testnet and are skipped unless enabled:
PROD_PROOF=1 runs tests/prod-proof.test.ts, VERIFY_AMOUNTS=1 runs
tests/verify-amounts.test.ts. VERIFY_STABLE=1 runs tests/verify-stable.test.ts, which
dry-runs the USDC and USDT programs from a rich account on Paseo and spends nothing, and
VERIFY_DOTUSD=1 runs tests/verify-dotusd.test.ts, the same for the dotUSD tier.
Being a prototype, parts of the tree exist to keep a demo moving and are not what a real
deployment would do. Each is marked TODO(production) at its definition:
- the faucet (
lib/faucet.ts,app/utils/demo.ts), which funds the burner in the route's deposit asset on Skip, and theestimateSource*helpers behind the≈amounts on the deposit screen demoFallbackinapp/stores/offers.ts, which offers every source ungated when Chainflip answers for nothingDEMO_MAX_CASHinapp/stores/session.ts, a 200 CASH cap on a purchase- the demo Chainflip picks, which run the Polkadot direct deposit under the source of the token the route delivers until the Chainflip channel rail lands
./deploy.sh [name.paseo] # default getcash.paseoBuilds the worker and the site and publishes both with bulletin-deploy, which must be
installed globally. The deploying account comes from MNEMONIC, or from VITE_DEPLOYER_SEED
in .env.local or .env.
CI does the same: a push to main deploys getcash.paseo, and every pull request gets a
preview at pr<N>-getcash.paseo. Both are signed with the repository's MNEMONIC secret and
build with VITE_FAUCET_SEED and VITE_MELD_BASE_URL from repository secrets.
Copyright (C) 2026 Parity Technologies. Licensed under the GNU General Public License v3.0 or later; see LICENSE.