Payment channels
Payment channels (Arrekta's L2) move value between two parties off chain:
- each payment settles in milliseconds and costs no on-chain fee;
- the chain is only touched to open and to close;
- payments route across several channels, so you only need a channel with someone well connected;
- a channel holds one asset, either ARRK or any issued asset;
- every key is post-quantum: ML-DSA signatures, hash-based revocation, ML-KEM onions.
You run arrekta-channeld next to a Arrekta node and drive it over its REST API
or the SDKs. How it works: specification ·
node design.
1. Run a channel node
export ARREKTA_CHANNELD_NODE_URL=http://127.0.0.1:28332 # your Arrekta node
# seed file: 32 bytes of hex, back it up; token file: ≥ 32 characters
export ARREKTA_CHANNELD_SEED_FILE=/secrets/channeld.seed # secret-scan: allow (a path)
export ARREKTA_CHANNELD_API_TOKEN_FILE=/secrets/channeld.token # secret-scan: allow (a path)
export ARREKTA_CHANNELD_DATA_DIR=/var/lib/arrekta-channeld # back this up continuously
export ARREKTA_CHANNELD_ANNOUNCE_ADDR=ln.example.com:9736 # only if you route for others
arrekta-channeldWith Docker Compose, enable the channels profile (Operating nodes).
Fund it. GET /v1/wallet returns the on-chain address. Send ARRK there to open channels and
to pay close fees.
2. Open a channel
curl -s -X POST -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
http://127.0.0.1:9737/v1/peers -d '{"address": "ln.example.com:9736"}'
curl -s -X POST -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
http://127.0.0.1:9737/v1/channels -d '{"node_id": "<their node id>", "capacity": "1000000000"}'- The channel is usable after
ARREKTA_CHANNELD_MIN_DEPTHconfirmations. - Public channels (the default) are announced after
ARREKTA_CHANNELD_ANNOUNCE_DEPTH, so others can route through you. - For an asset channel, pass
"asset": "<asset id>". The opener also locks a small ARRK fee reserve.
3. Receive and pay
/**
* Payment channels: decode an invoice offline, then (with two channel nodes) pay it.
*
* npx tsx examples/payment-channels.ts # offline: decode + verify
* ARREKTA_CHANNELD_A=http://127.0.0.1:9737 ARREKTA_CHANNELD_B=http://127.0.0.1:9747 \
* ARREKTA_CHANNELD_TOKEN=... npx tsx examples/payment-channels.ts
*/
import { readFileSync } from 'node:fs';
import { createChanneldClient, decodeInvoice, verifyPreimage } from 'arrekta-sdk';
const { ARREKTA_CHANNELD_A: A, ARREKTA_CHANNELD_B: B, ARREKTA_CHANNELD_TOKEN: TOKEN } = process.env;
if (!A || !B || !TOKEN) {
const vectors = JSON.parse(readFileSync(new URL('../../../core/vectors/channels.json', import.meta.url), 'utf8'));
const inv = decodeInvoice(vectors.invoices[0].invoice); // throws if the signature does not verify
console.log(`invoice for ${inv.amount} base units of asset ${inv.asset.slice(0, 8)}…, payee ${inv.payee.slice(0, 16)}…, '${inv.description}', expires at ${inv.expiresAt}`);
} else {
const payer = createChanneldClient({ url: A, token: TOKEN });
const payee = createChanneldClient({ url: B, token: TOKEN });
const { invoice } = await payee.createInvoice({ amount: 150_000_000n, description: 'example' });
console.log('route:', await payer.route({ invoice }));
const paid = await payer.pay({ invoice, timeoutSecs: 60 });
console.log('status:', paid.status, 'fee:', paid.fee);
if (paid.status !== 'succeeded' || !paid.preimage || !verifyPreimage(paid.hash, paid.preimage)) throw new Error('payment failed');
console.log('proof of payment:', paid.preimage);
}"""Payment channels: decode an invoice offline, then (with two channel nodes) pay it.
# offline: decode and verify a arrektapay: invoice
python examples/payment_channels.py
# live: B creates an invoice, A pays it, both over their REST APIs
ARREKTA_CHANNELD_A=http://127.0.0.1:9737 ARREKTA_CHANNELD_B=http://127.0.0.1:9747 \\
ARREKTA_CHANNELD_TOKEN=... python examples/payment_channels.py
"""
import json
import os
from pathlib import Path
from arrekta_sdk import ChanneldClient, decode_invoice, verify_preimage
A, B, TOKEN = (os.environ.get(k) for k in ("ARREKTA_CHANNELD_A", "ARREKTA_CHANNELD_B", "ARREKTA_CHANNELD_TOKEN"))
if not (A and B and TOKEN):
vectors = Path(__file__).resolve().parents[3] / "core/vectors/channels.json"
text = json.loads(vectors.read_text())["invoices"][0]["invoice"]
inv = decode_invoice(text) # raises if the signature does not verify
print(f"invoice for {inv.amount} base units of asset {inv.asset[:8]}…, payee {inv.payee[:16]}…, "
f"'{inv.description}', expires at {inv.expires_at}")
else:
with ChanneldClient(A, TOKEN) as payer, ChanneldClient(B, TOKEN) as payee:
invoice = payee.create_invoice(150_000_000, description="example")["invoice"]
print("route:", payer.route(invoice))
paid = payer.pay(invoice, timeout_secs=60)
print("status:", paid["status"], "fee:", paid["fee"])
assert paid["status"] == "succeeded"
assert verify_preimage(paid["hash"], paid["preimage"])
print("proof of payment:", paid["preimage"])Source: sdk/typescript/examples/payment-channels.ts (updated 2026-11-02) · sdk/python/examples/payment_channels.py (updated 2026-11-02). Runnable against a regtest node; CI runs every example.
- Receive:
POST /v1/invoicesreturns aarrektapay:invoice. It is signed by your node, and includes route hints if you are behind private channels. - Pay:
POST /v1/paymentsfinds a route, pays, and retries around failures. Hand the payer's wallet the invoice (an animated QR, or a link);decodeInvoiceshows what it asks for before paying. - Proof of payment: a successful payment returns the
preimage, which proves you paid (verifyPreimage(hash, preimage)).
4. Grow, shrink, co-fund
- Dual funding: pass
"request_funding": "<amount>"when opening, and the peer may put that much in too, so both sides can pay from the start. A node co-funds up toARREKTA_CHANNELD_DUAL_FUND_MAX. - Splicing:
POST /v1/channels/<id>/splicewith{"amount": "+200000000"}adds coins from your node's wallet.{"amount": "-50000000", "address": "…"}pays out to any address. The channel stays open, and payments pause until the splice is a few blocks deep. - Large payments split across several routes automatically (multi-path). The recipient releases the payment only when every part has arrived.
5. Close
POST /v1/channels/<id>/closecloses cooperatively. Funds arrive at your wallet in one confirmation.{"force": true}closes unilaterally when the peer is gone. Your balance then waitsto_self_delayblocks (default 720) before the node sweeps it to your wallet.
In the wallets and the explorer
- The desktop, mobile and browser-extension wallets have an Instant payments screen. Connect it to your channel node once, then pay or receive with a scanned or pasted invoice. The extension keeps the API token encrypted under your wallet password.
- The explorer's Channels page shows the public channel network. Its transaction pages label channel closes, splices and penalty (justice) transactions.
Stay safe
- Keep the node online, or use a watchtower (
ARREKTA_CHANNELD_TOWERS=host:port). If your peer publishes an old state while you are offline for longer thanto_self_delay, they could keep money that is yours. A watchtower answers on your behalf and learns nothing until then. - Back up the data directory continuously. Restoring an old copy and closing with it is treated as cheating. The node detects a stale backup on reconnect and refuses to broadcast.
- Keep some ARRK in the wallet. Closes and sweeps pay fees by CPFP.
- Keep the REST API private. It moves money: bind it to localhost and keep the token secret.