Wallets and exchanges
This page is for integrators: wallets, exchanges, payment gateways and apps. Velum is not EVM-compatible: it has its own address, transaction and signature formats. Everything you need is here.
Technical summary
| Parameter | Value |
|---|---|
| Chain | {{chain}} (the chain field of every transaction) |
| Native coin | VEL, 6 decimals (1 VEL = 1,000,000 µVEL) |
| Amounts | Integers in the token's smallest unit |
| Block time | 2 s target (actual pace measured in the explorer) · epoch: 1,800 blocks (≈ 1 h)30 blocks (≈ 60 s) |
| Curve and signature | ed25519 over canonical JSON |
| Address | vel + base58(32-byte ed25519 public key) |
| Wallet derivation | BIP-39 (24 words) + SLIP-10 ed25519 at m/44'/7420'/account'/0'/index' |
| Payment fee | 0 below €1,000 of value (with proof of work); 0.01% from €1,000 |
| Price | Own oracle: 1 h TWAP of {{pair}} |
| Transaction ordering | Per-wallet nonce, starting at 0 |
| Transaction ID | First 48 hex chars of SHA-256(canonical JSON including sig) |
| Recommended confirmations | 6 (payments), 30 (exchange deposits) |
| Node | JSON RPC over WebSocket |
| Explorer | Read-only REST API at /api |
Addresses {#direcciones}
An address is the prefix vel followed by the ed25519 public key (32 bytes) in base58 (Bitcoin alphabet). It is 46 to 47 characters long.
import { ed25519 } from "@noble/curves/ed25519.js";
import { base58 } from "@scure/base";
// A Velum address is "vel" + base58(32-byte ed25519 public key). No checksum byte:
// validate by decoding and checking the length is exactly 32.
const address = (pub: Uint8Array) => "vel" + base58.encode(pub);
const isAddress = (a: string) =>
a.startsWith("vel") && !a.startsWith("vel_") && (() => {
try { return base58.decode(a.slice(3)).length === 32; } catch { return false; }
})();
- There is no checksum. Always validate by decoding and checking you get exactly 32 bytes. For manually entered addresses, encourage copy and paste or QR scanning.
- Addresses starting with
vel_are special accounts with no key (vel_pool,vel_reserve,vel_escrow). Don't accept withdrawals to them. - Users can share an alias (
@lucia) instead of the address. Always resolve it against the chain and show the resulting address before sending.
Key derivation {#claves}
A 24-word BIP-39 phrase generates the wallets with SLIP-10 ed25519, all levels hardened:
| Item | Value |
|---|---|
| Path | m/44'/7420'/account'/0'/index' |
| Official app wallet | m/44'/7420'/0'/0'/0' |
| SLIP-44 coin type | 7420 (registration pending) |
The messaging identity and mailbox keys come from the same seed via HKDF-SHA512 (velum/v2/identity, velum/v2/mailbox) and are not wallet keys: an exchange or a wallet doesn't need them.
import { mnemonicToSeedSync } from "@scure/bip39";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha512 } from "@noble/hashes/sha2.js";
import { walletFromPhrase, walletPath } from "@velum/sdk";
// Wallet keys: SLIP-10 ed25519, every level hardened: m/44'/7420'/account'/0'/index'
const hot = walletFromPhrase(phrase, 0, 0); // m/44'/7420'/0'/0'/0' (the official app's wallet)
const user42 = walletFromPhrase(phrase, 0, 42); // one deposit address per customer
walletPath(0, 42); // "m/44'/7420'/0'/0'/42'"
// Messaging keys are NOT wallet keys: they come from the same seed with HKDF-SHA512.
const seed = mnemonicToSeedSync(phrase);
const identity = hkdf(sha512, seed, undefined, new TextEncoder().encode("velum/v2/identity"), 32);
With the SDK:
import { walletFromPhrase, isAddress } from "@velum/sdk";
const { address, secret } = walletFromPhrase(phrase, 0, 0);
isAddress(address); // true: "vel" + base58(32-byte ed25519 public key)
It's the same path used by the
@velum/sdkSDK, hardware wallets (Trezor, Ledger) and the MetaMask Snap. See Ecosystem.
Transactions {#transacciones}
All transactions share these fields:
| Field | Type | Meaning |
|---|---|---|
type |
string | Transaction type |
chain |
string | {{chain}}. Rejected if it doesn't match. |
from |
address | Signing wallet |
nonce |
integer | Number of previous transactions from from (0, 1, 2…) |
ts |
integer (ms) | Client time. Informational: the chain uses the block time. |
sig |
hex (128) | The wallet's ed25519 signature |
Signing and canonical JSON
// Canonical JSON: keys sorted, no whitespace, undefined fields dropped. This is what gets signed and hashed.
export function canonical(v: unknown): string {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
const o = v as Record<string, unknown>;
return "{" + Object.keys(o).filter((k) => o[k] !== undefined).sort()
.map((k) => JSON.stringify(k) + ":" + canonical(o[k])).join(",") + "}";
}
// sig = hex(ed25519.sign(utf8(canonical(tx without "sig")), walletSecret))
// txId = hex(sha256(utf8(canonical(tx with "sig")))).slice(0, 48)
- Build the object without
sig. Leave out optional fields you don't use (don't set them tonull). - Serialize to canonical JSON: sorted keys, no whitespace.
- Sign the UTF-8 bytes with the wallet key.
sig= signature in hex. - The ID is
sha256(canonical including sig)in hex, cut to 48 characters.
Payment (transfer)
{
"type": "transfer",
"chain": "{{chain}}",
"from": "vel8Kx3vQ…",
"to": "vel4Jt9mR…",
"amount": 12500000,
"fee": 0,
"token": "tEUR",
"memo": "inv-20481",
"nonce": 17,
"ts": 1793577600000,
"sig": "9f2c…(128 hex)"
}
amountin the token's smallest units (µVEL if there's notoken). Greater than 0.feefollows the v3 rule: 0 if the payment's value, at the network oracle price, is under €1,000; otherwise 0.01% of the amount. Payments withfee0 carry a work stamppow(SHA-512, about 50 ms on a phone) that is part of what is signed;buildTransferin@velum/sdkcomputes it. The exact format is inPROTOCOL.mdandspec/vectors/.tokenis optional: the token symbol. Without it, VEL is sent.memois optional, up to 128 characters. It is public: use it for payment references, never personal data.
Node sign-up (register)
{
"type": "register", "chain": "{{chain}}", "from": "vel…node", "nonce": 0, "ts": 1793577600000,
"name": "atico-madrid", "payout": "vel…wallet", "version": "2.0.0",
"kind": "full", "endpoint": "wss://node.example.org:7420/ws",
"sig": "…"
}
Free. kind is full (a computer, produces blocks) or light (a phone). payout is the wallet that gets paid.
Tokens (token.create, token.mint, pool.fund)
{ "type": "token.create", "chain": "{{chain}}", "from": "vel…", "nonce": 3, "ts": 1793577600000,
"symbol": "CAFE", "name": "Café de barrio", "decimals": 2,
"maxSupply": 100000000, "initial": 2500000, "mintable": true, "fee": 100000000, "sig": "…" }
{ "type": "token.mint", "chain": "{{chain}}", "from": "vel…issuer", "nonce": 4, "ts": 1793577660000,
"symbol": "CAFE", "to": "vel…", "amount": 50000, "fee": 0, "sig": "…" }
{ "type": "pool.fund", "chain": "{{chain}}", "from": "vel…issuer", "nonce": 5, "ts": 1793577720000,
"token": "CAFE", "amount": 1440000, "epochs": 1440, "fee": 0, "sig": "…" }
See Custom tokens for the rules for each field.
Market (order.place, order.cancel)
{ "type": "order.place", "chain": "{{chain}}", "from": "vel…", "nonce": 9, "ts": 1793577600000,
"pair": "VEL/tEUR", "side": "buy", "price": 12500, "amount": 250000000, "ttl": 1440, "sig": "…" }
{ "type": "order.cancel", "chain": "{{chain}}", "from": "vel…", "nonce": 10, "ts": 1793577660000,
"id": "4c1f0a…", "sig": "…" }
price in smallest token units per 1 VEL; amount in µVEL (minimum 1 VEL). See Market.
Device attestation (device.attest)
{ "type": "device.attest", "chain": "{{chain}}", "from": "vel…", "nonce": 0, "ts": 1793577600000,
"device": "e3b0c442…(64 hex = sha256 of the device public key)",
"keyId": "base64…", "attestation": "base64 CBOR…", "sig": "…" }
Sent by the iOS app to record that it is the original app on a real iPhone. Integrators don't need to send it.
Nonces
- A wallet's first
nonceis 0; each accepted transaction increases it by 1. - For the next one, use
account.get→nonce + pending(pending= that wallet's transactions waiting in the node's mempool). - A transaction with an already-used
nonceis rejected. Re-sending the same signed transaction is safe: it isn't charged twice.
Common errors
| Error | Cause |
|---|---|
Cadena incorrecta (wrong chain) |
chain isn't {{chain}} |
Firma no válida (invalid signature) |
Non-canonical JSON was signed, or sig was included |
Comisión incorrecta (wrong fee) |
fee doesn't match the €1,000 rule and the oracle price |
Nonce no válido (invalid nonce) |
Negative, not an integer or repeated |
Nota demasiado larga (memo too long) |
memo longer than 128 characters |
Finality and confirmations {#finalidad}
Finality is probabilistic for now (see Consensus). Reorganizations are short, usually one or two blocks.
| Case | Confirmations | Time |
|---|---|---|
| Chat, tips | 1 | One block |
| Shop, payment gateway | 6 | A few seconds |
| Exchange deposit | 30 | ≈ 1 min at the target pace |
| Large deposit | One epoch | ≈ 1 h≈ 1 min |
A transaction has n confirmations when its block is at height h and the chain head is at h + n − 1.
Node RPC (WebSocket) {#rpc}
Apps and services talk to nodes with JSON frames over WebSocket:
- Through the public gateway:
wss://velum.consultorabim.com/hub?role=client(fallback:wss://velum.consultora-bim.workers.dev/hub). - Directly with a node:
ws://<host>:7420/ws?role=client. For an exchange, running your own node is recommended.
// Client → gateway (wss://velum.consultorabim.com/hub?role=client) or a node (ws://host:7420/ws?role=client)
type ClientToHub =
| { t: "rpc"; node?: string; id: string; method: RpcMethod; params: any }
| { t: "ping" };
type HubToClient =
| { t: "welcome"; client: string; nodes: PeerInfo[] }
| { t: "nodes"; nodes: PeerInfo[] }
| { t: "rpc_res"; id: string; result?: any; error?: string; node?: string }
| { t: "push"; node: string; event: "shard" | "head"; data: any }
| { t: "pong" };
// Read-only live feed for explorers (wss://…/hub?role=watch)
type HubToWatch =
| { t: "snapshot"; head: { height: number; hash: string } | null; nodes: PeerInfo[]; stats: Record<string, number> }
| { t: "block"; block: Block }
| { t: "nodes"; nodes: PeerInfo[] }
| { t: "flow"; kind: "shard" | "receipt" | "tx"; id: string; from?: string; to?: string; ts: number };
Through the gateway, node is optional: if you leave it out, the gateway picks a node and tells you which one in the response.
Methods
| Method | Parameters | Result |
|---|---|---|
chain.head |
{} |
{ height, hash, ts, node } |
chain.block |
{ height? , hash? } |
Block or null |
chain.blocks |
{ from, to } |
Block[] |
account.get |
{ address } |
{ balance, nonce, pending } (VEL in µVEL) |
account.history |
{ address, limit? } |
[{ height, ts, tx }] |
tx.submit |
{ tx } |
{ id } |
| shard.put | { shard } | { ok, carrier } |
| mailbox.open | { mailboxes: MailboxOpen[] } | { shards, courier } and then push events |
| receipt.submit | { receipts } | { accepted } |
| node.info | {} | Node details |
| node.list | {} | Known nodes |
| stats | {} | Node counters |
Example without the SDK:
const ws = new WebSocket("wss://velum.consultorabim.com/hub?role=client");
let seq = 0;
const pending = new Map<string, (r: any) => void>();
const rpc = (method: string, params: object) =>
new Promise((resolve, reject) => {
const id = String(++seq);
pending.set(id, (m) => (m.error ? reject(new Error(m.error)) : resolve(m.result)));
ws.send(JSON.stringify({ t: "rpc", id, method, params }));
});
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.t === "rpc_res") pending.get(m.id)?.(m), pending.delete(m.id);
};
ws.onopen = async () => {
const head = await rpc("chain.head", {}); // { height, hash, ts, node }
const acc = await rpc("account.get", { address: "vel…" }); // { balance, nonce, pending }
};
Explorer REST API {#rest}
The explorer publishes a read-only API, with open CORS, at https://velum.consultorabim.com/api. Responses are JSON; errors are { "error": "…" } with their HTTP status code.
| Route | Returns |
|---|---|
GET /api/stats |
Height, hash, issuance, Reserve, supply, nodes, tiers and parameters |
GET /api/oracle |
Network price: { eur, usd, usdt, ts, source } (no price: null values) |
GET /api/blocks?limit=&before= |
Canonical blocks, newest first (max. 100) |
GET /api/block/:height-or-hash |
Block detail: payments, credits, rewards |
GET /api/block/:id?raw=1 |
The full signed block (last 5,000) |
GET /api/tx/:id |
Transaction, confirmations and signed JSON |
GET /api/txs?type=transfer|register|all&limit=&before= |
Latest transactions |
GET /api/fragments?limit=&before= |
Delivery receipts per block |
GET /api/nodes |
Nodes with tier, deliveries, earnings and status |
GET /api/address/:address |
Balance, nonce, activity and rewards |
GET /api/node/:address |
Same as above, for nodes |
GET /api/search?q= |
Looks up a height, hash, transaction, address or node name |
curl -s https://velum.consultorabim.com/api/stats
curl -s "https://velum.consultorabim.com/api/blocks?limit=25&before=120000"
curl -s https://velum.consultorabim.com/api/block/118204
curl -s "https://velum.consultorabim.com/api/block/118204?raw=1" # signed block JSON
curl -s https://velum.consultorabim.com/api/tx/4c1f0a9e…
curl -s "https://velum.consultorabim.com/api/txs?type=all&limit=50"
curl -s https://velum.consultorabim.com/api/address/vel8Kx3vQ…
curl -s "https://velum.consultorabim.com/api/search?q=118204"
For a live feed, connect to wss://velum.consultorabim.com/hub?role=watch: you'll get a snapshot, every new block and the nodes list.
The explorer API is convenient for lookups, but it is not a source of truth for moving funds. An exchange must verify against its own node.
Exchange deposits and withdrawals {#exchanges}
Account model
Two options, as on other account-based chains:
- One address per user (recommended). Derive
m/44'/7420'/0'/0'/N'per customer from a cold phrase. It doesn't depend on the user typing a memo correctly. - Shared address + memo. A single deposit address with the
memoas the customer reference. If the memo is missing or wrong, the deposit goes to manual review.
Detecting deposits
import { CHAIN_ID } from "@velum/core";
const CONFIRMATIONS = 6; // ≈ 12 s; see "Finality" above
let next = loadCursor(); // last processed height + 1
async function poll() {
const head = (await rpc("chain.head", {})) as { height: number };
while (next <= head.height - CONFIRMATIONS) {
const block = (await rpc("chain.block", { height: next })) as Block;
if (block.header.chain !== CHAIN_ID) throw new Error("wrong chain");
for (const tx of block.txs) {
if (tx.type !== "transfer" || !isOurDepositAddress(tx.to)) continue;
const asset = tx.token ?? "VEL";
credit({ txid: txId(tx), to: tx.to, asset, amount: tx.amount, memo: tx.memo, height: next });
}
saveCursor(++next);
}
}
// Re-check the hash of the last credited blocks on every poll: if it changed, a short reorg happened.
- Follow the chain block by block with
chain.headandchain.blockon your own node. - Only process
transfertransactions to your addresses. The asset istokenor, if absent, VEL. - Credit when the block has 30 confirmations.
- Store the hash of every processed block. If it has changed on the next pass, there was a reorganization: roll back and reprocess from that block.
- Sweep funds to cold storage when convenient. Moves under €1,000 pay no fee; larger ones pay 0.01%.
Withdrawals
import { makeTransfer, txId } from "@velum/core";
const acc = (await rpc("account.get", { address: hot.address })) as { nonce: number; pending: number };
const tx = makeTransfer(hot.secret, hot.address, userAddress, amountMicro, acc.nonce + acc.pending, { memo: withdrawalId });
const { id } = (await rpc("tx.submit", { tx })) as { id: string }; // id === txId(tx)
// Track id until it is CONFIRMATIONS blocks deep. Re-broadcasting the same signed tx is safe (same nonce).
- Sign in an isolated service; the node only receives already-signed transactions.
- Get the
noncewithaccount.get(nonce + pending) and keep your own counter if you send many in a row. - Check the destination address is valid and isn't a special
vel_…account. - If a withdrawal doesn't land within a few blocks, re-send the same signed transaction. Don't sign another one with a different
nonceuntil you know the first didn't land.
MetaMask Snaps {#snaps}
MetaMask speaks Ethereum, but Snaps let it add non-EVM chains. A Velum Snap needs to:
- Derive the ed25519 key. With
snap_getBip32Entropyandcurve: "ed25519"(SLIP-10) atm/44'/7420'/0'/0'/0', the same path as the SDK. The 7420 is pending registration in SLIP-44. - Build the address
vel+ base58 of the public key. - Sign the transaction's canonical JSON with ed25519 after asking the user to confirm with
snap_dialog. - Send the transaction to a node over WebSocket (
endowment:network-access) or return it signed to the dapp.
{
"version": "0.1.0",
"proposedName": "Velum",
"source": { "shasum": "…", "location": { "npm": { "filePath": "dist/bundle.js", "packageName": "velum-snap", "registry": "https://registry.npmjs.org/" } } },
"initialPermissions": {
"snap_getBip32Entropy": [{ "path": ["m", "44'", "7420'"], "curve": "ed25519" }],
"endowment:rpc": { "dapps": true, "snaps": false },
"snap_dialog": {},
"endowment:network-access": {}
},
"manifestVersion": "0.1"
}
import type { OnRpcRequestHandler } from "@metamask/snaps-sdk";
import { confirmUi } from "./ui"; // renders the transfer for the user
import { ed25519 } from "@noble/curves/ed25519.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { base58 } from "@scure/base";
import { canonical, CHAIN_ID } from "@velum/core";
// SLIP-10 ed25519 at m/44'/7420'/0'/0'/0' — the same path the Velum SDK and hardware wallets use.
async function walletKey() {
const node = await snap.request({
method: "snap_getBip32Entropy",
params: { path: ["m", "44'", "7420'", "0'", "0'", "0'"], curve: "ed25519" },
});
const secret = hexToBytes(node.privateKey!.slice(2)); // SLIP-10 ed25519 key, "0x…"
const pub = ed25519.getPublicKey(secret);
return { secret, address: "vel" + base58.encode(pub) };
}
export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
const k = await walletKey();
if (request.method === "velum_getAddress") return k.address;
if (request.method === "velum_signTransfer") {
const { to, amount, nonce, token, memo, fee } = request.params as any;
// fee: 0 below 1,000 € at the network oracle price (plus a small proof of work, see PROTOCOL.md), else 0.01 %
const body = { type: "transfer", chain: CHAIN_ID, from: k.address, to, amount, fee, nonce, ts: Date.now(), token, memo };
const ok = await snap.request({ method: "snap_dialog", params: { type: "confirmation", content: confirmUi(body) } });
if (!ok) throw new Error("rejected");
const sig = bytesToHex(ed25519.sign(new TextEncoder().encode(canonical(body)), k.secret));
return { ...body, sig };
}
throw new Error("method not found");
};
Important:
- The path is the same, but the phrase is MetaMask's: the Snap account doesn't match your Velum app account unless you use the same phrase in both places.
- Before publishing, the Snap must pass MetaMask's audit and allowlisting process.
Checklist for listing VEL on an exchange {#listado}
What a large exchange (Binance, Kraken, Coinbase…) usually asks for to integrate a new chain, and where each item stands:
| Requirement | Status |
|---|---|
| A node the exchange can run | Run a node: dependency-free binary, Linux x64/arm64 |
| Stable, documented RPC | Node RPC |
| Public explorer | Explorer and REST API |
| Transaction and signature format spec | Transactions |
| Finality rule | Finality |
| Open-source node | Public velum-node repository (in preparation) |
| External security audit | Pending, before mainnet |
| Mainnet | velum-mainnet-1, with no faucet or test tokens |
| Legal framework (MiCA in the EU) | Under review with legal counsel |
Payment gateways {#pasarelas}
Velum works for real-time payments: blocks within seconds and zero fees on payments under €1,000. Always show the value in EUR, USD or USDT at the network oracle price, and what the merchant receives.
- Generate an address per order (deriving
m/44'/7420'/0'/0'/N'), or use a fixed address with the order ID in thememo. - Show a QR code with the address, amount and token (for example,
{{quote}}). - Listen for blocks via
hub?role=watchor your node and mark the order as paid at 6 confirmations. - If you get paid in a token and want to settle in VEL (or the other way round), use the market.
The @velum/core SDK {#sdk}
The same core used by the app, the node and this website. TypeScript, no native dependencies: it runs on Bun, Node, browsers, Workers and React Native.
Connect
import { VelumClient, keysFromPhrase, generatePhrase, createDevice, createAccount } from "@velum/core";
const phrase = generatePhrase("en"); // 24 words — store it offline
const keys = keysFromPhrase(phrase);
const account = createAccount(keys, createDevice("server-1"));
const client = new VelumClient({ account }); // defaults to the public gateways
client.on("status", (s) => console.log("network:", s));
client.connect();
await client.faucet(); // testnet only: 25 VEL
Send a message
client.on("message", (m) => console.log(m.payload.from, m.payload.body));
client.setContact(theirDeviceList); // signed DeviceList received out of band / via QR
const progress = await client.send(theirIdentity, { kind: "text", body: "Hola" });
client.on("progress", (p) => console.log(`${p.accepted}/${p.total} shards accepted`));
Pay
import { parseAmount } from "@velum/core";
const { id } = await client.pay("vel4Jt9mR…", parseAmount("12,5")!); // VEL (µVEL integer)
await client.pay("vel4Jt9mR…", 2_000_000, { token: "tEUR", memo: "order-981" }); // 2 tEUR, fee in VEL
const { balance, nonce } = await client.balance();
Place an order
// Buy 250 VEL at 0.0125 tEUR each. price = smallest token units per 1 VEL (tEUR has 6 decimals).
await client.placeOrder({ token: "tEUR", side: "buy", price: 12_500, amount: 250_000_000, ttl: 1_440 });
Create a token
await client.createToken({ symbol: "CAFE", name: "Café de barrio", decimals: 2, maxSupply: 100_000_000, initial: 2_500_000, mintable: true });
// Costs 100 VEL. Then reward nodes in CAFE for a day:
// makePoolFund(wallet.secret, wallet.address, nonce, "CAFE", 1_440_000, 1_440)
The SDK also exports the transaction builders (makeTransfer, makeRegister, makeTokenCreate, makeTokenMint, makePoolFund, makeOrder, makeCancel), state readers (supplyInfo, difficulty, bookOf, tradesOf) and amount formatting (formatAmount, parseAmount).