MLXDocs
Transactions

Submitting

Markdown

Send a signed transaction to POST /api/tx; the reply is its record, usually already in a block, and GET /api/tx/<hash> tells you where it stands later.

Send a transaction

POSThttps://mlx.app/api/tx

The body is the signed transaction under tx. No key or session is needed: the signature is the authorization.

curl -X POST https://mlx.app/api/tx \
  -H 'content-type: application/json' \
  -d '{"tx":{"chain":"mlx-1","from":"luca.mlx","nonce":4,"ts":1793116800000,"pk":"7812b1435b317df04f45731ac508ae7774d4731ee0445fd46ccdcc5019faa524","body":{"type":"transfer","to":"shop.mlx","token":"USDC","amount":"2500000"},"sig":"ce8c7621b38777895b06107168e08a542bf9d5fbaf2ac53bb1b9a1b1d3ade2635d5c00150a2f76990b49330365d964c2b55d3289bbc9cc3e380550a2715cb10f"}}'

How to build and sign tx: Keys and signing. The example above uses a fixed old ts, so production refuses it; sign with Date.now().

ParameterTypeNotes
txobjectThe signed transaction: chain, from, nonce, ts, pk, body, sig.

What happens

  1. Checks at the door. The server checks the signature, the size, the chain id, the timestamp, that the wallet exists and that pk controls it, and that the nonce is ahead of the wallet's current one. A transaction that fails here is refused with an HTTP error and is not stored.
  2. Mempool. The transaction is stored as pending under its hash and queued.
  3. Block. The server tries to seal a block at once and waits a few seconds for the result. If another request is sealing at the same moment, or the transaction waits for an earlier nonce, it stays pending and a later run includes it.
  4. Reply. The record as it stands: success or failed once in a block, pending if the wait ran out first.

Sending the same signed transaction again is safe: it has the same hash, so the server answers from the stored record and never applies it twice. A record still pending well after it was received goes back into the mempool on a resend.

The reply

Response (a successful transfer)
{
  "hash": "2069d74fed709fd06538433002fa7e331c389f753709a62affc2179be2bf608d",
  "tx": {
    "body": {
      "amount": "5000000",
      "to": "luca.mlx",
      "token": "USDC",
      "type": "transfer"
    },
    "chain": "mlx-1",
    "from": "review.mlx",
    "nonce": 7,
    "pk": "53fbe3a2e4ee9ee2ad907174562e8f2fc9b2bb3e1074f8ce274935439422c4ab",
    "sig": "8c806f69381a9d190528ba2a15e42d6feadf0b184e4b77769fcb1c6413444d28be7196ab1bef8195bc16d904b030bb173dbcf24c8e1d2ed2b214564b28033e04",
    "ts": 1793018575027
  },
  "status": "success",
  "receivedAt": 1793018575432,
  "meta": { "country": "SE" },
  "effects": [{ "kind": "transfer", "token": "USDC", "from": "review.mlx", "to": "luca.mlx", "amount": "5000000" }],
  "height": 347,
  "index": 0
}

GET /api/tx/<hash> returns the same record and adds blockHash and blockTs; review.mlx is a demo wallet.

FieldTypeNotes
hashstringThe transaction hash, its id everywhere.
txobjectThe signed transaction as received.
statusstringpending, success or failed.
errorstringWhy it failed (only when failed).
heightintegerThe block it is in (not while pending).
indexintegerIts position in that block.
effectsarrayWhat it changed: kind (transfer, mint_node, reward, account, lock), token, from, to, amount, node, note, until. Empty when failed.
receivedAtintegerWhen the server first received it (ms).
meta.countrystringTwo-letter country of the sender's network, when known.
soldobjectbuy_node paid from the wallet balance only: the MLX sold for the seat, { mlx, usd } in base units.

Statuses

StatusMeaningNonce
pendingReceived and queued, not in a block yet.Not used yet.
successApplied in block height.The wallet's nonce is now this one.
failedRecorded in block height and changed nothing; error says why.Not used: sign the next transaction with the same nonce.

A transaction can be valid at the door and still fail in its block, for example when the balance it needs was spent by an earlier transaction in the same block. Such a failure is kept on the ledger for good, and shows in the wallet's history.

