bridge: the TypeScript twin, and Cardano can be watched

Two things, and the first is the one that mattered.

THE TWIN. app/server had the same default-to-eth I removed from the Go
side an hour ago — twice, in keygen and in the deposit check. Fixing one
language and leaving the other is the "one fact, two places, one fixed"
shape this session keeps turning up, and app/server is the one actually
deployed at bridge-api.lux.network. Both now refuse: minting a wallet
for a network with no known address format produces an address on the
wrong chain, and checking one with the EVM checker reads an 0x address
on a chain that has none and reports "no deposit" forever rather than
reporting that it cannot look.

CARDANO CAN NOW BE WATCHED, so it goes back into the offered set. A
checker over Koios — public, read-only, no key — on both sides. The test
that refused Cardano an hour ago passes it now, which is the test doing
its job in both directions rather than only saying no.

Two details worth keeping: an address that has never been used is ABSENT
from a Koios response rather than present with zero, so an empty array
is "nothing here" and not an error. And lovelace arrives as a decimal
STRING because the value exceeds what a JSON number carries exactly —
compared as integers, since a float parse introduces rounding exactly at
the threshold where a deposit is judged sufficient.
This commit is contained in:
2026-08-30 17:10:41 -07:00
parent cea8ef5ad6
commit 13b6b73cbc
3 changed files with 117 additions and 9 deletions
+55 -2
View File
@@ -120,7 +120,18 @@ export async function createMPCWalletForDeposit(
}
const vaultId = process.env.BRIDGE_MPC_VAULT_ID || "bridge"
const walletName = `bridge-${networkInternalName.toLowerCase()}-${Date.now()}`
const addrType = NETWORK_ADDRESS_TYPE[networkInternalName] || "eth"
// Refuse rather than guess. This defaulted to "eth", which was safe only
// while every supported chain was an EVM — untrue since Bitcoin. An unlisted
// non-EVM network silently became an EVM one and the user was handed an 0x
// deposit address for a chain that has never seen one. The same default was
// removed from internal/mchain on the Go side; this is its twin.
const addrType = NETWORK_ADDRESS_TYPE[networkInternalName]
if (!addrType) {
throw new Error(
`No address format is known for ${networkInternalName}. Minting a deposit ` +
"wallet would produce an address on the wrong chain.",
)
}
// Pick the right keygen protocol/curve for the destination chain.
// m-chain supports CGGMP21 (secp256k1) and FROST (ed25519); the bridge
@@ -258,7 +269,14 @@ export async function checkNativeDeposit({
asset: string
requiredAmount: number
}): Promise<boolean> {
const addrType = NETWORK_ADDRESS_TYPE[networkInternalName] || 'eth'
// Same refusal as keygen. Checking an unknown network with the EVM checker
// reads an 0x address on a chain that has none and reports "no deposit"
// forever, rather than reporting that it cannot look.
const addrType = NETWORK_ADDRESS_TYPE[networkInternalName]
if (!addrType) {
logger.warn(`Deposit check refused: no address format for ${networkInternalName}`)
return false
}
try {
switch (addrType) {
@@ -266,6 +284,8 @@ export async function checkNativeDeposit({
return await checkEVMDeposit(networkInternalName, address, asset, requiredAmount)
case 'btc':
return await checkBTCDeposit(networkInternalName, address, requiredAmount)
case 'ada':
return await checkADADeposit(networkInternalName, address, requiredAmount)
case 'sol':
return await checkSOLDeposit(networkInternalName, address, asset, requiredAmount)
case 'ton':
@@ -310,6 +330,8 @@ const RPC_URLS: Record<string, string> = {
TON_TESTNET: 'https://testnet.toncenter.com/api/v2',
// Polkadot / Substrate
POLKADOT_MAINNET: 'https://rpc.polkadot.io',
// Koios: public, read-only, no key. Not a JSON-RPC endpoint — a REST base.
CARDANO_MAINNET: 'https://api.koios.rest/api/v1',
}
async function checkEVMDeposit(network: string, address: string, asset: string, requiredAmount: number): Promise<boolean> {
@@ -346,6 +368,37 @@ async function checkBTCDeposit(network: string, address: string, requiredAmount:
return balanceBtc >= requiredAmount
}
/**
* Cardano deposits, via Koios — a public read-only API over the chain, with no
* key required.
*
* `/address_info` returns the address's balance in lovelace, and ADA has six
* decimals. An address that has never been used is absent from the response
* rather than present with zero, so an empty array is "nothing here" and not
* an error.
*/
async function checkADADeposit(network: string, address: string, requiredAmount: number): Promise<boolean> {
const apiBase = RPC_URLS[network]
if (!apiBase) return false
const resp = await fetch(`${apiBase}/address_info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _addresses: [address] }),
})
if (!resp.ok) return false
const rows = (await resp.json()) as Array<{ balance?: string }>
if (!Array.isArray(rows) || rows.length === 0) return false
// Lovelace is a string because the value exceeds what a JSON number holds
// exactly. Parsing it as a float to compare against a float amount is the
// ordinary rounding hazard; BigInt against a scaled integer is not.
const lovelace = BigInt(rows[0]?.balance ?? '0')
const required = BigInt(Math.round(requiredAmount * 1e6))
return lovelace >= required
}
async function checkSOLDeposit(network: string, address: string, asset: string, requiredAmount: number): Promise<boolean> {
const rpc = RPC_URLS[network]
if (!rpc) return false
+12 -7
View File
@@ -264,13 +264,14 @@ networks:
txExplorerTpl: https://polkascan.io/polkadot/extrinsic/{addr}
addrExplorerTpl: https://polkascan.io/polkadot/account/{addr}
# CARDANO is not offered yet, and the gap is the deposit side rather than
# the address. The CIP-19 encoder exists (app/server cardano-address.ts,
# verified against its own vectors) and internal/mchain names the ADA
# family — but internal/depositcheck has no Cardano checker and no RPC, so
# a deposit to a correct address would be watched for by nobody and the
# swap would wait forever on money that had already arrived. It goes in
# when the checker does.
- internalName: CARDANO_MAINNET
displayName: Cardano
nativeCurrency: ADA
type: cardano
isFeatured: false
avgCompletion: 2m
txExplorerTpl: https://cardanoscan.io/transaction/{tx}
addrExplorerTpl: https://cardanoscan.io/address/{addr}
tokens:
# The chains Lux runs — native coin only. Liquid tokens get added per
@@ -475,6 +476,10 @@ tokens:
name: Polkadot
decimals: 10
network: POLKADOT_MAINNET
- asset: ADA
name: Cardano
decimals: 6
network: CARDANO_MAINNET
limits:
minUSD: 10
+50
View File
@@ -43,6 +43,7 @@ import (
"errors"
"fmt"
"io"
"math"
"math/big"
"net/http"
"strings"
@@ -97,6 +98,8 @@ var rpcURLs = map[string]string{
"TON_TESTNET": "https://testnet.toncenter.com/api/v2",
// Polkadot
"POLKADOT_MAINNET": "https://rpc.polkadot.io",
// Cardano (Koios REST — note: NOT JSON-RPC)
"CARDANO_MAINNET": "https://api.koios.rest/api/v1",
// XRP Ledger (rippled JSON-RPC). Same public clusters as the outbound
// broadcaster (internal/broadcast/client.go) — s1.ripple.com is the
// load-balanced mainnet cluster, s.altnet the Ripple-hosted testnet;
@@ -231,6 +234,8 @@ func (c *Client) Check(ctx context.Context, p CheckParams) (bool, error) {
return c.checkTON(ctx, url, p.Address, p.RequiredAmount)
case mchain.AddressTypeXRP:
return c.checkXRP(ctx, url, p.Address, p.RequiredAmount)
case mchain.AddressTypeADA:
return c.checkADA(ctx, url, p.Address, p.RequiredAmount)
case mchain.AddressTypeDOT:
return false, ErrSubstrateNotImplemented
default:
@@ -443,6 +448,51 @@ func (c *Client) checkSOL(ctx context.Context, rpcURL, address, _asset string, r
// TON — TON Center REST `/getAddressBalance`
// =============================================================================
// checkADA reads an address's balance from Koios, a public read-only API over
// Cardano that needs no key.
//
// An address that has never been used is ABSENT from the response rather than
// present with a zero balance, so an empty array means "nothing here" and not
// an error.
func (c *Client) checkADA(ctx context.Context, apiBase, address string, requiredAmount float64) (bool, error) {
body := `{"_addresses":["` + address + `"]}`
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(apiBase, "/")+"/address_info", strings.NewReader(body))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient().Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("koios: status %d", resp.StatusCode)
}
var rows []struct {
Balance string `json:"balance"`
}
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
return false, err
}
if len(rows) == 0 {
return false, nil
}
// Lovelace is a decimal string because the value exceeds what a JSON
// number carries exactly. Comparing as integers avoids the rounding a
// float parse would introduce at the threshold.
lovelace, ok := new(big.Int).SetString(rows[0].Balance, 10)
if !ok {
return false, fmt.Errorf("koios: balance %q is not an integer", rows[0].Balance)
}
required := big.NewInt(int64(math.Round(requiredAmount * 1e6)))
return lovelace.Cmp(required) >= 0, nil
}
func (c *Client) checkTON(ctx context.Context, apiBase, address string, requiredAmount float64) (bool, error) {
url := strings.TrimRight(apiBase, "/") + "/getAddressBalance?address=" + address
// TON Center returns balances as a *string* (decimal), not a number;