luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.
Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
The rename to /v1/chain is real in luxd source, but the deployed fleet has not
taken it yet: probed just now, api.lux.network, api.lux-test.network,
api.zoo.network and api.hanzo.network each answer 200 on /v1/bc/C/rpc and 404 on
/v1/chain/C/rpc. Composing /v1/chain against those hosts reaches nothing, which
is the failure the rename set out to prevent.
The move belongs with the node redeploy, not ahead of it.
luxd serves chain routes under /v1/chain; the /v1/bc prefix it replaced is
gone, so anything composing it reaches nothing. Lines that record an
observation - a probe, a status code, a past release - keep the prefix they
were written against, as do URLs on hosts that are not ours.
The bridge settled to Lux and Zoo but not to the other three chains Lux
runs, so the set it offered was smaller than the set the exchange lists.
Each is an EVM L1 whose custody address is the same secp256k1 key at the
same address it already derives, so a crossing needs no new key material.
A network reaches a user through five tables that are edited separately —
address family, deposit endpoint, broadcast endpoint, assembler endpoint,
asset row — and a chain in four of them fails after the user has already
sent funds. estate_test reads all five and the served config, so the next
chain either arrives everywhere or fails here.
The assembler's chain ids move out of main into evm.go: an EIP-155 id is
what goes into the signature, and a wrong one is valid somewhere else.
Native symbols are the ones each chain's own registry reports — Hanzo's
coin is AI. Zoo was serving live but missing from the checked-in config.
Co-authored-by: Hanzo Dev <[email protected]>
Port of f3e25de from preserve/lux-bridge-golive onto main's
architecture. A BTC release now parks in
bridge_transfer_pending_confirmation on mempool admission and only
completes once mined; a release unconfirmed past --btc-confirm-timeout
(or fee-rejected at submit) is reset for re-sign at a bumped RBF
feerate — PreSign takes MinFeeRateSatPerVB as a floor over the live
estimate, and the paid rate persists on the swap (LastFeeRate) so each
rebuild strictly out-bids the last (BIP-125). Rebuild cycles share one
ceiling (default 5); past it the swap fails loudly. The broadcast
client answers confirmation status from the same mempool.space base it
posts to; /metrics gains bridge_btc_confirm_checks_total /
_timeouts_total / bridge_broadcast_rebuilds_total.
luxd serves exactly one HTTP prefix — /v1 (node server/http/server.go
baseURL). /ext/ was removed some releases ago, so every Lux and Zoo URL
in this repo was pointing at a 404.
Measured 2026-07-27:
api.lux.network/v1/bc/C/rpc 200 eth_chainId 0x17871 (96369)
api.lux.network/ext/bc/C/rpc 404
api.lux-test.network/v1/bc/C/rpc 200 eth_chainId 0x17870 (96368)
api.zoo.network/v1/bc/C/rpc 200 eth_chainId 0x30e08 (200200)
api.zoo-test.network/v1/bc/C/rpc 200 eth_chainId 0x30e09 (200201)
control api.lux.network/v1/bc/ZZZNOPE/rpc 404
ZOO_MAINNET/ZOO_TESTNET were doubly wrong: legacy prefix AND a "Z" chain
alias that 404s on both prefixes. Zoo's EVM answers under bc/C.
Avalanche endpoints deliberately keep /ext/ — that is their API, not
ours (measured: api.avax.network/ext/bc/C/rpc 200 0xa86a, /v1 404).
B-chain/T-chain URLs move to /v1 as well; BridgeVM is still undeployed
(re-verified today: /v1/bc/B/rpc 404 while /v1/bc/C/rpc 200, so the 404
is "no such chain", not "no such prefix").
Squash of the local main lineage onto the canonical origin/main. Net tree only
(62 files): stale docs pages removed, tenant config + tests updated, minor Go
fixes, refreshed opnet-watcher build. No third-party white-label branding in
code or history.
Co-authored-by: Hanzo Dev <[email protected]>
Makes XRP withdrawal safe to enable (testnet on; mainnet staged-gated) by closing
the fund-loss + gate-bypass vectors a red/blue loop surfaced over three rounds.
DESTINATION TAG (else a tag-less payout to an exchange pool is lost):
- internal/xrpl: ValidateDestination (reject X-addresses + validate r-address);
parseAccount recover-wrapper (fixes a rippledata base58 panic-DoS reachable in
the signing goroutine); tag set on the tx BEFORE SigningHash so it is committed
into the SHA-512Half the MPC signs (unstrippable, proven).
- internal/txassembler: XRPSpec.DestinationTag *uint32 -> PaymentUnsigned.
- cmd/bridge: Swap.DestinationTag, createSwapReq accepts+range-checks it [0,2^32),
signOneXRP threads it + re-validates the destination (terminal-fail on bad addr).
AUTHORITATIVE WITHDRAWAL GATE (was cosmetic — read only by the SPA list):
- config.go: WithdrawalEnabled/DepositEnabled now enforced server-side. Case-
INSENSITIVE (network,asset) match + default-DENY for wired non-EVM families
(BTC/SOL/TON/XRP/DOT) so an asset-case variant ('xrp'), empty asset, or network-
case variant ('bitcoin_testnet') can't dodge a disabled row into a release pool
(gate key now aligns with the network-only, case-folded signer routing). EVM
stays default-on.
- Enforced at CREATE (swapsCreateNative 403), SIGN (signOne skip = kill-switch),
BROADCAST (broadcastOne holds already-signed in-flight swaps), and the operator
INJECT path (swapsInjectRawTxNative 403). signOne also gains a defer-recover
backstop so one swap's panic can't crash the daemon.
Tests: tag carried+committed+on-wire; X-addr/bad-addr/panic-input rejected; tag
range boundaries; gate variant-bypass closed; create/sign/broadcast/inject all
honor the gate; kill-switch holds then resumes. cmd/bridge + xrpl + txassembler
green, full module green, vet clean.
KNOWN FOLLOW-UPS (pre-existing, bridge-wide, NOT XRP-specific; tracked):
- /admin/swaps/:id/{reset,inject-raw-tx} have no auth middleware (bounded by
64-bit random swap IDs + the now-gated inject). Gate behind operator token/mTLS.
- GET /v1/bridge/swaps has no tenant scoping (IDOR). Both are mainnet-readiness
items; mainnet XRP withdrawal stays isWithdrawalEnabled:false until addressed +
a live testnet tag-carrying-payout proof.
- Remove / / doc-comment mentions
- Remove 'liquid' from SupportedChains allowlist (-specific
chain identifier per CLAUDE.md, not a generic chain family)
- Update tenant config example strings and tests accordingly
- Trim BRAND-AUDIT.md to drop specific references while
keeping the foreign-brand rule
Consolidates origin/z/bridgev2-ton-broadcast — final of the five chain
broadcaster branches. With TON wired, all six families (EVM, BTC, SOL,
DOT, XRP, TON) are now implemented end-to-end.
Conflicts resolved:
- internal/broadcast/client.go: TON case added alongside the existing
five; ErrFamilyNotImplemented is now reserved for future families
- cmd/bridge/release_pool.go: dropped TON agent's separate Wallet field
shape (ECDSAPubKey string + EDDSAPubKey string) in favor of the
merged shape from earlier chains:
* Wallet.ECDSAPubKey stays []byte (BTC's choice — XRP/DOT decode hex)
* Wallet.EDDSAPubKeyHex added as hex string (TON's choice — uniform
with the ed25519-only wallet families)
ReleasePoolEntry now carries both ECDSAPubKey (hex) and EDDSAPubKey
(hex), plus AddressType (mchain type — distinct from Family string)
- cmd/bridge/signing_driver.go: TON dispatch added to the family
switch in signOne after the SOL+XRP branches but before the DOT/EVM
assembler arm; threaded tonAssembler + tonUnsigned through the
PreSign/MPC-sign/Finalize flow with TON-specific gas pre-check;
lookupPoolEntry helper added for fetching the EDDSAPubKey alongside
the address; tonAmountToNano + isTONNativeAsset utilities
- cmd/bridge/main.go: TON assembler wired through signer.SetTONAssembler
alongside EVM/SOL/DOT; balance probe TON endpoints + API keys wired
via networks.yaml + balanceProbe.TONBalanceURLs/TONAPIKeys
- cmd/bridge/balance_probe.go: TON branch added to BalanceAt switch
(calls balanceAtTON via GET getAddressBalance); SOL kept in the
dedicated branch; isLikelyNonEVMNetwork docstring updated to
acknowledge SOL+TON are routed elsewhere
- internal/mchain/client.go: Wallet.ECDSAPubKey stays []byte; new
EDDSAPubKeyHex string field for Ed25519 wallets (TON); pickAddress
TON case derives v4r2 raw address from EDDSAPubKey when present,
falls back to SOLAddress slot for legacy mpcd
- go.mod: added xssnick/tonutils-go + ed25519 dependency chain; took
golang.org/x/crypto v0.51.0 + golang.org/x/sys v0.44.0 (TON's
newer minimums)
- internal/broadcast/client_test.go: TestBroadcast_FamilyNotImplemented
retired — every address type now has a concrete broadcaster
- Test refactors: ReleasePool tests use NewReleasePoolForFamily +
PutEntry(family, idx, entry); FamilyTON constant added
Consolidates origin/z/bridgev2-xrp-broadcast. Conflicts resolved:
- internal/broadcast/client.go: XRP case added alongside EVM/BTC/SOL/DOT;
ErrFamilyNotImplemented now lists only TON as pending
- cmd/bridge/release_pool.go: dropped XRP agent's PubKeyHex field +
AcquireForFamily/SizeByFamily/Reload accessors that re-implemented
family routing inside ReleasePool. Kept BTC's family-keyed store
approach (LoadEntries(ctx, family)) as the single source of truth.
XRP pubkey persistence rides on ReleasePoolEntry.ECDSAPubKey (hex
string field added by DOT merge — XRP signing driver reads the same
field for its SigningPubKey embedding). Added FamilyXRP constant.
- cmd/bridge/release_pool_set.go: added XRP slot to ReleasePoolSet +
Size/FamilySizes/PoolFor/poolFor coverage
- cmd/bridge/signing_driver.go: XRP dispatch added before EVM/DOT
assembler path; threaded poolEntry through both poolSet acquire and
legacy fallback acquire so signOneXRP can read poolEntry.ECDSAPubKey
- cmd/bridge/main.go: XRP release pool wired through releasePoolSet.XRP
via the same NewReleasePoolForFamily(store, FamilyXRP, …) shape as
BTC/SOL/DOT — no more shared-store-with-different-indices hack
- internal/mchain/client.go: Wallet.ECDSAPubKey stays []byte (BTC's
choice) — XRP consumers hex-encode when persisting. XRP r-address
derivation runs in pickAddress when ECDSAPubKey present, falls back
to ETHAddress otherwise (matches the merged-state shape-only tests)
- cmd/bridge/broadcast_driver.go: humanizeBroadcastErr merges BTC's
txn-* rejections with XRP's tecINSUFFICIENT_FUNDS / tecUNFUNDED
- go.mod: added rubblelabs/ripple + bits-and-blooms/bitset to
union of all branches' deps
- Test refactors: ECDSAPubKeyHex → ECDSAPubKey ([]byte); PutEntry takes
family arg; XRP-specific tests using AcquireForFamily/SizeByFamily
replaced with TestReleasePool_XRPFamily_BootstrapAndAcquire matching
the BTC family-pool shape
Consolidates origin/z/bridgev2-sol-broadcast. Conflicts resolved:
- internal/broadcast/client.go: keep BTC + SOL cases in family switch
- cmd/bridge/signing_driver.go: BTC dispatch first, then SOL branch
- cmd/bridge/main.go: BTC pool block + SOL pool block + multi-family
router (releasePoolSet) that BOTH families register into
- go.mod/go.sum: union of dependencies, tidy applied
- Replaced SOL agent's NewInMemoryStoreFamily wrappers with BTC agent's
family-scoped ReleasePoolStore (LoadEntries(ctx, family)) — single
source of truth, no parallel wrappers
- release_pool_family_store.go: deleted (redundant with BTC design)
- FamilySOL constant added alongside FamilyEVM + FamilyBTC
End-to-end Polkadot integration in the cmd/bridge binary:
cmd/bridge/dot_signing.go (new):
- DOTChainContext interface: AccountNextIndex, RuntimeVersion,
GenesisHash, FreeBalance — the four substrate RPC queries the
signing payload + gas pre-check need.
- LiveDOTChainContext: production JSON-RPC impl. Caches GenesisHash
forever (stable per chain), RuntimeVersion for 30s (refreshes on
runtime upgrades). Reuses the broadcast URL overrides — single
--source-rpc-overrides flag covers every leg.
- preSignDOT: wires PreSign(DOTSpec) on the signing driver — resolves
pubkey via release pool, refreshes assembler config from live chain
state, builds the signing-payload hash to feed the MPC.
- dotGasPrecheck: substrate analogue of the EVM eth_getBalance check.
Required = transfer_value + fee + existential_deposit; insufficient
short-circuits the swap to failed_insufficient_release_gas before
burning the 75s MPC ceremony.
- extractDOTValuePlanck: parses a Compact<Balance> trailing the call
bytes (so the gas pre-check has the exact transfer value).
signing_driver.go:
- New dotAssembler + dotChainCtx + dotPool slots.
- Family-aware dispatch in signOne: DOT swaps route through preSignDOT
+ the substrate Finalize path; EVM swaps stay on the EIP-155 path.
- Both ParseRSV (EVM) and ParseDOTSignature (substrate) live in
txassembler — the driver picks the right one by checking which
*Unsigned came out of PreSign.
- ReleasePubKey persisted at claim time so Finalize can determine the
ECDSA recovery byte.
release_pool.go + zap_store.go + InMemoryStore:
- ReleasePoolEntry.ECDSAPubKey: persisted with each entry so the DOT
signing path doesn't need a fresh keygen lookup.
- ReleasePoolStoreWithKeyspace: optional interface adding LoadEntriesNS /
PutEntryNS. Lets the bridge run parallel pools without colliding on
the legacy "releasepool:NNN" key prefix. ZapStore + InMemoryStore
both implement it.
- NewReleasePoolWithKey: constructor for keyspace-isolated pools (the
DOT pool uses "releasepool:dot" as its namespace).
- PubKeyHex(walletID): pool-side lookup the DOT signing path uses.
cmd/bridge/main.go:
- New CLI flags: --release-pool-dot-size (default 3),
--release-pool-dot-mint-network (default POLKADOT_TESTNET),
--release-pool-dot-threshold-planck (default 1e11 = 10 DOT).
- Per-network DOTAssembler config for POLKADOT_MAINNET (prefix 0,
decimals 10, CallIndex 5/3), POLKADOT_TESTNET / Westend (prefix 42,
decimals 12, CallIndex 4/3), KUSAMA_MAINNET (prefix 2).
- Wires LiveDOTChainContext + the DOT pool into the signing driver
alongside the EVM pool.
txassembler/rlp.go:
- FloatToBaseUnits: exported alias for floatToWei. The DOT path
reuses the same human-amount → integer scaling helper as the EVM
path; this just makes it importable from cmd/bridge.
swap_store.go:
- Swap.ReleasePubKey: new field carrying the compressed secp256k1
pubkey for the chosen release wallet. Required by the DOT signing
path to derive AccountId + recovery byte at Finalize.
Tests:
- TestSigning_DOT_InsufficientBalance_ShortCircuits: end-to-end
driver tick, balance 0 -> failed_insufficient_release_gas without
hitting the MPC.
- TestSigning_DOT_WithoutDOTAssembler_FallsBackToPlaceholder:
legacy behaviour preserved when DOT assembler unwired.
- TestSigning_DOT_PreSign_QueriesChainContextEachTick: verifies all
three chain-context calls (nonce, runtime version, genesis hash)
happen on every PreSign.
- TestSigning_DOT_ChainContextError_RollsBack: transient RPC errors
re-queue the swap.
- TestReleasePool_KeyspaceIsolation: default + DOT pools see only
their own entries.
- TestReleasePool_PubKeyHexRoundtrip: pubkey persistence works.
- TestZapStore_NamespacedPool: disk-side keyspace isolation works
(including the legacy-prefix overlap fix in LoadEntriesNS).
- TestExtractDOTValuePlanck: compact-int decode all four modes.
internal/txassembler/xrp.go:
- XRPProvider interface + per-network XRPNetwork config (LastLedgerWindow,
fee override, canonical-sig flag).
- Assembler.PreSignXRP: pulls Sequence + suggested fee from rippled,
embeds the release wallet's compressed-secp256k1 pubkey, returns
the 32-byte SHA-512Half (via internal/xrpl) the MPC quorum signs.
- Assembler.FinalizeXRP: parses (r, s) from the MPC's 65-byte R||S||V
hex (ignores recovery byte — XRPL doesn't need ecrecover), DER-encodes
with low-s canonicalization, attaches as TxnSignature, returns the
wire blob (hex) + canonical XRPL tx hash.
- XRPRequiredDrops: amount + fee + 10-XRP reserve, used by the gas
pre-check to refuse signing when the wallet can't cover.
- xrpFloatToDrops with the same string-format trick floatToWei uses,
avoiding IEEE noise at 6-decimal native XRP precision.
internal/mchain/client.go:
- Wallet now carries ECDSAPubKeyHex (compressed-secp256k1 hex). EVM
paths ignore it; XRP signing needs it on the wire.
- pickAddress(XRP) derives the canonical r-address from the ECDSAPubKey
via internal/xrpl.AddressFromPubKey instead of falling back to the
ETH slot. Fallback retained for legacy mocks without the pubkey field.
cmd/bridge/xrp_provider.go:
- HTTP wrapper over rippled JSON-RPC (account_info, fee). Returns
(Sequence, validated_ledger_index) and the open_ledger_fee/median
fee / base_fee chain. Treats actNotFound on balance lookup as
"wallet not activated → balance 0" so the gas pre-check correctly
reports an unactivated release wallet.
cmd/bridge/release_pool.go:
- ReleasePoolEntry gains Family + PubKeyHex fields (round-trips through
JSON; pre-XRP entries leave them empty).
- AcquireForFamily filters entries by family — the signing driver now
picks an XRP wallet for XRP destinations and an EVM wallet for EVM.
Falls back to family-agnostic Acquire when no per-family match.
- SizeByFamily + Reload helpers so a parallel XRP-pool bootstrap can
share the same store as the EVM pool without re-indexing.
cmd/bridge/signing_driver.go:
- signOne dispatches on mchain.AddressTypeFor(DestinationNetwork). XRP
routes to signOneXRP, which mirrors the EVM ceremony but: builds a
Payment via Assembler.PreSignXRP, runs an XRP gas pre-check (balance
vs amount+fee+reserve) instead of eth_getBalance, hands the
SHA-512Half digest to the MPC, then Assembler.FinalizeXRP emits the
signed wire blob.
- xrpGasPrecheck short-circuits to SwapStatusFailedInsufficientReleaseGas
with a humanized "balance=X drops, required=Y drops" reason. Probe
failures DON'T block the swap (best-effort, broadcast layer still
retries on transient rippled flakes).
cmd/bridge/main.go:
- New flags: --xrp-release-pool-size (default 5),
--xrp-release-pool-mint-network (default XRP_TESTNET),
--xrp-release-balance-threshold-drops (default 10 XRP).
- XRPProviderClient instantiated once, shared between the assembler
(for PreSignXRP) and the gas pre-check (via assembler.XRPProviderFor).
- XRP pool minted on top of the existing EVM pool (via a helper-pool
bound to the same store + Reload), so a single ReleasePool object
serves both families.
- /health surfaces pool sizes broken out by family.
Tests:
- xrpl: address derivation round-trip, bad-pubkey rejection, DER
encoding + low-s flip, PreSign digest matches SHA512Half(STX||body),
full PreSign→Finalize round-trip through the rubblelabs parser.
- broadcast: tesSUCCESS happy path, terQUEUED retryable, tecINSUFFICIENT_FUNDS
+ tecNO_DST fatal, temBAD_AMOUNT malformed, telINSUF_FEE_P retryable,
rippled high-level errors, classifyEngineResult table.
- txassembler: PreSignXRP populates Sequence / Fee / LastLedger,
fee floor + pin, bad-input rejection, FinalizeXRP round-trip,
XRPRequiredDrops math, xrpFloatToDrops table.
- mchain: XRP r-address derivation from ECDSAPubKey, legacy fallback
to ETH slot, bad-hex rejection.
- release pool: XRP-family Bootstrap + entry shape + PubKey roundtrip,
AcquireForFamily filtering, no-match returns ErrEmptyPool.
- signing driver: XRP happy path advances to broadcasting with
populated DestRawTx, insufficient balance short-circuits, missing
PubKeyHex rolls back with readable LastError.
- xrp_provider: account_info / fee / balance stubs, actNotFound → 0,
rippled error surfacing.
Wire-correct BTC release tx assembler that the MPC quorum can
threshold-sign and the broadcast driver can push. Mirrors the EVM
PreSign/Finalize shape but emits N per-input sighashes (one MPC
sign call per input) instead of one EIP-155 digest.
Scope (v1):
- P2WPKH witness-v0 only (bech32 bc1q.../tb1q...); P2TR deferred
- SIGHASH_ALL only (no anyonecanpay / single)
- Greedy largest-first UTXO selection — adequate for the bridge's
own pool wallet, no churn concerns
- Single change output back to the release address (dropped under
the 546 sat dust threshold)
- BIP143 sighash via btcd/txscript.CalcWitnessSigHash; scriptCode
computed from the P2WPKH pkScript via the canonical
OP_DUP OP_HASH160 <pkh> OP_EQUALVERIFY OP_CHECKSIG transform
- Fee estimation: mempool.space halfHourFee with fastestFee /
minimumFee fallback; static fallback rate 10 sat/vB
- Low-s canonicalization in Finalize (BIP146); high-s sigs are
refused by relay nodes
- DER encoding: canonical with 0x00 leading-pad when high bit set
- RBF-friendly sequence (0xfffffffd) on every input
What's NOT in this commit:
- PSBT wire format — we use raw wire internally; PSBT is a
hardware-wallet convenience irrelevant in-process
- Bitcoin Core RPC — operators can override broadcast URL but the
default uses mempool.space's REST API (zero-setup)
New types:
- BTCNetwork, BTCSpec, BTCUTXO, BTCInputCtx, BTCUnsigned, BTCECDSASig
- BTCAssembler with UTXOFetcher / FeeEstimator / BalanceProbe seams
- MempoolSpaceClient implements all three from public REST endpoints
Tests: 30+ across selectBTCUTXOs (greedy correctness, insufficient
funds, fee scaling), p2wpkhScriptCode, chainHashFromTxID round-trip,
encodeDERSignature (low-s + high-bit padding + zero rejection),
ParseBTCRSV, PreSign happy path + dust handling + insufficient funds
+ pubkey/address validation, Finalize witness layout + sig count
mismatch, MempoolSpaceClient UTXO/fee/balance shape parsing
Add internal/txassembler/dot.go — the DOT analogue of the EVM Assembler.
Pipeline:
1. DOTSpec ── PreSign ──> DOTUnsigned (call bytes + signing payload)
2. DOTUnsigned + (r,s,v) ── Finalize ──> raw_extrinsic_hex + extrinsic_hash
PreSign behaviour:
- decode recipient SS58, refuse cross-prefix transfers (operator
typo'ing a mainnet → testnet address surfaces loudly).
- SCALE-encode balances.transfer_keep_alive(MultiAddress::Id(dest),
Compact<value>) using the per-network CallIndex.
- derive signer AccountId32 from compressed pubkey via
blake2_256(pub) — substrate ECDSA convention.
- build ExtrinsicPayloadV4 = call || era(immortal) || nonce ||
tip || spec_version || tx_version || genesis_hash || block_hash;
blake2_256-hash it if it exceeds 256 bytes.
Finalize behaviour:
- canonicalize low-s and (if hint was definite) flip v.
- If recoveryHint ∈ {0,1}: try only that v.
If 0xff: try both v=0 and v=1, pick the one whose recovered
pubkey verifies the signature against the signer pubkey.
Neither matching → "sign context wrong" (the MPC signed the
wrong payload, or the wrong key share was used).
- Wrap in MultiSignature::Ecdsa (tag 0x02) || 65-byte (r||s||v).
- Assemble v4 signed extrinsic via substrate.AssembleSignedExtrinsic.
dot_crypto.go: self-contained pure-Go secp256k1 ECDSA verify (math/big
only). About 100 lines: curve params, affine point arithmetic,
compressed-pubkey decoding via y = x3^((P+1)/4), standard ECDSA verify.
No CGO; runs cross-arch.
Tests cover PreSign happy-path with golden call bytes, prefix-mismatch
refusal, bad-input rejection, Finalize round-trip with both an explicit
and an auto-discovered recovery byte, wrong-signer-fails-both,
ParseDOTSignature normalization (27/28 → 0/1, unknown → 0xff), and the
ECDSA verify self-check against known scalars.
internal/broadcast/ton_test.go:
- Happy path: hex payload → base64 on wire, X-API-Key header set
per-network, response hash returned verbatim.
- Base64 payload pass-through (skip the hex re-encode).
- Error classification matrix:
429 / "rate" → ErrTONRateLimited (retryable)
"invalid boc" / "wrong signature" → ErrTONMalformedBOC (fatal)
"account not initialized" → ErrTONAccountNotInitialized (re-build w/ state_init)
HTTP 502/503/504 → TONError{Retryable:true}
- Family-dispatch parity: TON_TESTNET must NOT trip ErrFamilyNotImplemented.
- Direct parser tests (no HTTP) for non-JSON 5xx + hash-less success
payloads (synthesized "ton:sendBoc:ok:200" identifier).
- isHexString + payload normalizer unit tests.
internal/txassembler/ton_test.go:
- Address derivation: same pubkey ⇒ same address (deterministic),
different pubkeys ⇒ different addresses, UQ vs EQ flags surface
correctly via TONAddressFromPubKey / …BounceableFromPubKey, raw
form parses back to the same address (parse-roundtrip via
ParseTONAddress).
- PreSign native transfer: deterministic body hash under a fixed
clock, StateInit populated/skipped per SourceInitialized flag,
rejects nil / zero / negative amounts + bad pubkey + spec
SourceAddress mismatch.
- PreSign jetton: spec-provided JettonSourceWallet skips the
JettonProvider, provider path is invoked with the right
(network, master, owner) args, missing-provider + missing-master
both error cleanly.
- PreSign + FinalizeTONHex round-trip: locally sign u.SigHash with
Ed25519, finalize, decode the resulting BOC, verify
ExternalMessage.Body's first 64 bytes match the signature.
- Body schema: 1 ref (the internal message), 112 bits of header
data (subwallet 32 + valid_until 32 + seqno 32 + op 8 + mode 8).
- State_init hash matches the pubkey-derived address — confirms
the wallet contract we deploy on first send IS the wallet
contract whose address we keygen'd to.
- Static seqno provider drives v4r2 body's seqno field.
Add the wire-correct TON destination handler so the bridge can release
into TON_MAINNET / TON_TESTNET via TON Center.
internal/broadcast/ton.go:
- broadcastTON pushes a signed external-message BOC to TON Center's
sendBoc endpoint (mainnet: toncenter.com/api/v2/sendBoc; testnet:
testnet.toncenter.com/api/v2/sendBoc).
- X-API-Key header sourced per-network from Client.TONAPIKeys (set via
SetTONAPIKey or populated by main.go from networks.yaml). Required
at production rates — public gateway throttles to ~1 req/s.
- normalizeTONPayload accepts the hex shape the txassembler emits (to
match the EVM raw-tx convention) and converts to base64 for the
toncenter wire. Pre-base64 payloads pass through.
- parseTONResponse classifies error responses: 429 / "rate" →
ErrTONRateLimited, "account not initialized" / "state_init" →
ErrTONAccountNotInitialized, "invalid boc" / "wrong signature" →
ErrTONMalformedBOC (fatal — signer must re-build), other 5xx /
transport → TONError{Retryable: true}.
- Returns the in-message hash as BroadcastResult.TxHash. Callers
resolve the on-chain tx hash later via getTransactions polling.
internal/txassembler/ton.go:
- Two-phase PreSign / Finalize parallel to the EVM path. PreSignTON
builds the wallet-v4r2 unsigned body cell (subwallet || valid_until
|| seqno || op=0 || message_mode || ref_to_internal_msg) and
surfaces its representation hash for the MPC Ed25519 quorum to
sign. FinalizeTON / FinalizeTONHex re-assemble with the 64-byte
signature, wrap in an external-in message (with state_init when
SourceInitialized=false so first sends self-deploy), and emit BOC
bytes (or base64) for the broadcast layer.
- Native TON transfer + jetton transfer (TRC-3 op=0x0f8a7ea5) under
one spec. Jetton path resolves the source's jetton-wallet address
off the master either via TONJettonWalletAddressProvider (live
runMethod) or operator-supplied TONSpec.JettonSourceWallet. Forward
TON convention 0.05 TON (50_000_000 nanoton) for gas — same
amount the body itself carries to the master.
- Wallet v4r2 (= tonwallet.V4R2) + sub-wallet 698983191 (=
tonwallet.DefaultSubwallet). messages_ttl=180s matches upstream.
- Address helpers: TONAddressFromPubKey (UQ), …Bounceable… (EQ),
TONRawAddress ("<workchain>:<hex>"), ParseTONAddress (accepts any
form). Exported so mchain.pickAddress can derive a real TON
address from EDDSAPubKey once mpcd surfaces it, and so the
deposit-watcher can match incoming-deposit addresses.
internal/broadcast/client.go:
- Add TONAPIKeys map on Client + AddressTypeTON dispatch case
routing to broadcastTON. ErrFamilyNotImplemented message updated
(BTC/SOL/XRP/DOT remain not implemented; EVM + TON are in).
Adds SOLAssembler — companion to the EVM Assembler — that constructs
Solana transactions for the bridge's destination-release flow:
- PreSign: fetch a recent blockhash via JSON-RPC getLatestBlockhash,
build a v0 message (one signer = the MPC release wallet as fee
payer, one recipient, lamport amount), serialize the bytes to be
signed. SPL token mode (when SourceMint is set) derives source +
destination ATAs via solana.FindAssociatedTokenAddress and probes
the destination ATA via getAccountInfo — if absent, the assembler
prepends a CreateAssociatedTokenAccount instruction so the release
wallet pays the ~2_039_280 lamport rent + creates the account.
- Finalize: slot the 64-byte Ed25519 signature into Transaction
.Signatures[0], serialize the full wire tx with MarshalBinary, and
base64-encode for JSON-RPC sendTransaction (encoding:base64). The
signature itself doubles as the canonical Solana "tx hash" — return
it base58-encoded for SDK consumption.
Dependency: github.com/gagliardetto/solana-go v1.20.0 — the de facto
standard Go client. The assembler stays decoupled from the broader
*rpc.Client beyond GetLatestBlockhash + GetAccountInfo, so the
broadcast layer can use its own JSON-RPC endpoint.
Helpers:
- LamportsFromFloat(amount, decimals): safe float → uint64 conversion
with overflow protection. 9dp for native SOL; varies for SPL.
- ParseSOLSignatureHex(s): decodes a 128-char hex string to a fixed
[64]byte buffer — used by the signing driver when the dashboard
returns the Ed25519 sig in hex form.
- SOLDefaultBlockhashURL(network): canonical RPC URL per network.
Tests (sol_test.go) cover:
- Native transfer happy path: instruction is system.Transfer at
Account[0] = payer; round-trip of MessageBytes through
Message.Unmarshal yields an identical message.
- SPL transfer with destination ATA missing: 2 instructions
(ata.Create, then token.Transfer), destination ATA correctly
derived and probed.
- SPL transfer with destination ATA present: only 1 instruction.
- Finalize round-trip: rawTx parses back through solana-go's
Transaction decoder with matching signature + payer + blockhash.
- Error paths: unknown network, zero amount, malformed payer addr.
- ParseSOLSignatureHex + LamportsFromFloat: corner cases.
Total: 12 SOL tests, all green.