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/sdk SDK, 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)
  1. Build the object without sig. Leave out optional fields you don't use (don't set them to null).
  2. Serialize to canonical JSON: sorted keys, no whitespace.
  3. Sign the UTF-8 bytes with the wallet key. sig = signature in hex.
  4. 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)"
}
  • amount in the token's smallest units (µVEL if there's no token). Greater than 0.
  • fee follows 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 with fee 0 carry a work stamp pow (SHA-512, about 50 ms on a phone) that is part of what is signed; buildTransfer in @velum/sdk computes it. The exact format is in PROTOCOL.md and spec/vectors/.
  • token is optional: the token symbol. Without it, VEL is sent.
  • memo is 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 nonce is 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 nonce is 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 }
| `faucet` | `{ address }` | `{ id, amount }` (test network only) |

| 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 memo as 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.
  1. Follow the chain block by block with chain.head and chain.block on your own node.
  2. Only process transfer transactions to your addresses. The asset is token or, if absent, VEL.
  3. Credit when the block has 30 confirmations.
  4. 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.
  5. 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 nonce with account.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 nonce until 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:

  1. Derive the ed25519 key. With snap_getBip32Entropy and curve: "ed25519" (SLIP-10) at m/44'/7420'/0'/0'/0', the same path as the SDK. The 7420 is pending registration in SLIP-44.
  2. Build the address vel + base58 of the public key.
  3. Sign the transaction's canonical JSON with ed25519 after asking the user to confirm with snap_dialog.
  4. 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.

  1. Generate an address per order (deriving m/44'/7420'/0'/0'/N'), or use a fixed address with the order ID in the memo.
  2. Show a QR code with the address, amount and token (for example, {{quote}}).
  3. Listen for blocks via hub?role=watch or your node and mark the order as paid at 6 confirmations.
  4. 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).