Developer documentation
Build on ImagineNation
ImagineNation is a thin layer on top of TXC (a Bitcoin-derived chain) and the Omni Layer token protocol. This page tells you exactly what the server does, what the on-chain calls look like, and how to drive it from your own code — even if you've never touched Bitcoin RPC before.
Start here
What is this?
ImagineNation lets a community (a co-op, a venue, a music festival, a neighborhood) issue its own token on the TXC blockchain and hand it out to people. Tokens can be earned, granted (minted), spent at a tap-to-pay terminal, or sold via a USD on-ramp.
There are three audiences for this page:
- Operators running a community in the Console — read Concepts + Issue a token.
- Terminal / POS integrators calling our public API — jump to POST /api/public/tap.
- Devs forking the codebase — read everything. The
src/lib/txc/*.server.tsfiles are the source of truth.
Mental model
Concepts (read first)
These five ideas explain 90% of the system. If you skip them, the rest will look like nonsense.
- TXC is Bitcoin, mostly. Same UTXO model, same script opcodes, same RPC names (
scantxoutset,sendrawtransaction, …). Only the network constants differ — addresses start withT, WIF private keys start with version0xc1. See Network constants. - Omni is a token layer that piggybacks on TXC. Each token transaction is a regular TXC transaction with an extra
OP_RETURNoutput that the Omni node interprets. The magic byte is0x42(Omni calls it "exodus"). - Every token has a numeric
propertyId. It's assigned by the network when the create-property tx is mined, not when it's broadcast. That's why issuance is a two-step flow: create then confirm. - The server signs with one wallet. The
TXC_WIFsecret is the issuer's private key. We never custody end-user keys — users paste their own TXC address. - Divisible Omni tokens are always 8 on-chain decimals. The
precisiondeclared at issuance (we default to2for money-style display) is a UX hint in app metadata — it does not change the on-chain unit. On the wire, every divisible-token amount is a uint64 of base units where1 token = 100,000,000 base units. So$63.88of a 1:1-USD-pegged token is6,388,000,000base units — not6388. High-level RPCs (omni_send,omni_sendgrant) accept decimal strings ("63.88") and convert internally; raw-built payloads must put the 8-dp base-unit integer directly into the amount field.cents → base units (divisible)// WRONG — assumes display precision == on-chain precision const units = BigInt(cents) * 10n ** BigInt(displayDecimals - 2); // 6388 // RIGHT — divisible Omni is always 8 dp on-chain const units = BigInt(cents) * 10n ** BigInt(8 - 2); // 6_388_000_000
5-minute tour
Quickstart
From zero to a tap-to-pay community in five steps:
- Sign in to the Console and create a community. Pick a 3–5 char symbol (e.g.
CROW). - The server auto-broadcasts an Omni Create Property (Managed) tx. The community row stores the
issuance_txidimmediately. - Wait ~1 confirmation, then click Confirm on the community row. The server reads back the assigned
propertyIdand stores it astoken_property_id. - Test the on-ramp at /crow-dollars-test: enter a USD amount + a recipient TXC address; the server grants the equivalent tokens.
- Provision a terminal in the Console. Use its
terminal_id+hmac_secretto call POST /api/public/tap from your POS device.
Constants
Network constants (TXC mainnet)
TXC is Bitcoin-derived but uses its own version bytes. Reusing Bitcoin defaults (e.g. WIF 0x80) will fail with Invalid network version when you try to import a key. Hard-code these in any TXC-aware library:
pubKeyHash (P2PKH): 0x42 // "T..." addresses
scriptHash (P2SH): 0x05
WIF version: 0xc1 // private keys start with "L" or "K" tail-encoded
bech32 prefix: (none — TXC uses base58 only)
Omni magic byte: 0x42 // OP_RETURN payload prefix ("exodus")
Omni mainnet eco: 1These match what texitcoin.org/build and tokens.texitcoin.org publish. Do not reuse bitcoinjs-lib mainnet defaults — build a small TXC network object and pass it explicitly.
Connection
RPC connection
The server talks to a TXC Core node via JSON-RPC over HTTPS with HTTP basic auth. Four secrets are required, all kept server-side:
TXC_RPC_URL # e.g. https://node.example.com:8332
TXC_RPC_USER # basic-auth user
TXC_RPC_PASS # basic-auth pass
TXC_WIF # issuer wallet WIF (version byte 0xc1)All RPC calls live in src/lib/txc/rpc.server.ts. They are read inside server-fn handlers via process.env so they never leak to the client bundle.
curl -u "$TXC_RPC_USER:$TXC_RPC_PASS" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"1.0","id":"1","method":"getblockchaininfo","params":[]}' \
$TXC_RPC_URLKeys
Wallets & WIF import
The issuer's private key is supplied as WIF (Wallet Import Format). To use it on the node we importprivkey once, then derive its address client-side with our TXC-aware decoder.
// Decode WIF -> raw 32-byte private key + compression flag
// first byte must equal 0xc1 (TXC), not 0x80 (Bitcoin)
// Derive secp256k1 pubkey
// hash160(pubkey)
// base58check( 0x42 || hash160 ) -> "T..." addressNever custody user keys
Gotchas
Fees & dust on TXC
TXC's effective dust threshold is 10,000 sats (0.0001 TXC) — significantly higher than Bitcoin's 546 sats. Any output below that gets rejected with:
{"result":null,"error":{"code":-26,"message":"dust"}}Our raw-tx builders (raw-issuance.server.ts, raw-grant.server.ts) use BigInt-safe satoshi math, fix the Omni reference output at exactly 10,000 sats, and drop the change output (folding it into the miner fee) if change would itself be dust. Don't go lower.
Operator flow
Issue a community token
Communities are minted as Omni-layer managed tokens on TXC mainnet (ecosystem 1). Auto-issuance fires when an operator creates a community in /console/communities.
We do not use omni_sendissuancemanaged directly. That RPC requires the node wallet to own and have rescanned the issuer address; a freshly imported key has no usable UTXOs from the node's perspective (RPC error -206 "Error with selected inputs"). Instead we build and sign the issuance tx ourselves:
1. derive issuer "T..." address from TXC_WIF
2. scantxoutset for UTXOs paying that address
3. build raw tx:
vin = chosen UTXOs
vout = [ change back to issuer (>= 10_000 sats or omitted),
OP_RETURN <omni_payload> ]
4. omni_payload — 15-byte header + null-terminated strings:
magic 0x42 // "exodus" on TXC
version 0x0000
type 0x0036 // 54 = Create Property (Managed)
ecosystem 0x01 // mainnet
prop_type 0x0002 // 2 = divisible (1 = indivisible)
previousId 0x00000000
category "" \0
subcategory "" \0
name <symbol> \0
url "" \0
data "" \0
5. signrawtransactionwithkey(rawHex, [TXC_WIF], prevouts)
6. sendrawtransaction(signedHex) -> txidDefaults baked into operator.functions.ts: precision 2 (money-style), divisible, blank URL, name = the community's token symbol. The returned txid is stored on the community row immediately. The assigned propertyId is filled in by the Confirm step below.
Operator flow
Confirm issuance
Issuance is async: the create tx broadcasts immediately, but Omni doesn't assign the token its propertyId until the tx is mined. The Confirm button on each community row runs issuanceConfirm:
1. load community, require issuance_txid
2. if token_property_id is set -> done, return it
3. omni_gettransaction(issuance_txid)
- !valid or confirmations < 1 -> { confirmed: false }
4. extract status.purchasedpropertyid (fallback: .propertyid)
5. omni_getproperty(propertyId) // sanity check
6. UPDATE communities SET token_property_id = <id>Idempotent — safe to mash
{ confirmed: false }. After it mines, the row shows the assigned property id and grants/sends become available.Operator flow
Send / mint tokens
Once a community has a confirmed token_property_id, the issuer can mint new supply with omni_sendgrant or move existing balance with omni_send. The on-ramp uses sendgrant directly to the buyer's address.
omni_sendgrant(fromAddress, toAddress, propertyId, amount)
omni_send(fromAddress, toAddress, propertyId, amount)amount is a decimal string ("12.34") — the node handles the precision shift internally for divisible tokens. If you build a raw Simple Send payload instead (the path self-custody wallets use — see Client-signed payments), the amount field on the wire is a uint64 of 8-dp base units, not a decimal string.
Self-custody flow
Client-signed payments (wallet → merchant)
Tap-to-pay debits the issuer's ledger. A user paying a merchant from their own wallet is different: the private key lives on the user's device and never touches the server. The server's only job is to prepare the target and record the result.
Flow
- Merchant terminal creates a
chargesrow (amount in cents, currency, optional memo) and shows a QR encodingqr_token. - User scans, opens
/pay/$token. The page callsgetPayTargetserver fn which returns the merchant payout address, the community'sproperty_id, andamount_units_str— a stringified BigInt of 8-dp base units (already correctly scaled, see Concept #5). - Client unlocks the local encrypted WIF, fetches a fee-paying UTXO via
scantxoutset, builds and signs an Omni Simple Send transaction in the browser, then broadcasts the signed hex viasendrawtransaction. - Server's
recordChargePaidlooks up the sending address inwallets, then setscharges.status='paid',tx_hash=<txid>,paid_by_wallet_id=<wallet.id>.
getPayTarget response
{
"charge": { "id": "...", "qr_token": "...", "amount_cents": 6388, ... },
"merchant": { "id": "...", "name": "Bear Cafe" },
"destination_address": "T...",
"custodial": false,
"community": {
"slug": "apsaalooke",
"property_id": 23,
"symbol": "APSA",
"decimals": 2 // DISPLAY hint, not on-chain decimals
},
"amount_units_str": "6388000000" // 8-dp base units, BigInt as string
}Wire format (Omni Simple Send)
magic 0x42 // exodus
version 0x0000
type 0x0000 // 0 = Simple Send
propertyId u32 BE // e.g. 0x00000017 = property 23
amount u64 BE // 8-dp base units, NOT decimal stringThe signer also needs at least FEE_SATS + DUST_SATS (~10,000 sat reference output + miner fee) of plain TXC at the sending address. getSpendableUtxos sorts by amount so the largest UTXO is preferred.
Client-side amount round-trip
// server hands you a string so BigInt survives JSON
const amountUnits = BigInt(target.amount_units_str); // 6_388_000_000n
const hex = signOmniSimpleSend({
privkeyHex: unlocked.privkeyHex,
fromAddress: unlocked.address,
toAddress: target.destination_address,
propertyId: target.community.property_id,
amount: amountUnits, // base units, NOT amount_cents
utxo: feeUtxo,
});Wallet history blind spot
omni_listtransactions(address, …) only returns transactions where the address (or its counterparty) is in the node's local wallet. Self-custody → external merchant addresses are NOT indexed there. Incoming grants show up because the issuer/treasury IS in the node wallet; outgoing spends to merchant addresses are invisible. The wallet UI must merge in charges rows filtered by paid_by_wallet_id to surface them — see getLiveCommunityActivity in src/lib/live-wallet.functions.ts.USD on-ramp
USD → tokens (test flow)
The /crow-dollars-test page simulates a buy: USD amount in, equivalent CROW granted to a user-supplied TXC address. Two-decimal money math throughout. A fair.money checkbox on the community row enables the hosted on-ramp page.
Under the hood, the server calls the same raw-grant pipeline that powers the Console — see src/lib/txc/raw-grant.server.ts.
Public API
POST /api/public/tap
Authoritative endpoint for tap-to-pay terminals. Records a tap, deduplicates by nonce, and (in event mode) debits the custodial ledger. Community-mode taps land as pending_approve for the push-to-phone confirmation flow.
Headers
x-terminal-iduuidrequiredThe terminal's id, issued in the Console when you provision the device.
x-terminal-signaturehex stringrequiredHMAC-SHA256(hmac_secret, raw_request_body), hex-encoded. Sign the exact bytes you POST — don't re-serialize.
content-typestringapplication/json
Body
card_uidstring (1–64)requiredThe NFC card's UID as read by the terminal.
amount_centsinteger (1 – 10,000,000)requiredTap amount in cents. 250 = $2.50.
noncestring (8–128)requiredClient-generated unique id for this tap. Replays with the same (terminal_id, nonce) return the original tap with replay: true.
Example request
BODY='{"card_uid":"04A1B2C3D4E580","amount_cents":250,"nonce":"tap_01HV…"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$HMAC_SECRET" -hex | awk '{print $2}')
curl -X POST https://txc-rails.lovable.app/api/public/tap \
-H "content-type: application/json" \
-H "x-terminal-id: $TERMINAL_ID" \
-H "x-terminal-signature: $SIG" \
--data "$BODY"import { createHmac } from "node:crypto";
const body = JSON.stringify({
card_uid: "04A1B2C3D4E580",
amount_cents: 250,
nonce: crypto.randomUUID(),
});
const sig = createHmac("sha256", HMAC_SECRET).update(body).digest("hex");
const res = await fetch("https://txc-rails.lovable.app/api/public/tap", {
method: "POST",
headers: {
"content-type": "application/json",
"x-terminal-id": TERMINAL_ID,
"x-terminal-signature": sig,
},
body, // must be the SAME bytes you signed
});
const { tap, replay } = await res.json();Responses
{
"tap": {
"id": "…",
"terminal_id": "…",
"merchant_id": "…",
"card_uid": "04A1B2C3D4E580",
"amount_cents": 250,
"mode": "community",
"status": "pending_approve",
"nonce": "tap_01HV…",
"token_property_id": 12345,
"wallet_id": "…"
},
"replay": false
}{ "tap": { … }, "replay": true }401 { "error": "missing auth headers" }
401 { "error": "unknown terminal" }
401 { "error": "bad signature" }
400 { "error": "invalid body", "detail": "…" }
404 { "error": "unknown card" }
403 { "error": "card disabled" }
500 { "error": "tap insert failed", "detail": "…" }Sign the raw bytes
Help
Errors & troubleshooting
-26 "dust"
An output is below 10,000 sats. Bump the reference output or the change to ≥ 0.0001 TXC, or drop change into the fee.
-206 "Error with selected inputs"
Node wallet doesn't see UTXOs for the issuer address. Use the raw-tx path (scantxoutset + sign with WIF) instead of omni_sendissuancemanaged.
Invalid network version
You're decoding TXC keys/addresses with Bitcoin defaults. Use WIF version 0xc1, P2PKH version 0x42.
confirmed: false
Issuance tx hasn't mined yet. Wait a block (~check mempool.texitcoin.org) and click Confirm again.
401 bad signature
HMAC mismatch. Double-check you're signing the exact bytes you POST, using sha256, with the right hmac_secret for that terminal_id.
404 unknown card
The card_uid isn't registered. Provision it in the Console (or via the cards API) before tapping.
Spend went through for ~1e-6 of the intended amount
You scaled amount_cents using the token's display precision instead of on-chain 8 dp. For divisible Omni, always cents × 10^6 (i.e. 10n ** BigInt(8 - 2)). See Concept #5.
Reference
Glossary
- TXC
- Texit Coin — a Bitcoin-derived UTXO chain.
- Omni Layer
- Token protocol that embeds payloads in OP_RETURN outputs of TXC transactions.
- propertyId
- Numeric id Omni assigns to each token after the create-property tx mines.
- Managed token
- Omni token type whose issuer can mint and revoke supply (vs. fixed supply).
- WIF
- Wallet Import Format — base58check-encoded private key. TXC's version byte is 0xc1.
- scantxoutset
- Bitcoin RPC that scans the UTXO set for outputs matching a descriptor — works without a synced wallet.
- Dust
- An output too small to relay. On TXC the threshold is 10,000 sats (0.0001 TXC).
- HMAC
- Hash-based message auth code. Terminals sign requests with HMAC-SHA256(secret, body).
- Tap
- A single NFC-card payment event recorded by a terminal.
- Community / Event mode
- Community mode = push-to-phone approval; Event mode = pre-funded custodial ledger debited at tap time.
Further reading
External references
- texitcoin.org/build — TXC node + protocol docs.
- tokens.texitcoin.org — Omni-on-TXC token explorer.
- mempool.texitcoin.org — block + transaction explorer.
- github.com/OmniLayer/spec — Omni payload spec (the bytes inside OP_RETURN).
Living doc — edits land here as we wire new flows (burn, revoke, L2 bridges, fair.money settlement). Spot something wrong or unclear? Open an issue.