One block at a time per wallet
Each block moves the wallet’s head. Re-read the account (or use the hash and balance you just submitted) before building the next block, or the next one is refused as fork.
A small JSON API over HTTPS. Read balances, chains and payments, and submit blocks your own code has signed. There are no API keys: every write is a block signed by the wallet itself.
https://gateway.oneeur.comcontent-type: application/json on POST. Request bodies are limited to 64 KB.1 OEUR = 100000000 units.oe_ed25519_…: the public key plus a 4-byte checksum, in lowercase base32.curl https://gateway.oneeur.com/api/overview
{
"genesis": "oe_ed25519_iawd2rvv…",
"total_supply": "600000000000000000000000000",
"block_count": 481510,
"wallet_count": 23,
"pending_total": "82721929340"
}
Every confirmed block is also visible in the explorer.
Open the explorerGET /api/accounts/:addresshead). Read this before signing the wallet’s next block. 404 if the wallet has no chain yet.{address, balance, height, head}GET /api/accounts/:address/pending{address, pending: [{send_hash, from, amount, memo}]}GET /api/accounts/:address/chainlimit is 1–500, default 50.{address, blocks: [block]}GET /api/blocks/:hashblockPOST /api/blocks201 {status, hash, confirmations, holders, confirmed_in_us}GET /api/overview{genesis, total_supply, block_count, wallet_count, pending_total}GET /api/walletspage starts at 1; per_page is 1–100, default 20.{page, per_page, total, items: [{address, balance, height, head, pending}]}GET /api/transactionsamount, counterparty and whether it is still pending. Paged like /api/wallets. Add address= to keep only blocks on that wallet’s chain or paying it.{page, per_page, total, items: [block + {amount, counterparty, pending}]}GET /api/activityminute is a Unix timestamp. Counted since the server last started.{minutes: [{minute, sends, receives}]}GET /api/eventsblock event per confirmed block. See below.event: block · data: blockGET /api/holders{primary, holders: [{name, online, loading, catching_up, blocks, bytes, wallets}]}GET /health{"status": "ok"}open (a wallet’s first block), send or receive"ed25519" today. May be left out on input.open"" for none. Public and permanent.hash by the wallet’s key{
"type": "send",
"algorithm": "ed25519",
"account": "oe_ed25519_…",
"height": 2,
"previous": "<64 hex>",
"balance": "2099999000000000",
"link": "oe_ed25519_…",
"memo": "March rent",
"hash": "<64 hex>",
"signature": "<128 hex>"
}
The block carries no amount. A send’s amount is the drop in balance from the block before it; a receive’s is the amount of the send it collects.
GET /api/accounts/<sender> gives head, height and balance.
Build a send with height + 1, previous = head, balance = balance − amount and link = the receiver. Sign it and POST /api/blocks.
The receiver reads /pending and, for each item, signs an open (if the account was 404) or a receive with link = send_hash and the same memo.
Each block moves the wallet’s head. Re-read the account (or use the hash and balance you just submitted) before building the next block, or the next one is refused as fork.
An open or receive must repeat the send’s memo exactly, so both chains hold the same signed text. Use it for an invoice or order number; anyone can read it.
"ONEEUR-BLOCK-V1" 15 bytes format name
type 1 byte 1 = open, 2 = send, 3 = receive
account key algorithm (1) + length (2) + public key
height 8 bytes unsigned big-endian
previous 32 bytes hash of the previous block
balance 16 bytes unsigned big-endian, after this block
link key / 32 send: receiver · open/receive: the send’s hash
memo length 1 byte 0–128
memo 0–128 UTF-8 text
key = 0x00 (Ed25519) + 0x0020 (32) + 32-byte public key
hash = SHA-256(bytes above)
signature = Ed25519(wallet seed, hash)
address = "oe_ed25519_" + base32(public key + SHA-256(public key)[0..4])
(RFC 4648 alphabet, lowercase, no padding)
npm install @noble/curves @noble/hashes
import { ed25519 } from '@noble/curves/ed25519.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
const PREFIX = 'oe_ed25519_';
const B32 = 'abcdefghijklmnopqrstuvwxyz234567';
function base32(bytes) {
let bits = 0, value = 0, out = '';
for (const b of bytes) {
value = (value << 8) | b; bits += 8;
while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
function unbase32(str) {
let bits = 0, value = 0; const out = [];
for (const c of str) {
const i = B32.indexOf(c);
if (i < 0) throw new Error('invalid address');
value = (value << 5) | i; bits += 5;
if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; }
}
return Uint8Array.from(out);
}
export function encodeAddress(publicKey) {
const check = sha256(publicKey).slice(0, 4);
return PREFIX + base32(new Uint8Array([...publicKey, ...check]));
}
export function decodeAddress(address) {
if (!address.startsWith(PREFIX)) throw new Error('invalid address');
const raw = unbase32(address.slice(PREFIX.length));
const key = raw.slice(0, 32), check = raw.slice(32);
const expected = sha256(key).slice(0, 4);
if (raw.length !== 36 || check.some((b, i) => b !== expected[i])) throw new Error('invalid address');
return key;
}
// Keep `seed` secret, e.g. in the device's secure storage.
export function newWallet(seed = randomBytes(32)) {
const publicKey = ed25519.getPublicKey(seed);
return { seed, publicKey, address: encodeAddress(publicKey) };
}
const MAGIC = new TextEncoder().encode('ONEEUR-BLOCK-V1');
const TYPES = { open: 1, send: 2, receive: 3 };
function uintBE(value, bytes) {
const out = new Uint8Array(bytes);
let v = BigInt(value);
for (let i = bytes - 1; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; }
return out;
}
export function memoBytes(memo = '') {
const bytes = new TextEncoder().encode(memo);
if (bytes.length > 128 || /\p{Cc}/u.test(memo)) throw new Error('bad memo');
return bytes;
}
// algorithm 0 (Ed25519), 2-byte key length, public key
const key = (publicKey) => [0, publicKey.length >> 8, publicKey.length & 0xff, ...publicKey];
export function signingPayload(b) {
const memo = memoBytes(b.memo);
return new Uint8Array([
...MAGIC,
TYPES[b.type],
...key(b.account),
...uintBE(b.height, 8),
...b.previous,
...uintBE(b.balance, 16),
...(b.type === 'send' ? key(b.link) : b.link),
memo.length,
...memo,
]);
}
export function signBlock(wallet, b) {
const hash = sha256(signingPayload(b));
return {
type: b.type,
algorithm: 'ed25519',
account: encodeAddress(b.account),
height: b.height,
previous: bytesToHex(b.previous),
balance: b.balance.toString(),
link: b.type === 'send' ? encodeAddress(b.link) : bytesToHex(b.link),
memo: b.memo ?? '',
hash: bytesToHex(hash),
signature: bytesToHex(ed25519.sign(hash, wallet.seed)),
};
}
// account: JSON from GET /api/accounts/:address; amount: bigint units
export function sendBlock(wallet, account, toAddress, amount, memo = '') {
const balance = BigInt(account.balance);
if (amount <= 0n || amount > balance) throw new Error('bad amount');
return signBlock(wallet, {
type: 'send', account: wallet.publicKey, height: account.height + 1,
previous: hexToBytes(account.head), balance: balance - amount,
link: decodeAddress(toAddress), memo,
});
}
// pending: one item from /pending; account: null if the wallet had a 404
export function receiveBlock(wallet, account, pending) {
const amount = BigInt(pending.amount);
const link = hexToBytes(pending.send_hash);
const memo = pending.memo ?? '';
if (!account) {
return signBlock(wallet, {
type: 'open', account: wallet.publicKey, height: 1,
previous: new Uint8Array(32), balance: amount, link, memo,
});
}
return signBlock(wallet, {
type: 'receive', account: wallet.publicKey, height: account.height + 1,
previous: hexToBytes(account.head), balance: BigInt(account.balance) + amount, link, memo,
});
}
const API = 'https://gateway.oneeur.com';
const account = await (await fetch(`${API}/api/accounts/${me.address}`)).json();
const block = sendBlock(me, account, 'oe_ed25519_…', 150_000_000n, 'Invoice 1042');
const res = await fetch(`${API}/api/blocks`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(block),
});
console.log(res.status, await res.json());
// 201 { status: 'confirmed', hash: '…', confirmations: 1, holders: 3, confirmed_in_us: 1840 }
Blocks signed with this code give the same hashes as the backend’s own signing code. In another language, rebuild the same bytes and compare your hash with one from /api/transactions.
GET /api/events keeps the connection open and sends one block event per confirmed block, with the block JSON as data. A comment line (: ping) arrives every 15 seconds so idle connections stay open. Events are not replayed: after a reconnect, catch up with /api/transactions.
curl -N https://gateway.oneeur.com/api/events
retry: 2000
event: block
data: {"type":"receive","account":"oe_ed25519_…","height":23920,…}
// Node 22+ or any EventSource client
const events = new EventSource('https://gateway.oneeur.com/api/events');
events.addEventListener('block', (e) => {
const block = JSON.parse(e.data);
if (block.type === 'send' && block.link === myAddress) {
// a payment to you: collect it from /pending
}
});
400invalid_address, invalid_hash, invalid_limit, invalid_page, malformed_block or malformed_json.{"error": "…"}404not_found: no such block, a wallet with no chain yet, or an unknown path.{"error": "not_found"}413, 415request_too_large (over 64 KB) or unsupported_media_type (not JSON).422{"status": "rejected", "reason": "…"}503primary_unavailable: blocks can’t be confirmed right now. loading: a holder is still reading its files after a restart. unavailable: no holder answered a read.bad_signaturebad_balanceforkbad_previous, gapaccount_not_opensend or receive on a wallet that has no chain yet. Its first block must be an open.unknown_sendalready_existsinvalid_memomemo_mismatchself_sendunsupported_algorithmed25519.A 201 means the primary has added the block (confirmations: 1); copies reach the other holders in the background. The primary is trusted, and while it is down no block can be confirmed. There is no failover yet.
Accounts, pending payments and a wallet’s chain come straight from the primary. Overview, wallets, transactions and blocks come from a read copy that is usually less than a millisecond behind. Right after you submit, look up your own wallet through /api/accounts.
There are no rate limits or spam protection yet. Please don’t poll in a tight loop: use /api/events to learn about new blocks.
Two blocks per payment, one primary that confirms, full copies on every holder.
Read moreEd25519 and SHA-256, a key that stays on your phone, and what is still to do.
Read moreKeep a full copy of every chain on your own machine with one Docker command.
Read more