A failed transaction (trimmed to the fields that matter)
{
  "hash": "8ea20332f8a534dc257e5f0f60d47a9f4815046c9114254db1492dabe4d69a0c",
  "tx": {
    "body": { "amount": "5000000", "to": "luca.mlx", "token": "USDC", "type": "transfer" },
    "from": "review.mlx",
    "nonce": 8
  },
  "status": "failed",
  "error": "review.mlx has insufficient USDC",
  "effects": [],
  "height": 315,
  "index": 0
}

Nonces

  • Use the wallet's current nonce plus one. Read it from GET /api/account/<name> (account.nonce).
  • At the door, a nonce must be above the wallet's current one and at most a small window ahead of it; older nonces are refused ("Bad nonce: expected N, got M").
  • In a block, a wallet's transactions apply in nonce order, whatever order they arrived in.
  • A transaction whose nonce is ahead of the next one waits in the mempool for its predecessor, for up to the hold time (NONCE_HOLD_MS in lib/chain/ledger.ts). If the gap is still there after that, it is applied and fails with "Bad nonce".
  • create_account always has nonce 0, and system transactions are not held.

To send several transactions quickly, number them N+1, N+2, … and send them in any order. If one fails, every later one fails too ("Bad nonce") because the failed one didn't use its number: sign them again from the failed nonce.

Wait for inclusion

Most replies are already final. When the reply says pending, poll the record:

GEThttps://mlx.app/api/tx/2069d74fed709fd06538433002fa7e331c389f753709a62affc2179be2bf608d
ParameterTypeNotes
hashpathThe transaction hash (64 hex characters).

It returns the record above plus blockHash and blockTs once it is in a block, and 404 {"error":"Transaction not found"} for a hash the ledger has never seen.

Wait for a final status
async function waitForTx(hash, { everyMs = 2000, tries = 60 } = {}) {
  for (let i = 0; i < tries; i++) {
    const r = await fetch(`https://mlx.app/api/tx/${hash}`);
    if (r.ok) {
      const rec = await r.json();
      if (rec.status !== "pending") return rec;
    }
    await new Promise((ok) => setTimeout(ok, everyMs));
  }
  throw new Error("still pending");
}

If a transaction stays pending, send the same signed transaction again: that puts it back into the mempool without any risk of applying it twice. To prove a block is in the ledger's history, fetch its inclusion proof.

Limits

LimitValueReply
From one IP address, on each server instance40 a minute429 "Too many transactions. Wait a minute and try again."
From one IP address, across the network60 a minute429, the same message
From one wallet30 a minute429 "Too many transactions from this wallet. Wait a minute and try again."
Failed transactions from one wallet20 in an hour429 "Too many failed transactions from this wallet. Try again in an hour."
New wallets from one IP address10 in an hour429 "Too many new wallets from this network. Try again in an hour."

Only new transactions that fail in a block count toward the failed limit; resending one already on record doesn't. A transaction also has a maximum size ("Transaction too large"), far above any normal transfer. A body that isn't well formed (an unknown type, an amount that isn't a whole number of base units, a field that isn't a plain value) is refused at the door with 400 and the same message a block would give it, so it's never stored. See Rate limits.

Errors

Errors are JSON { "error": "<message>" }. A transaction refused at the door is not stored and uses no nonce.

StatusWhenExample message
400Body isn't JSONBody must be JSON: { tx }
400tx is missing fields, or body.type is missingMalformed transaction
400A body type a wallet can't sign, or a field that isn't a plain valueUnknown transaction type, Malformed transaction
400amount, amountIn, minOut or maxMlx isn't a whole number of base unitsAmount must be a positive integer string in base units
400Too largeTransaction too large
401Not signed by the wallet's own key (the signature doesn't check, pk isn't the wallet's, or the wallet doesn't exist), checked before any per-wallet limit is countedThis transaction isn't signed by luca.mlx's key.
400Another network's chain idWrong chain: expected mlx-1
400ts too far from the server's clockTransaction timestamp is too far from network time
400Nonce too old or too far aheadBad nonce: expected 5, got 9
400create_account: name rules, nonce, key, a name or key already on recordThat name is reserved., New accounts start at nonce 0, That public key can't hold a wallet, luca.mlx is already taken, This key already controls an account
400withdraw to a Solana token account instead of a wallet addressThat's a token account's address, not a wallet's. …
429A limit abovesee Limits

Everything else (balances, locks, seat rules, link requests) is checked in the block and comes back as status: "failed" with error. The messages per type are on Transaction types.