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.

Network: TXC mainnetTokens: Omni eco 1Auth: HMAC-SHA256

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.ts files 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.

  1. TXC is Bitcoin, mostly. Same UTXO model, same script opcodes, same RPC names (scantxoutset, sendrawtransaction, …). Only the network constants differ — addresses start with T, WIF private keys start with version 0xc1. See Network constants.
  2. Omni is a token layer that piggybacks on TXC. Each token transaction is a regular TXC transaction with an extra OP_RETURN output that the Omni node interprets. The magic byte is 0x42 (Omni calls it "exodus").
  3. 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.
  4. The server signs with one wallet. The TXC_WIF secret is the issuer's private key. We never custody end-user keys — users paste their own TXC address.
  5. Divisible Omni tokens are always 8 on-chain decimals. The precision declared at issuance (we default to 2 for 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 where 1 token = 100,000,000 base units. So $63.88 of a 1:1-USD-pegged token is 6,388,000,000 base units — not 6388. 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:

  1. Sign in to the Console and create a community. Pick a 3–5 char symbol (e.g. CROW).
  2. The server auto-broadcasts an Omni Create Property (Managed) tx. The community row stores the issuance_txid immediately.
  3. Wait ~1 confirmation, then click Confirm on the community row. The server reads back the assigned propertyId and stores it as token_property_id.
  4. Test the on-ramp at /crow-dollars-test: enter a USD amount + a recipient TXC address; the server grants the equivalent tokens.
  5. Provision a terminal in the Console. Use its terminal_id + hmac_secret to 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:

constants
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:    1

These 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:

env
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 — talk to the node directly
curl -u "$TXC_RPC_USER:$TXC_RPC_PASS" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"1.0","id":"1","method":"getblockchaininfo","params":[]}' \
  $TXC_RPC_URL

Keys

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.

pseudo
// 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..." address

Never custody user keys

End-user wallets are TXC addresses pasted by the user (from the TXC mobile/web wallet). We never hold their private 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:

src/lib/txc/raw-issuance.server.ts
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) -> txid

Defaults 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:

src/lib/issuance.functions.ts
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

Click Confirm as many times as you want. Until the tx mines, it returns { 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.

rpc
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

  1. Merchant terminal creates a charges row (amount in cents, currency, optional memo) and shows a QR encoding qr_token.
  2. User scans, opens /pay/$token. The page calls getPayTarget server fn which returns the merchant payout address, the community's property_id, and amount_units_str — a stringified BigInt of 8-dp base units (already correctly scaled, see Concept #5).
  3. 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 via sendrawtransaction.
  4. Server's recordChargePaid looks up the sending address in wallets, then sets charges.status='paid', tx_hash=<txid>, paid_by_wallet_id=<wallet.id>.

getPayTarget response

json
{
  "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)

omni payload (type 0)
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 string

The 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

ts
// 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.

POST/api/public/tapHMAC-SHA256

Headers

x-terminal-iduuidrequired

The terminal's id, issued in the Console when you provision the device.

x-terminal-signaturehex stringrequired

HMAC-SHA256(hmac_secret, raw_request_body), hex-encoded. Sign the exact bytes you POST — don't re-serialize.

content-typestring

application/json

Body

card_uidstring (1–64)required

The NFC card's UID as read by the terminal.

amount_centsinteger (1 – 10,000,000)required

Tap amount in cents. 250 = $2.50.

noncestring (8–128)required

Client-generated unique id for this tap. Replays with the same (terminal_id, nonce) return the original tap with replay: true.

Example request

bash
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"
javascript / node
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

200 OK — new tap
{
  "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
}
200 OK — replay (same nonce)
{ "tap": { … }, "replay": true }
errors
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

The server verifies HMAC against the raw request body string. If you sign an object then re-stringify with different key order or whitespace, the signature won't match. Build the JSON string once, sign it, send it.

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

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.