Bet UID Format
A bet UID identifies a bet globally. It has two parts:
{service_uid}-{ulid} 16 1 26- service_uid (16 chars) — which service minted it
- ulid (26 chars) — unique within that service, time-ordered
- a single
-separator
Total: 43 characters. The design gives four properties:
- Self-identifying — the prefix names the minting service.
- Idempotency key — the
(engineType, serviceBetUid)pair is unique in the ledger. - Time-sortable — the ULID’s first 48 bits are a timestamp, so sort order = time order.
- Collision-free — the ULID’s 80 bits of randomness make collision risk effectively zero.
Format specification
Section titled “Format specification”Alphabet — Crockford Base32
Section titled “Alphabet — Crockford Base32”ULIDs use Crockford Base32:
0123456789ABCDEFGHJKMNPQRSTVWXYZ- 32 symbols: 0–9 plus A–Z, excluding
I,L,O,U. - Case-insensitive (but lowercase is used here — see below).
I/Lare dropped so they cannot be confused with1, andOwith0.
Full bet UID validation:
^[0-9a-hjkmnp-tv-z]{16}-[0-9a-hjkmnp-tv-z]{26}$- first 16:
service_uid(lowercase Crockford) - separator
- - last 26:
ulid(lowercase Crockford)
Service UID alone:
^[0-9a-hjkmnp-tv-z]{16}$Service UID (16 characters)
Section titled “Service UID (16 characters)”The platform mints the service UID and provides it when the service is registered. The service only stores it — not in env, but learned at runtime from the first desired-state push.
16 Crockford Base32 characters = 16 × 5 = 80 bits. The platform checks for collisions against existing services when minting; the service does nothing here.
ULID (26 characters)
Section titled “ULID (26 characters)” 01JQ8Z9K7E 3M4N5P6Q7R8S9T0V|timestamp | | randomness | 48 bit 80 bit 10 chars 16 chars- first 10 characters: 48-bit timestamp (ms, Unix epoch)
- last 16 characters: 80-bit randomness
- 26 characters total
Why ULID, not UUID?
| Property | ULID | UUID v4 |
|---|---|---|
| Length | 26 chars | 36 chars |
| Sortable | ✅ (timestamp prefix) | ❌ |
| URL-safe | ✅ | ⚠️ (hyphens) |
| Readable | ✅ | ❌ |
Lowercase. The ULID spec uses uppercase, but bet UIDs are lowercased —
to avoid case-insensitive comparison issues in the ledger, for URL consistency,
and for DB index consistency. Always lowercase: call .toLowerCase() after
minting.
Reference implementation (JavaScript, vanilla)
Section titled “Reference implementation (JavaScript, vanilla)”A minimal, zero-dependency implementation, ready to copy into a service:
// betUid.js — CGSP reference implementationimport { randomBytes } from 'node:crypto';
const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';// Crockford Base32 (lowercase) — i, l, o, u excluded
export const SERVICE_UID_LENGTH = 16;export const ULID_LENGTH = 26;export const BET_UID_LENGTH = SERVICE_UID_LENGTH + 1 + ULID_LENGTH; // 43
const SERVICE_UID_RE = /^[0-9a-hjkmnp-tv-z]{16}$/;const ULID_RE = /^[0-9a-hjkmnp-tv-z]{26}$/;const BET_UID_RE = /^[0-9a-hjkmnp-tv-z]{16}-[0-9a-hjkmnp-tv-z]{26}$/;
function encodeCrockford(value, length) { let v = value; let out = ''; for (let i = 0; i < length; i++) { out = ALPHABET[Number(v & 0x1fn)] + out; v >>= 5n; } return out;}
function randomBigInt(byteLen) { const bytes = randomBytes(byteLen); let acc = 0n; for (const b of bytes) acc = (acc << 8n) | BigInt(b); return acc;}
/** 26-char lowercase ULID: 48-bit timestamp + 80-bit randomness. */export function ulid() { const ts = BigInt(Date.now()); return encodeCrockford(ts, 10) + encodeCrockford(randomBigInt(10), 16);}
export const isValidServiceUid = (v) => typeof v === 'string' && SERVICE_UID_RE.test(v);export const isValidUlid = (v) => typeof v === 'string' && ULID_RE.test(v);export const isValidBetUid = (v) => typeof v === 'string' && BET_UID_RE.test(v);
/** * Build a {serviceUid}-{ulid} bet reference. Throws on an invalid serviceUid so * misconfiguration (empty lookup, legacy string, etc.) surfaces immediately * instead of writing a malformed bet id. */export function makeBetUid(serviceUid) { if (!isValidServiceUid(serviceUid)) { throw new Error( `makeBetUid: invalid serviceUid (expected ${SERVICE_UID_LENGTH} lowercase Crockford Base32 chars)`, ); } return `${serviceUid}-${ulid()}`;}The service_uid arrives on the desired-state push; store it, then mint per bet.
If an engine has no service_uid yet (a bet arrived before reconcile), the bet
must be rejected — otherwise it produces a malformed bet id the platform
rejects.
import { makeBetUid } from './betUid.js';
const serviceUidByEngineId = new Map();
function onDesiredState(payload) { for (const e of payload.engines) { serviceUidByEngineId.set(e.engineId, e.serviceUid); }}
function acceptBet(engineId /* ... */) { const serviceUid = serviceUidByEngineId.get(engineId); if (!serviceUid) { throw new Error(`engine ${engineId} has no serviceUid yet — reject bet`); } return makeBetUid(serviceUid); // "wp9dvdgp4wfqmxq4-01krrh7xn3qq5e69gn58wkjt38"}Other languages
Section titled “Other languages”The algorithm is the same everywhere; it needs only three primitives: a CSPRNG (10 random bytes), big-integer arithmetic (to split into 5-bit groups), and the alphabet lookup table.
Python:
import secrets, time
ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'
def encode(value: int, length: int) -> str: out = [] for _ in range(length): out.append(ALPHABET[value & 0x1f]) value >>= 5 return ''.join(reversed(out))
def ulid() -> str: ts = int(time.time() * 1000) rand_bigint = int.from_bytes(secrets.token_bytes(10), 'big') return encode(ts, 10) + encode(rand_bigint, 16)
def make_bet_uid(service_uid: str) -> str: if not (len(service_uid) == 16 and all(c in ALPHABET for c in service_uid)): raise ValueError("invalid service_uid") return f"{service_uid}-{ulid()}"Go:
package betuid
import ( "crypto/rand" "errors" "math/big" "time")
const alphabet = "0123456789abcdefghjkmnpqrstvwxyz"
func encode(v *big.Int, length int) string { out := make([]byte, length) mask := big.NewInt(0x1f) tmp := new(big.Int).Set(v) for i := length - 1; i >= 0; i-- { out[i] = alphabet[new(big.Int).And(tmp, mask).Int64()] tmp.Rsh(tmp, 5) } return string(out)}
func ULID() string { ts := big.NewInt(time.Now().UnixMilli()) b := make([]byte, 10) rand.Read(b) rnd := new(big.Int).SetBytes(b) return encode(ts, 10) + encode(rnd, 16)}
func MakeBetUID(serviceUID string) (string, error) { if len(serviceUID) != 16 { return "", errors.New("invalid serviceUID") } return serviceUID + "-" + ULID(), nil}Common mistakes
Section titled “Common mistakes”| Mistake | Cause |
|---|---|
| Uppercase ULID | Forgot .toLowerCase() |
| Minting your own service_uid | The platform mints it; only use it |
| Using UUID | Use ULID (sortable) |
_ as separator | Use a single - |
| No service_uid validation | Validate when minting |
Suggested DB schema
Section titled “Suggested DB schema”CREATE TABLE bets ( bet_uid char(43) PRIMARY KEY, service_uid char(16) NOT NULL, ...);char(43) is fixed-length — ideal for index performance. The service_uid can
also be parsed out into its own column for easier querying.
Related
Section titled “Related”- CGSP v1.1 Specification — Service UID and engine identity
- Wallet API — where bet UIDs become
trxIds - Gameplay Protocol — minting on each
placeBet